fix(api): name the refused fields on every shape rejection

Fifty-nine 422s raised off a Zod `safeParse` answered with one opaque
sentence and nothing a client could map onto a field, while the other
166 sent `details.issues` — the multi-issue envelope that exists so a
caller with three bad fields fixes all three in one round-trip. Twelve
of the fifty-nine put a dotted machine token in `error` where the
sentence belongs, so `error` was sometimes text to show a person and
sometimes a token that must not be shown, with nothing in the payload
to tell them apart.

Every one of them now answers through `apiValidationError`, which keeps
the existing `error` string and the existing `meta` byte for byte and
adds the issue list beside them. The dotted tokens keep their place in
`error` for the clients that already match on them and are published in
`meta.errorCode` as well, which is the field a machine code belongs in
and the one a new client should read.

A guard reads the route tree per refusal site rather than per file —
per file is what let this grow, since a route that used the helper once
looked compliant to a `grep -l` sweep — and asserts it still finds the
sites it judges, so a matcher that stops matching fails instead of
reporting a clean zero.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 07:19:55 +02:00
1 parent c64708cc37
commit 9ce0bd5027
63 files changed
+912 -140

No files matched your search

+11 -2
View File
@@ -37,7 +37,12 @@
*/
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -80,7 +85,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return apiError("identifier required", 422);
return apiValidationError(
"identifier required",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const identifier = parsed.data.identifier;
+11 -2
View File
@@ -3,7 +3,12 @@ import { loginPasswordSchema } from "@/lib/validations/auth";
import { verifyPassword } from "@/lib/auth/password";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
import { apiError, safeJson } from "@/lib/api-response";
import {
apiError,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { NextRequest, NextResponse } from "next/server";
@@ -55,7 +60,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = loginPasswordSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid credentials", 422);
return apiValidationError(
"Invalid credentials",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const { email, password } = parsed.data;
+14 -2
View File
@@ -16,7 +16,11 @@
* row on every turn — there's no caching layer to invalidate.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import {
apiSuccess,
apiValidationError,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import {
@@ -74,7 +78,15 @@ export const PUT = apiHandler(async (req: Request) => {
action: { name: "auth.me.coach-prefs.put.invalid" },
meta: { issues: parsed.error.issues.length },
});
throw new HttpError(422, "coach-prefs.body.invalid_shape");
// The dotted token keeps its place in `error` — clients already match on it
// there — while `meta.errorCode` publishes it in the field a machine code
// belongs in, and `details.issues` names the fields that were refused.
return apiValidationError(
"coach-prefs.body.invalid_shape",
sanitiseZodIssues(parsed.error.issues),
422,
{ errorCode: "coach-prefs.body.invalid_shape" },
);
}
// Persist the canonical defaulted form so the column shape stays
+7 -1
View File
@@ -20,8 +20,10 @@ import {
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -63,7 +65,11 @@ export const POST = apiHandler(async (req: Request) => {
}
const parsed = mfaDisableSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const factor = await verifyMfaFactor(
@@ -25,8 +25,10 @@ import {
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -78,7 +80,11 @@ export const POST = apiHandler(async (req: Request) => {
}
const parsed = totpConfirmSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid code", 422);
return apiValidationError(
"Invalid code",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
// Decrypt is fail-closed — a missing/rotated key throws rather than
@@ -13,8 +13,10 @@ import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -39,7 +41,11 @@ export const PATCH = apiHandler(
const parsed = mfaWebauthnRenameSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const existing = await prisma.webauthnMfaCredential.findUnique({
@@ -11,8 +11,10 @@ import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -34,7 +36,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = mfaWebauthnRegisterVerifySchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const { challengeId, credential, name } = parsed.data;
+15 -2
View File
@@ -17,7 +17,12 @@
* outside that route ever read.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import {
apiSuccess,
apiValidationError,
getClientIp,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma, toJson } from "@/lib/db";
@@ -57,7 +62,15 @@ export const PUT = apiHandler(async (req: Request) => {
action: { name: "auth.me.report-selection.put.invalid" },
meta: { issues: parsed.error.issues.length },
});
throw new HttpError(422, "report-selection.body.invalid_shape");
// The dotted token keeps its place in `error` — clients already match on it
// there — while `meta.errorCode` publishes it in the field a machine code
// belongs in, and `details.issues` names the fields that were refused.
return apiValidationError(
"report-selection.body.invalid_shape",
sanitiseZodIssues(parsed.error.issues),
422,
{ errorCode: "report-selection.body.invalid_shape" },
);
}
const minted = selectionFromRequest(parsed.data);
+15 -2
View File
@@ -19,7 +19,12 @@
* payloads were built from.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import {
apiSuccess,
apiValidationError,
getClientIp,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -57,7 +62,15 @@ export const PUT = apiHandler(async (req: Request) => {
action: { name: "auth.me.source-priority.put.invalid" },
meta: { issues: parsed.error.issues.length },
});
throw new HttpError(422, "source-priority.body.invalid_shape");
// The dotted token keeps its place in `error` — clients already match on it
// there — while `meta.errorCode` publishes it in the field a machine code
// belongs in, and `details.issues` names the fields that were refused.
return apiValidationError(
"source-priority.body.invalid_shape",
sanitiseZodIssues(parsed.error.issues),
422,
{ errorCode: "source-priority.body.invalid_shape" },
);
}
// v1.4.25 W10 reconcile (security M-3): capture the previous shape
+11 -2
View File
@@ -17,7 +17,12 @@
* limited per IP on top of the per-ticket cap.
*/
import { NextRequest } from "next/server";
import { apiError, safeJson } from "@/lib/api-response";
import {
apiError,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -61,7 +66,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = mfaVerifySchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const { mfaTicket, method, code, rememberDevice } = parsed.data;
@@ -13,7 +13,13 @@
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -45,7 +51,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = mfaWebauthnLoginOptionsSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const challenge = await loadActiveChallenge(parsed.data.mfaTicket);
+11 -2
View File
@@ -16,7 +16,12 @@
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { apiError, safeJson } from "@/lib/api-response";
import {
apiError,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -59,7 +64,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = mfaWebauthnLoginVerifySchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const { mfaTicket, challengeId, credential, rememberDevice } = parsed.data;
+11 -2
View File
@@ -20,7 +20,12 @@
* `requireFreshMfa` step-up.
*/
import { NextRequest } from "next/server";
import { apiError, safeJson } from "@/lib/api-response";
import {
apiError,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -83,7 +88,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = nativeHandoffTokenSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const { code, codeVerifier } = parsed.data;
+11 -2
View File
@@ -23,7 +23,12 @@
* `requireFreshMfa` step-up, so an SSO login can never satisfy step-up.
*/
import { NextRequest } from "next/server";
import { apiError, safeJson } from "@/lib/api-response";
import {
apiError,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -89,7 +94,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = oidcNativeTokenSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const { code, codeVerifier } = parsed.data;
+12 -2
View File
@@ -18,7 +18,13 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireBearerAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -50,7 +56,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = stepUpOptionsSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
if (parsed.data.method === "webauthn") {
+7 -1
View File
@@ -33,8 +33,10 @@ import { apiHandler, requireBearerAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -112,7 +114,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = stepUpMintSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const method: StepUpMethod = parsed.data.method;