diff --git a/src/__tests__/zod-refusal-issue-list-guard.test.ts b/src/__tests__/zod-refusal-issue-list-guard.test.ts new file mode 100644 index 000000000..00031d521 --- /dev/null +++ b/src/__tests__/zod-refusal-issue-list-guard.test.ts @@ -0,0 +1,143 @@ +/** + * Every 422 raised by a Zod `safeParse` names the fields it refused. + * + * `returnAllZodIssues` / `apiValidationError` emit `details.issues` — one entry + * per rejected field — so a client with three bad fields fixes all three in one + * round-trip instead of three. Most routes use them. Fifty-nine refusal sites + * did not: they returned the correct 422 with a single opaque sentence, and a + * client could not map the failure onto a field. Twelve of those put a dotted + * machine token in `error` instead of a sentence, 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. + * + * The matcher is per-site, not per-file, because per-file is what let this + * grow: a route that used the helper once and a bare `apiError(..., 422)` + * somewhere else in the same file looked compliant to a `grep -l` sweep. So + * every `if (!.success)` block whose identifier came from a `safeParse` + * in that file is read on its own, and only the blocks that answer 422 are + * judged — a 400, a 401 or a 403 raised off a parse result is a different + * refusal and is left alone. + * + * Its limits, stated so nobody reads more into a green run than is there: + * a refusal built somewhere other than the `if (!x.success)` block that decided + * it — a helper called with the error, a status assembled from a variable — is + * invisible here, and so is a route that hands the `ZodError` to shared code + * outside `src/app/api`. It catches the shape this defect actually took, and + * the count assertion below is what keeps it from passing on a matcher that + * silently stopped matching anything. + */ +import { readFileSync, readdirSync } from "node:fs"; +import { join, relative, sep } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const API_ROOT = join(process.cwd(), "src", "app", "api"); + +/** + * Routes that answer a shape refusal without the issue list, each with the + * reason it must differ. Empty today: every 422 raised off a `safeParse` under + * `src/app/api` carries `details.issues`. An entry here is a decision, not a + * backlog item — write why the list cannot ship, not when it will. + */ +const ALLOWLIST: Record = {}; + +/** Every `route.ts` under `src/app/api`, tests excluded. */ +function routeFiles(dir: string): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "__tests__") continue; + found.push(...routeFiles(full)); + continue; + } + if (entry.name === "route.ts") found.push(full); + } + return found; +} + +/** + * The `{ … }` (or single statement) that follows an `if (…)`, brace-balanced. + * + * A refusal is often one statement with no braces — `if (!parsed.success) + * return apiError("Invalid data", 422);` — so both forms are read. + */ +function refusalBlock(src: string, afterCondition: number): string { + let i = afterCondition; + while (i < src.length && /\s/.test(src[i])) i++; + if (src[i] === "{") { + let depth = 0; + for (let j = i; j < src.length; j++) { + if (src[j] === "{") depth++; + else if (src[j] === "}") { + depth--; + if (depth === 0) return src.slice(i, j + 1); + } + } + return src.slice(i); + } + const end = src.indexOf(";", i); + return src.slice(i, end === -1 ? src.length : end + 1); +} + +interface RefusalSite { + route: string; + line: number; + carriesIssues: boolean; +} + +/** Every 422 refusal decided by a `safeParse` result, across the route tree. */ +function scanRefusalSites(): RefusalSite[] { + const sites: RefusalSite[] = []; + for (const file of routeFiles(API_ROOT)) { + const src = readFileSync(file, "utf8"); + // Identifiers bound to a parse result. Both call styles are in the tree: + // `schema.safeParse(body)` and the standalone `z.safeParse(schema, body)`. + const parsed = new Set(); + for (const m of src.matchAll( + /(?:const|let)\s+([A-Za-z0-9_$]+)\s*=\s*(?:await\s+)?[^;]*?safeParse\s*\(/g, + )) { + parsed.add(m[1]); + } + if (parsed.size === 0) continue; + + for (const m of src.matchAll( + /if\s*\(\s*!\s*([A-Za-z0-9_$]+)\.success\s*\)/g, + )) { + if (!parsed.has(m[1])) continue; + const block = refusalBlock(src, (m.index ?? 0) + m[0].length); + if (!/\b422\b/.test(block)) continue; + sites.push({ + route: relative(process.cwd(), file).split(sep).join("/"), + line: src.slice(0, m.index).split("\n").length, + carriesIssues: /returnAllZodIssues|apiValidationError/.test(block), + }); + } + } + return sites; +} + +describe("Zod shape refusals carry the issue list", () => { + const sites = scanRefusalSites(); + + it("finds the refusal sites it is meant to judge", () => { + // A matcher that stops matching would otherwise report a clean zero it did + // not earn. The tree carried 59 of these when the guard was written; the + // floor is deliberately loose so ordinary route churn does not trip it. + expect(sites.length).toBeGreaterThan(40); + }); + + it("answers every 422 with details.issues", () => { + const missing = sites + .filter((site) => !site.carriesIssues && !(site.route in ALLOWLIST)) + .map((site) => `${site.route}:${site.line}`); + expect(missing).toEqual([]); + }); + + it("keeps no stale allowlist entries", () => { + const stale = Object.keys(ALLOWLIST).filter( + (route) => !sites.some((site) => site.route === route), + ); + expect(stale).toEqual([]); + }); +}); diff --git a/src/app/api/admin/settings/web-push-vapid/generate/route.ts b/src/app/api/admin/settings/web-push-vapid/generate/route.ts index db647d982..27ce847d4 100644 --- a/src/app/api/admin/settings/web-push-vapid/generate/route.ts +++ b/src/app/api/admin/settings/web-push-vapid/generate/route.ts @@ -2,10 +2,12 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; import { - apiSuccess, apiError, + apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; @@ -58,7 +60,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = generateSchema.safeParse(body ?? {}); if (!parsed.success) { - return apiError("Invalid request", 422); + return apiValidationError( + "Invalid request", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const existing = await prisma.appSettings.findUnique({ diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index 6c3abfaae..56440ce29 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -6,7 +6,13 @@ import { type AITestOverride, } from "@/lib/ai/provider"; import { singleUserTurn } from "@/lib/ai/types"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { @@ -69,7 +75,11 @@ export const POST = apiHandler(async (request: NextRequest) => { if (error) return error; const parsed = aiTestOverrideSchema.safeParse(data); if (!parsed.success) { - return apiError("Invalid override payload", 422); + return apiValidationError( + "Invalid override payload", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } override = parsed.data; } diff --git a/src/app/api/auth/check-user/route.ts b/src/app/api/auth/check-user/route.ts index cae5d1db0..1aaf77170 100644 --- a/src/app/api/auth/check-user/route.ts +++ b/src/app/api/auth/check-user/route.ts @@ -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; diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 6d48a15b5..eb3272f8f 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -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; diff --git a/src/app/api/auth/me/coach-prefs/route.ts b/src/app/api/auth/me/coach-prefs/route.ts index d76c3fdb2..2c0c3d26d 100644 --- a/src/app/api/auth/me/coach-prefs/route.ts +++ b/src/app/api/auth/me/coach-prefs/route.ts @@ -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 diff --git a/src/app/api/auth/me/mfa/disable/route.ts b/src/app/api/auth/me/mfa/disable/route.ts index 8e9718220..d1ec12e0f 100644 --- a/src/app/api/auth/me/mfa/disable/route.ts +++ b/src/app/api/auth/me/mfa/disable/route.ts @@ -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( diff --git a/src/app/api/auth/me/mfa/totp/confirm/route.ts b/src/app/api/auth/me/mfa/totp/confirm/route.ts index 1251829d0..2aac38635 100644 --- a/src/app/api/auth/me/mfa/totp/confirm/route.ts +++ b/src/app/api/auth/me/mfa/totp/confirm/route.ts @@ -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 diff --git a/src/app/api/auth/me/mfa/webauthn/[id]/route.ts b/src/app/api/auth/me/mfa/webauthn/[id]/route.ts index 61af3c671..cff4259d6 100644 --- a/src/app/api/auth/me/mfa/webauthn/[id]/route.ts +++ b/src/app/api/auth/me/mfa/webauthn/[id]/route.ts @@ -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({ diff --git a/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts index 371e91174..e96388a1f 100644 --- a/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts +++ b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts @@ -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; diff --git a/src/app/api/auth/me/report-selection/route.ts b/src/app/api/auth/me/report-selection/route.ts index dd9c4b6a9..621c3ac78 100644 --- a/src/app/api/auth/me/report-selection/route.ts +++ b/src/app/api/auth/me/report-selection/route.ts @@ -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); diff --git a/src/app/api/auth/me/source-priority/route.ts b/src/app/api/auth/me/source-priority/route.ts index 2deba909d..264de6c7e 100644 --- a/src/app/api/auth/me/source-priority/route.ts +++ b/src/app/api/auth/me/source-priority/route.ts @@ -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 diff --git a/src/app/api/auth/mfa/verify/route.ts b/src/app/api/auth/mfa/verify/route.ts index 67a4028d9..cdd7b03af 100644 --- a/src/app/api/auth/mfa/verify/route.ts +++ b/src/app/api/auth/mfa/verify/route.ts @@ -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; diff --git a/src/app/api/auth/mfa/webauthn/verify/options/route.ts b/src/app/api/auth/mfa/webauthn/verify/options/route.ts index aeff3e827..36992dd24 100644 --- a/src/app/api/auth/mfa/webauthn/verify/options/route.ts +++ b/src/app/api/auth/mfa/webauthn/verify/options/route.ts @@ -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); diff --git a/src/app/api/auth/mfa/webauthn/verify/route.ts b/src/app/api/auth/mfa/webauthn/verify/route.ts index 3ad7c0b57..00df78104 100644 --- a/src/app/api/auth/mfa/webauthn/verify/route.ts +++ b/src/app/api/auth/mfa/webauthn/verify/route.ts @@ -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; diff --git a/src/app/api/auth/native/token/route.ts b/src/app/api/auth/native/token/route.ts index 5055a2024..0d74291b3 100644 --- a/src/app/api/auth/native/token/route.ts +++ b/src/app/api/auth/native/token/route.ts @@ -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; diff --git a/src/app/api/auth/oidc/native/token/route.ts b/src/app/api/auth/oidc/native/token/route.ts index d0bd4674e..bc28bed5e 100644 --- a/src/app/api/auth/oidc/native/token/route.ts +++ b/src/app/api/auth/oidc/native/token/route.ts @@ -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; diff --git a/src/app/api/auth/step-up/options/route.ts b/src/app/api/auth/step-up/options/route.ts index 30392af0c..6ec2f67ac 100644 --- a/src/app/api/auth/step-up/options/route.ts +++ b/src/app/api/auth/step-up/options/route.ts @@ -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") { diff --git a/src/app/api/auth/step-up/route.ts b/src/app/api/auth/step-up/route.ts index 7744cc320..2514d777a 100644 --- a/src/app/api/auth/step-up/route.ts +++ b/src/app/api/auth/step-up/route.ts @@ -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; diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts index 2c69eb869..9906a35c5 100644 --- a/src/app/api/cycle/calendar/route.ts +++ b/src/app/api/cycle/calendar/route.ts @@ -18,7 +18,12 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiError, apiSuccess } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + sanitiseZodIssues, +} from "@/lib/api-response"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { cycleCalendarQuerySchema } from "@/lib/validations/cycle"; import { @@ -55,9 +60,14 @@ export const GET = apiHandler(async (request: NextRequest) => { to: url.searchParams.get("to") ?? undefined, }); if (!parsed.success) { - return apiError("Invalid calendar query", 422, { - errorCode: "cycle.calendar.invalid", - }); + return apiValidationError( + "Invalid calendar query", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "cycle.calendar.invalid", + }, + ); } const tz = user.timezone ?? DEFAULT_TIMEZONE; diff --git a/src/app/api/cycle/cycles/route.ts b/src/app/api/cycle/cycles/route.ts index 6e1c65fb3..0e4a90692 100644 --- a/src/app/api/cycle/cycles/route.ts +++ b/src/app/api/cycle/cycles/route.ts @@ -12,7 +12,11 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiError, apiSuccess } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + sanitiseZodIssues, +} from "@/lib/api-response"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { cycleHistoryQuerySchema } from "@/lib/validations/cycle"; import { toMenstrualCycleDTO } from "@/lib/cycle/dto"; @@ -39,9 +43,14 @@ export const GET = apiHandler(async (request: NextRequest) => { limit: url.searchParams.get("limit") ?? undefined, }); if (!parsed.success) { - return apiError("Invalid history query", 422, { - errorCode: "cycle.cycles.invalid", - }); + return apiValidationError( + "Invalid history query", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "cycle.cycles.invalid", + }, + ); } const limit = parsed.data.limit ?? DEFAULT_LIMIT; diff --git a/src/app/api/documents/inbound/[id]/chat/route.ts b/src/app/api/documents/inbound/[id]/chat/route.ts index 4a001abad..b0559de97 100644 --- a/src/app/api/documents/inbound/[id]/chat/route.ts +++ b/src/app/api/documents/inbound/[id]/chat/route.ts @@ -24,7 +24,13 @@ import { type NextRequest } from "next/server"; import { apiHandler, requireAuth, HttpError } 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 { auditLog } from "@/lib/auth/audit"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -93,9 +99,14 @@ export const POST = apiHandler( if (jsonError) return jsonError; const parsed = documentChatRequestSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid document chat request", 422, { - errorCode: "documents.chat.invalid", - }); + return apiValidationError( + "Invalid document chat request", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.chat.invalid", + }, + ); } const { conversationId, message, locale: bodyLocale } = parsed.data; @@ -231,9 +242,14 @@ export const GET = apiHandler( limit: url.searchParams.get("limit") ?? undefined, }); if (!parsed.success) { - return apiError("Invalid chat history query", 422, { - errorCode: "documents.chat.invalid", - }); + return apiValidationError( + "Invalid chat history query", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.chat.invalid", + }, + ); } // With a conversationId → that one thread's messages (must hold this document). diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts index 93de5f7e9..757c012a7 100644 --- a/src/app/api/documents/inbound/[id]/extract/route.ts +++ b/src/app/api/documents/inbound/[id]/extract/route.ts @@ -30,8 +30,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; @@ -236,9 +238,14 @@ async function handleTextExtract( const parsed = inboundTextExtractSchema.safeParse(body); if (!parsed.success) { await refundDocumentAiSlot(userId); - return apiError("Invalid document text payload", 422, { - errorCode: "documents.inbound.extractFailed", - }); + return apiValidationError( + "Invalid document text payload", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.inbound.extractFailed", + }, + ); } const dateKey = buildDateKey(); diff --git a/src/app/api/documents/inbound/[id]/index/route.ts b/src/app/api/documents/inbound/[id]/index/route.ts index d64166fe1..9a3cd6756 100644 --- a/src/app/api/documents/inbound/[id]/index/route.ts +++ b/src/app/api/documents/inbound/[id]/index/route.ts @@ -26,8 +26,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; @@ -154,9 +156,14 @@ async function handleTextIndex( const parsed = inboundTextExtractSchema.safeParse(body); if (!parsed.success) { await refundDocumentAiSlot(userId); - return apiError("Invalid document text payload", 422, { - errorCode: "documents.inbound.extractFailed", - }); + return apiValidationError( + "Invalid document text payload", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.inbound.extractFailed", + }, + ); } const { tokenCount } = await upsertContentIndex({ diff --git a/src/app/api/documents/inbound/[id]/route.ts b/src/app/api/documents/inbound/[id]/route.ts index 22fef5f43..ee87db643 100644 --- a/src/app/api/documents/inbound/[id]/route.ts +++ b/src/app/api/documents/inbound/[id]/route.ts @@ -13,8 +13,10 @@ import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -143,9 +145,14 @@ export const PATCH = apiHandler( const parsed = documentUpdateSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid document update", 422, { - errorCode: "documents.inbound.invalidUpdate", - }); + return apiValidationError( + "Invalid document update", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.inbound.invalidUpdate", + }, + ); } const existing = await prisma.inboundDocument.findFirst({ diff --git a/src/app/api/documents/inbound/[id]/suggest/route.ts b/src/app/api/documents/inbound/[id]/suggest/route.ts index 94f435641..b7feb1f51 100644 --- a/src/app/api/documents/inbound/[id]/suggest/route.ts +++ b/src/app/api/documents/inbound/[id]/suggest/route.ts @@ -18,8 +18,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; @@ -148,9 +150,14 @@ async function handleTextSuggest( const parsed = inboundTextExtractSchema.safeParse(body); if (!parsed.success) { await refundDocumentAiSlot(userId); - return apiError("Invalid document text payload", 422, { - errorCode: "documents.inbound.extractFailed", - }); + return apiValidationError( + "Invalid document text payload", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.inbound.extractFailed", + }, + ); } const dateKey = buildDateKey(); diff --git a/src/app/api/documents/inbound/[id]/summary/route.ts b/src/app/api/documents/inbound/[id]/summary/route.ts index 55fbea7ec..186e842d7 100644 --- a/src/app/api/documents/inbound/[id]/summary/route.ts +++ b/src/app/api/documents/inbound/[id]/summary/route.ts @@ -25,8 +25,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; @@ -297,9 +299,14 @@ async function handleTextSummary( const parsed = inboundTextExtractSchema.safeParse(body); if (!parsed.success) { await refundDocumentAiSlot(userId); - return apiError("Invalid document text payload", 422, { - errorCode: "documents.inbound.extractFailed", - }); + return apiValidationError( + "Invalid document text payload", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.inbound.extractFailed", + }, + ); } // `mode=text` over posted OCR text is a pure echo — the text IS the diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts index f4ebcbe0a..15e79f51b 100644 --- a/src/app/api/documents/inbound/route.ts +++ b/src/app/api/documents/inbound/route.ts @@ -33,7 +33,13 @@ import { requireRecordAuth, type AuthContext, } from "@/lib/api-handler"; -import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + getClientIp, + sanitiseZodIssues, +} from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { hashQueryTokens } from "@/lib/documents/content-index"; @@ -236,9 +242,14 @@ async function processUpload( encounterIds: rawEncounterIds.length > 0 ? rawEncounterIds : undefined, }); if (!parsed.success) { - return apiError("Invalid document metadata", 422, { - errorCode: "documents.inbound.invalidMetadata", - }); + return apiValidationError( + "Invalid document metadata", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.inbound.invalidMetadata", + }, + ); } let buffer: Buffer; @@ -518,9 +529,14 @@ export const GET = apiHandler(async (request: Request) => { kind: kinds.length > 0 ? kinds : undefined, }); if (!parsed.success) { - return apiError("Invalid list query", 422, { - errorCode: "documents.inbound.invalidQuery", - }); + return apiValidationError( + "Invalid list query", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.inbound.invalidQuery", + }, + ); } const { q, diff --git a/src/app/api/fitbit/credentials/__tests__/route.test.ts b/src/app/api/fitbit/credentials/__tests__/route.test.ts index bb694f3ed..e8a5f6444 100644 --- a/src/app/api/fitbit/credentials/__tests__/route.test.ts +++ b/src/app/api/fitbit/credentials/__tests__/route.test.ts @@ -19,6 +19,28 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() })); vi.mock("@/lib/api-response", () => ({ + // The parse-refusal pair the routes now answer with: the same envelope + // plus the per-field issue list. + sanitiseZodIssues: ( + issues: { path: (string | number)[]; code: string; message: string }[], + ) => + issues.map((issue) => ({ + path: issue.path.join("."), + code: issue.code, + message: issue.message, + })), + apiValidationError: ( + error: string, + issues: unknown[], + status: number, + options?: { errorCode?: string }, + ) => ({ + data: null, + error, + status, + details: { issues }, + meta: options?.errorCode ? { errorCode: options.errorCode } : undefined, + }), apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), apiError: (error: string, status: number) => ({ data: null, error, status }), safeJson: async (req: NextRequest) => { diff --git a/src/app/api/fitbit/credentials/route.ts b/src/app/api/fitbit/credentials/route.ts index bef303a15..d313e401d 100644 --- a/src/app/api/fitbit/credentials/route.ts +++ b/src/app/api/fitbit/credentials/route.ts @@ -2,7 +2,12 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { isP2025 } from "@/lib/prisma-errors"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; import { markDisconnected } from "@/lib/integrations/status"; @@ -47,7 +52,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const result = z.safeParse(fitbitCredentialsSchema, body); if (!result.success) { - return apiError("Client ID and Client Secret are required", 422); + return apiValidationError( + "Client ID and Client Secret are required", + sanitiseZodIssues(result.error.issues), + 422, + ); } await prisma.user.update({ diff --git a/src/app/api/google-health/credentials/route.ts b/src/app/api/google-health/credentials/route.ts index d7edaa328..f8f2a271d 100644 --- a/src/app/api/google-health/credentials/route.ts +++ b/src/app/api/google-health/credentials/route.ts @@ -2,7 +2,12 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { isP2025 } from "@/lib/prisma-errors"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; import { markDisconnected } from "@/lib/integrations/status"; @@ -47,7 +52,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const result = z.safeParse(googleHealthCredentialsSchema, body); if (!result.success) { - return apiError("Client ID and Client Secret are required", 422); + return apiValidationError( + "Client ID and Client Secret are required", + sanitiseZodIssues(result.error.issues), + 422, + ); } // Field-by-field assignment — never spread the parsed object, so no future diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index e81f51a54..02bad9389 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -3,10 +3,12 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { - apiSuccess, apiError, + apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { NextRequest } from "next/server"; @@ -175,8 +177,9 @@ export const POST = apiHandler(async (request: NextRequest) => { if (jsonError) return jsonError; const parsed = importSchema.safeParse(body); if (!parsed.success) { - return apiError( + return apiValidationError( parsed.error.issues[0]?.message ?? "Invalid import payload", + sanitiseZodIssues(parsed.error.issues), 422, ); } diff --git a/src/app/api/insights/chat/[id]/attachments/route.ts b/src/app/api/insights/chat/[id]/attachments/route.ts index 2b1dcd488..a6c66fb75 100644 --- a/src/app/api/insights/chat/[id]/attachments/route.ts +++ b/src/app/api/insights/chat/[id]/attachments/route.ts @@ -10,7 +10,13 @@ import { type NextRequest } from "next/server"; import { apiHandler, requireAuth } 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 { auditLog } from "@/lib/auth/audit"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -65,9 +71,14 @@ export const POST = apiHandler( if (jsonError) return jsonError; const parsed = coachAttachmentCreateSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid attachment request", 422, { - errorCode: "coach.fenced.invalid", - }); + return apiValidationError( + "Invalid attachment request", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "coach.fenced.invalid", + }, + ); } const { documentId } = parsed.data; diff --git a/src/app/api/insights/chat/fenced/route.ts b/src/app/api/insights/chat/fenced/route.ts index 338c938c1..1c7da325a 100644 --- a/src/app/api/insights/chat/fenced/route.ts +++ b/src/app/api/insights/chat/fenced/route.ts @@ -15,7 +15,12 @@ import { type NextRequest } from "next/server"; import { apiHandler, requireAuth, HttpError } 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 { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -57,9 +62,14 @@ export const POST = apiHandler(async (request: NextRequest) => { if (jsonError) return jsonError; const parsed = fencedChatRequestSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid fenced chat request", 422, { - errorCode: "coach.fenced.invalid", - }); + return apiValidationError( + "Invalid fenced chat request", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "coach.fenced.invalid", + }, + ); } const { conversationId, diff --git a/src/app/api/insights/chat/messages/[id]/feedback/route.ts b/src/app/api/insights/chat/messages/[id]/feedback/route.ts index 973f9a264..668a06599 100644 --- a/src/app/api/insights/chat/messages/[id]/feedback/route.ts +++ b/src/app/api/insights/chat/messages/[id]/feedback/route.ts @@ -34,8 +34,10 @@ import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -91,7 +93,15 @@ async function handlePost(request: NextRequest, ctx: RouteContext) { action: { name: "insights.coach.message.feedback" }, meta: { outcome: "validation_failed" }, }); - throw new HttpError(422, "feedback.body.invalid"); + // 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( + "feedback.body.invalid", + sanitiseZodIssues(parsed.error.issues), + 422, + { errorCode: "feedback.body.invalid" }, + ); } const body = parsed.data; diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index a5c0e3191..e14cdff7e 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -27,10 +27,15 @@ * 7. Persist user message + assistant message (encrypted) and bump * the day's CoachUsage token ledger. */ -import { NextResponse, type NextRequest } from "next/server"; +import { type NextRequest } from "next/server"; import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; -import { apiError, apiSuccess } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate, getEvent } from "@/lib/logging/context"; import { redactOptional, redactSecrets } from "@/lib/logging/redact"; import { auditLog } from "@/lib/auth/audit"; @@ -209,9 +214,14 @@ async function handleChatRequest(request: NextRequest): Promise { action: { name: "insights.coach.invalid" }, meta: { issues: parsed.error.issues.length }, }); - return NextResponse.json( - { data: null, error: "coach.request.invalid" }, - { status: 422 }, + // 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.request.invalid", + sanitiseZodIssues(parsed.error.issues), + 422, + { errorCode: "coach.request.invalid" }, ); } const { diff --git a/src/app/api/insights/feedback/route.ts b/src/app/api/insights/feedback/route.ts index a8b30b5e7..6d1e22b36 100644 --- a/src/app/api/insights/feedback/route.ts +++ b/src/app/api/insights/feedback/route.ts @@ -4,8 +4,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -66,7 +68,11 @@ async function handlePost(request: NextRequest) { action: { name: "insights.recommendation.feedback" }, meta: { outcome: "validation_failed" }, }); - return apiError("Invalid feedback payload", 422); + return apiValidationError( + "Invalid feedback payload", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const body = parsed.data; diff --git a/src/app/api/insights/provider-chain/route.ts b/src/app/api/insights/provider-chain/route.ts index 82216c462..b5afc4379 100644 --- a/src/app/api/insights/provider-chain/route.ts +++ b/src/app/api/insights/provider-chain/route.ts @@ -2,7 +2,12 @@ import type { NextRequest } from "next/server"; import { z } from "zod/v4"; import { apiHandler, HttpError, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { resolveProviderChain } from "@/lib/ai/provider"; import { getLastWorkingProvider } from "@/lib/ai/provider-runner"; @@ -111,7 +116,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const parsed = chainBodySchema.safeParse(body); if (!parsed.success) { - throw new HttpError(422, "Invalid provider chain payload"); + return apiValidationError( + "Invalid provider chain payload", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } // Defence-in-depth: reject duplicate provider types so a stale-tab diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts index d6219657e..125af81dd 100644 --- a/src/app/api/labs/ocr/extract/route.ts +++ b/src/app/api/labs/ocr/extract/route.ts @@ -28,7 +28,13 @@ import { Buffer } from "node:buffer"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { assertConsentForChain } from "@/lib/ai/consent-guard"; import { @@ -154,9 +160,14 @@ async function handleTextExtract( const parsed = ocrTextExtractSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid OCR text payload", 422, { - errorCode: "labs.ocr.extractFailed", - }); + return apiValidationError( + "Invalid OCR text payload", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "labs.ocr.extractFailed", + }, + ); } // Budget — text mode is a plain text→JSON structuring pass, far cheaper than diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 52672d2ae..776e870b7 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -45,8 +45,10 @@ import { auditLog } from "@/lib/auth/audit"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { withIdempotency } from "@/lib/idempotency"; import { @@ -274,7 +276,11 @@ async function postBatch(request: NextRequest): Promise { const parsed = batchPayloadSchema.safeParse(rawBody); if (!parsed.success) { - return apiError(parsed.error.issues[0]?.message ?? "Invalid batch", 422); + return apiValidationError( + parsed.error.issues[0]?.message ?? "Invalid batch", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } // A scoped credential may attribute MANUAL and nothing else. Refused loudly diff --git a/src/app/api/measurements/by-external-ids/route.ts b/src/app/api/measurements/by-external-ids/route.ts index 8ede3dcb1..387db7ed4 100644 --- a/src/app/api/measurements/by-external-ids/route.ts +++ b/src/app/api/measurements/by-external-ids/route.ts @@ -38,8 +38,10 @@ import { auditLog } from "@/lib/auth/audit"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; @@ -83,7 +85,11 @@ async function deleteByExternalIds(request: NextRequest): Promise { const parsed = payloadSchema.safeParse(rawBody); if (!parsed.success) { - return apiError(parsed.error.issues[0]?.message ?? "Invalid batch", 422); + return apiValidationError( + parsed.error.issues[0]?.message ?? "Invalid batch", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const { externalIds } = parsed.data; diff --git a/src/app/api/monitoring/glitchtip/route.ts b/src/app/api/monitoring/glitchtip/route.ts index d84d38d87..0d444cc59 100644 --- a/src/app/api/monitoring/glitchtip/route.ts +++ b/src/app/api/monitoring/glitchtip/route.ts @@ -1,6 +1,12 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; -import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + getClientIp, + sanitiseZodIssues, +} from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { getGlitchtipSettings } from "@/lib/monitoring-settings"; import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip"; @@ -42,7 +48,12 @@ export const POST = apiHandler(async (request: NextRequest) => { } const parsed = clientErrorSchema.safeParse(body); - if (!parsed.success) return apiError("Invalid error data", 422); + if (!parsed.success) + return apiValidationError( + "Invalid error data", + sanitiseZodIssues(parsed.error.issues), + 422, + ); const delivery = await sendGlitchtipEvent({ dsn: settings.glitchtipDsn, diff --git a/src/app/api/nightscout/connect/__tests__/route.test.ts b/src/app/api/nightscout/connect/__tests__/route.test.ts index f1cde5a7f..bb7b545a3 100644 --- a/src/app/api/nightscout/connect/__tests__/route.test.ts +++ b/src/app/api/nightscout/connect/__tests__/route.test.ts @@ -42,6 +42,28 @@ vi.mock("@/lib/rate-limit", () => ({ })); vi.mock("@/lib/api-response", () => ({ + // The parse-refusal pair the routes now answer with: the same envelope + // plus the per-field issue list. + sanitiseZodIssues: ( + issues: { path: (string | number)[]; code: string; message: string }[], + ) => + issues.map((issue) => ({ + path: issue.path.join("."), + code: issue.code, + message: issue.message, + })), + apiValidationError: ( + error: string, + issues: unknown[], + status: number, + options?: { errorCode?: string }, + ) => ({ + data: null, + error, + status, + details: { issues }, + meta: options?.errorCode ? { errorCode: options.errorCode } : undefined, + }), apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), apiError: ( error: string, diff --git a/src/app/api/nightscout/connect/route.ts b/src/app/api/nightscout/connect/route.ts index 57a221e2d..77bc5f383 100644 --- a/src/app/api/nightscout/connect/route.ts +++ b/src/app/api/nightscout/connect/route.ts @@ -3,7 +3,13 @@ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; @@ -53,7 +59,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const result = z.safeParse(nightscoutConnectSchema, body); if (!result.success) { - return apiError("A valid Nightscout URL is required", 422); + return apiValidationError( + "A valid Nightscout URL is required", + sanitiseZodIssues(result.error.issues), + 422, + ); } const { url, token } = result.data; diff --git a/src/app/api/notifications/preferences/route.ts b/src/app/api/notifications/preferences/route.ts index 9b98b2ca4..0fbe2fa4d 100644 --- a/src/app/api/notifications/preferences/route.ts +++ b/src/app/api/notifications/preferences/route.ts @@ -2,7 +2,12 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate, getEvent } from "@/lib/logging/context"; -import { apiSuccess, apiError } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + sanitiseZodIssues, +} from "@/lib/api-response"; import { notificationPreferenceSchema } from "@/lib/validations/notifications"; import { EVENT_TYPES, CHANNEL_TYPE_LABELS } from "@/lib/notifications/types"; import type { ChannelType } from "@/lib/notifications/types"; @@ -106,7 +111,12 @@ export const PUT = apiHandler(async (request: NextRequest) => { } const parsed = notificationPreferenceSchema.safeParse(body); - if (!parsed.success) return apiError("Invalid data", 422); + if (!parsed.success) + return apiValidationError( + "Invalid data", + sanitiseZodIssues(parsed.error.issues), + 422, + ); const { channelId, eventType, enabled } = parsed.data; diff --git a/src/app/api/notifications/status/route.ts b/src/app/api/notifications/status/route.ts index 54fb387cc..5adf11a2a 100644 --- a/src/app/api/notifications/status/route.ts +++ b/src/app/api/notifications/status/route.ts @@ -2,7 +2,12 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiSuccess, apiError } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + sanitiseZodIssues, +} from "@/lib/api-response"; import { CHANNEL_TYPE_LABELS, EVENT_TYPES } from "@/lib/notifications/types"; import type { ChannelType, EventType } from "@/lib/notifications/types"; import { reEnableChannel } from "@/lib/notifications/channel-state"; @@ -156,7 +161,12 @@ export const POST = apiHandler(async (request: NextRequest) => { } const parsed = reEnableChannelSchema.safeParse(body); - if (!parsed.success) return apiError("Invalid data", 422); + if (!parsed.success) + return apiValidationError( + "Invalid data", + sanitiseZodIssues(parsed.error.issues), + 422, + ); const channel = await prisma.notificationChannel.findFirst({ where: { id: parsed.data.channelId, userId: user.id }, diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts index 4bfe35382..a41c6fb52 100644 --- a/src/app/api/onboarding/complete/route.ts +++ b/src/app/api/onboarding/complete/route.ts @@ -1,7 +1,12 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { setOnboardingPendingCookie } from "@/lib/auth/session"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -22,9 +27,14 @@ export const POST = apiHandler(async (request: NextRequest) => { if (jsonError) return jsonError; const result = z.safeParse(onboardingCompleteSchema, body); if (!result.success) { - return apiError("Invalid input", 422, { - errorCode: "onboarding.complete.invalid", - }); + return apiValidationError( + "Invalid input", + sanitiseZodIssues(result.error.issues), + 422, + { + errorCode: "onboarding.complete.invalid", + }, + ); } const data: Record = { diff --git a/src/app/api/onboarding/disclaimer/route.ts b/src/app/api/onboarding/disclaimer/route.ts index e9cf2dd5a..acb33fff5 100644 --- a/src/app/api/onboarding/disclaimer/route.ts +++ b/src/app/api/onboarding/disclaimer/route.ts @@ -3,7 +3,12 @@ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { DISCLAIMER_VERSION } from "@/lib/onboarding/disclaimer"; /** @@ -38,9 +43,14 @@ export const POST = apiHandler(async (request) => { const parsed = z.safeParse(bodySchema, rawBody); if (!parsed.success) { - return apiError("Invalid input", 422, { - errorCode: "onboarding.disclaimer.invalid", - }); + return apiValidationError( + "Invalid input", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "onboarding.disclaimer.invalid", + }, + ); } // The server pins the canonical version it stamps — the body version is diff --git a/src/app/api/onboarding/step/route.ts b/src/app/api/onboarding/step/route.ts index 177e42831..c21dc2cc8 100644 --- a/src/app/api/onboarding/step/route.ts +++ b/src/app/api/onboarding/step/route.ts @@ -4,8 +4,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { setOnboardingPendingCookie } from "@/lib/auth/session"; @@ -80,9 +82,14 @@ export const POST = apiHandler(async (request: NextRequest) => { action: { name: "onboarding.step" }, meta: { outcome: "validation_failed" }, }); - return apiError("Invalid step payload", 422, { - errorCode: "onboarding.step.invalid", - }); + return apiValidationError( + "Invalid step payload", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "onboarding.step.invalid", + }, + ); } const { step, goals } = parsed.data; diff --git a/src/app/api/onboarding/tour/route.ts b/src/app/api/onboarding/tour/route.ts index 08e9df6a2..b68d24e7b 100644 --- a/src/app/api/onboarding/tour/route.ts +++ b/src/app/api/onboarding/tour/route.ts @@ -4,7 +4,12 @@ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { tourProgressSchema } from "@/lib/onboarding/tour-progress"; import { Prisma } from "@/generated/prisma/client"; @@ -56,9 +61,14 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = z.safeParse(tourBodySchema, body); if (!parsed.success) { - return apiError("Invalid input", 422, { - errorCode: "onboarding.tour.invalid", - }); + return apiValidationError( + "Invalid input", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "onboarding.tour.invalid", + }, + ); } // Build the update field-by-field (no mass assignment). `completed` diff --git a/src/app/api/oura/credentials/__tests__/route.test.ts b/src/app/api/oura/credentials/__tests__/route.test.ts index 800be41a7..d59052f89 100644 --- a/src/app/api/oura/credentials/__tests__/route.test.ts +++ b/src/app/api/oura/credentials/__tests__/route.test.ts @@ -23,6 +23,28 @@ vi.mock("@/lib/oura/credentials", () => ({ })); vi.mock("@/lib/api-response", () => ({ + // The parse-refusal pair the routes now answer with: the same envelope + // plus the per-field issue list. + sanitiseZodIssues: ( + issues: { path: (string | number)[]; code: string; message: string }[], + ) => + issues.map((issue) => ({ + path: issue.path.join("."), + code: issue.code, + message: issue.message, + })), + apiValidationError: ( + error: string, + issues: unknown[], + status: number, + options?: { errorCode?: string }, + ) => ({ + data: null, + error, + status, + details: { issues }, + meta: options?.errorCode ? { errorCode: options.errorCode } : undefined, + }), apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), apiError: (error: string, status: number) => ({ data: null, error, status }), safeJson: vi.fn(), diff --git a/src/app/api/oura/credentials/route.ts b/src/app/api/oura/credentials/route.ts index 177718127..b5bb0a6e9 100644 --- a/src/app/api/oura/credentials/route.ts +++ b/src/app/api/oura/credentials/route.ts @@ -1,6 +1,11 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { markDisconnected } from "@/lib/integrations/status"; @@ -51,7 +56,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const result = z.safeParse(ouraCredentialsSchema, body); if (!result.success) { - return apiError("Client ID and Client Secret are required", 422); + return apiValidationError( + "Client ID and Client Secret are required", + sanitiseZodIssues(result.error.issues), + 422, + ); } await storeOuraClientCredentials( diff --git a/src/app/api/polar/credentials/__tests__/route.test.ts b/src/app/api/polar/credentials/__tests__/route.test.ts index 3b744bc78..2952491f7 100644 --- a/src/app/api/polar/credentials/__tests__/route.test.ts +++ b/src/app/api/polar/credentials/__tests__/route.test.ts @@ -26,6 +26,28 @@ vi.mock("@/lib/polar/credentials", () => ({ clearPolarClientCredentials: clearMock, })); vi.mock("@/lib/api-response", () => ({ + // The parse-refusal pair the routes now answer with: the same envelope + // plus the per-field issue list. + sanitiseZodIssues: ( + issues: { path: (string | number)[]; code: string; message: string }[], + ) => + issues.map((issue) => ({ + path: issue.path.join("."), + code: issue.code, + message: issue.message, + })), + apiValidationError: ( + error: string, + issues: unknown[], + status: number, + options?: { errorCode?: string }, + ) => ({ + data: null, + error, + status, + details: { issues }, + meta: options?.errorCode ? { errorCode: options.errorCode } : undefined, + }), apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), apiError: (error: string, status: number) => ({ data: null, error, status }), safeJson: vi.fn(async (req: { _body: unknown }) => ({ diff --git a/src/app/api/polar/credentials/route.ts b/src/app/api/polar/credentials/route.ts index 7dbab091b..6d23712f0 100644 --- a/src/app/api/polar/credentials/route.ts +++ b/src/app/api/polar/credentials/route.ts @@ -1,7 +1,12 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { markDisconnected } from "@/lib/integrations/status"; import { @@ -51,7 +56,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const result = z.safeParse(polarCredentialsSchema, body); if (!result.success) { - return apiError("Client ID and Client Secret are required", 422); + return apiValidationError( + "Client ID and Client Secret are required", + sanitiseZodIssues(result.error.issues), + 422, + ); } await storePolarClientCredentials( diff --git a/src/app/api/settings/email/route.ts b/src/app/api/settings/email/route.ts index bd2dc592d..c8a2afb2c 100644 --- a/src/app/api/settings/email/route.ts +++ b/src/app/api/settings/email/route.ts @@ -1,5 +1,11 @@ import { prisma } from "@/lib/db"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { emailSettingsSchema, notificationChannelEnabledSchema, @@ -102,7 +108,12 @@ export const PUT = apiHandler(async (request: NextRequest) => { } const parsed = emailSettingsSchema.safeParse(body); - if (!parsed.success) return apiError("Invalid data", 422); + if (!parsed.success) + return apiValidationError( + "Invalid data", + sanitiseZodIssues(parsed.error.issues), + 422, + ); const { recipient, enabled } = parsed.data; diff --git a/src/app/api/settings/ntfy/route.ts b/src/app/api/settings/ntfy/route.ts index ea54e4c0f..eb0f4b05a 100644 --- a/src/app/api/settings/ntfy/route.ts +++ b/src/app/api/settings/ntfy/route.ts @@ -1,5 +1,11 @@ import { prisma } from "@/lib/db"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { notificationChannelEnabledSchema, ntfySettingsSchema, @@ -119,7 +125,12 @@ export const PUT = apiHandler(async (request: NextRequest) => { } const parsed = ntfySettingsSchema.safeParse(body); - if (!parsed.success) return apiError("Invalid data", 422); + if (!parsed.success) + return apiValidationError( + "Invalid data", + sanitiseZodIssues(parsed.error.issues), + 422, + ); const { serverUrl, topic, authToken, enabled } = parsed.data; diff --git a/src/app/api/settings/telegram/route.ts b/src/app/api/settings/telegram/route.ts index c837ce010..545392e9a 100644 --- a/src/app/api/settings/telegram/route.ts +++ b/src/app/api/settings/telegram/route.ts @@ -1,5 +1,11 @@ import { prisma } from "@/lib/db"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { decrypt, encrypt } from "@/lib/crypto"; import { deleteTelegramWebhook, setTelegramWebhook } from "@/lib/telegram"; import { telegramSettingsSchema } from "@/lib/validations/telegram"; @@ -171,7 +177,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const result = z.safeParse(telegramSettingsSchema, body); if (!result.success) { - return apiError("Invalid input", 422); + return apiValidationError( + "Invalid input", + sanitiseZodIssues(result.error.issues), + 422, + ); } const { botToken, chatId, enabled } = result.data; diff --git a/src/app/api/settings/webhook/route.ts b/src/app/api/settings/webhook/route.ts index 8129a2745..0b8c5082f 100644 --- a/src/app/api/settings/webhook/route.ts +++ b/src/app/api/settings/webhook/route.ts @@ -1,5 +1,11 @@ import { prisma } from "@/lib/db"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { notificationChannelEnabledSchema, webhookSettingsSchema, @@ -108,7 +114,12 @@ export const PUT = apiHandler(async (request: NextRequest) => { } const parsed = webhookSettingsSchema.safeParse(body); - if (!parsed.success) return apiError("Invalid data", 422); + if (!parsed.success) + return apiValidationError( + "Invalid data", + sanitiseZodIssues(parsed.error.issues), + 422, + ); const { url, headerName, headerValue, enabled } = parsed.data; diff --git a/src/app/api/strava/credentials/route.ts b/src/app/api/strava/credentials/route.ts index ac53d9c47..7605775ef 100644 --- a/src/app/api/strava/credentials/route.ts +++ b/src/app/api/strava/credentials/route.ts @@ -1,6 +1,11 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { markDisconnected } from "@/lib/integrations/status"; @@ -52,7 +57,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const result = z.safeParse(stravaCredentialsSchema, body); if (!result.success) { - return apiError("Client ID and Client Secret are required", 422); + return apiValidationError( + "Client ID and Client Secret are required", + sanitiseZodIssues(result.error.issues), + 422, + ); } await storeStravaClientCredentials( diff --git a/src/app/api/sync/changes/route.ts b/src/app/api/sync/changes/route.ts index 3688a6863..694929cc0 100644 --- a/src/app/api/sync/changes/route.ts +++ b/src/app/api/sync/changes/route.ts @@ -50,7 +50,12 @@ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, apiError } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; import { readNote } from "@/lib/crypto/note-cipher"; @@ -211,7 +216,11 @@ export const GET = apiHandler(async (request: NextRequest) => { limit: url.searchParams.get("limit") ?? undefined, }); if (!parsed.success) { - return apiError("Invalid sync query", 422); + return apiValidationError( + "Invalid sync query", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const limit = parsed.data.limit ?? DEFAULT_LIMIT; diff --git a/src/app/api/whoop/credentials/__tests__/route.test.ts b/src/app/api/whoop/credentials/__tests__/route.test.ts index 6ca1e67ea..091c9835d 100644 --- a/src/app/api/whoop/credentials/__tests__/route.test.ts +++ b/src/app/api/whoop/credentials/__tests__/route.test.ts @@ -19,6 +19,28 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() })); vi.mock("@/lib/api-response", () => ({ + // The parse-refusal pair the routes now answer with: the same envelope + // plus the per-field issue list. + sanitiseZodIssues: ( + issues: { path: (string | number)[]; code: string; message: string }[], + ) => + issues.map((issue) => ({ + path: issue.path.join("."), + code: issue.code, + message: issue.message, + })), + apiValidationError: ( + error: string, + issues: unknown[], + status: number, + options?: { errorCode?: string }, + ) => ({ + data: null, + error, + status, + details: { issues }, + meta: options?.errorCode ? { errorCode: options.errorCode } : undefined, + }), apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), apiError: (error: string, status: number) => ({ data: null, error, status }), safeJson: async (req: NextRequest) => { diff --git a/src/app/api/whoop/credentials/route.ts b/src/app/api/whoop/credentials/route.ts index ffc7e9183..26448bbc9 100644 --- a/src/app/api/whoop/credentials/route.ts +++ b/src/app/api/whoop/credentials/route.ts @@ -2,7 +2,12 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { isP2025 } from "@/lib/prisma-errors"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; import { markDisconnected } from "@/lib/integrations/status"; @@ -45,7 +50,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const result = z.safeParse(whoopCredentialsSchema, body); if (!result.success) { - return apiError("Client ID and Client Secret are required", 422); + return apiValidationError( + "Client ID and Client Secret are required", + sanitiseZodIssues(result.error.issues), + 422, + ); } await prisma.user.update({ diff --git a/src/app/api/withings/credentials/route.ts b/src/app/api/withings/credentials/route.ts index 3829ed3d6..56e1f83e8 100644 --- a/src/app/api/withings/credentials/route.ts +++ b/src/app/api/withings/credentials/route.ts @@ -1,6 +1,11 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { isP2025 } from "@/lib/prisma-errors"; import { auditLog } from "@/lib/auth/audit"; import { annotate } from "@/lib/logging/context"; @@ -46,7 +51,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { if (jsonError) return jsonError; const result = z.safeParse(withingsCredentialsSchema, body); if (!result.success) { - return apiError("Client ID and Client Secret are required", 422); + return apiValidationError( + "Client ID and Client Secret are required", + sanitiseZodIssues(result.error.issues), + 422, + ); } await prisma.user.update({