diff --git a/src/__tests__/baselines/route-safe-json.json b/src/__tests__/baselines/route-safe-json.json index 3e1e6201b..ec52c3e59 100644 --- a/src/__tests__/baselines/route-safe-json.json +++ b/src/__tests__/baselines/route-safe-json.json @@ -1,21 +1,15 @@ { "$comment": "safeJson calls per route file under src/app; new body-reading routes use parseJsonBody. Maintained by src/__tests__/route-body-parse-ratchet.test.ts.", - "count": 106, - "total": 106, + "count": 61, + "total": 61, "entries": { "app/api/admin/ai-settings/route.ts": 1, "app/api/admin/drain-per-sample-cumulative/route.ts": 1, - "app/api/admin/encryption/key-backup/confirm/route.ts": 1, "app/api/admin/encryption/key-backup/verify/route.ts": 1, - "app/api/admin/invites/route.ts": 1, "app/api/admin/notifications/reminder-check/route.ts": 1, "app/api/admin/rollups/recompute/route.ts": 1, - "app/api/admin/settings/web-push-vapid/generate/route.ts": 1, "app/api/admin/users/[id]/reset-password/route.ts": 1, "app/api/ai/test/route.ts": 1, - "app/api/anamnesis/emergency/route.ts": 1, - "app/api/anamnesis/facts/[id]/route.ts": 1, - "app/api/anamnesis/facts/route.ts": 1, "app/api/auth/login/route.ts": 1, "app/api/auth/me/locale/route.ts": 1, "app/api/auth/me/mfa/disable/route.ts": 1, @@ -26,85 +20,46 @@ "app/api/auth/passkey/login-options/route.ts": 1, "app/api/auth/profile/route.ts": 1, "app/api/auth/refresh/route.ts": 1, - "app/api/biomarkers/[id]/route.ts": 1, - "app/api/biomarkers/route.ts": 1, - "app/api/coach/about-me/questions/route.ts": 1, "app/api/coach/about-me/route.ts": 1, - "app/api/consent/ai/route.ts": 1, - "app/api/custom-metrics/[id]/entries/[entryId]/route.ts": 1, - "app/api/custom-metrics/[id]/entries/route.ts": 1, - "app/api/custom-metrics/[id]/route.ts": 1, - "app/api/custom-metrics/route.ts": 1, "app/api/cycle/day-logs/bulk/route.ts": 1, - "app/api/cycle/day-logs/route.ts": 1, - "app/api/cycle/period/route.ts": 1, - "app/api/dashboard/chart-overlay-prefs/route.ts": 1, "app/api/dashboard/widgets/route.ts": 1, "app/api/dawarich/connect/route.ts": 1, "app/api/dawarich/disconnect/route.ts": 1, "app/api/dawarich/settings/route.ts": 1, "app/api/dawarich/sync/route.ts": 1, - "app/api/devices/route.ts": 1, "app/api/documents/inbound/[id]/extract/route.ts": 1, "app/api/documents/inbound/[id]/index/route.ts": 1, "app/api/documents/inbound/[id]/suggest/route.ts": 1, "app/api/documents/inbound/[id]/summary/route.ts": 1, "app/api/fitbit/credentials/route.ts": 1, "app/api/google-health/credentials/route.ts": 1, - "app/api/illness/episodes/[id]/resolve/route.ts": 1, "app/api/import/route.ts": 1, - "app/api/ingest/medication/route.ts": 1, - "app/api/insights/chat/messages/[id]/feedback/route.ts": 1, - "app/api/insights/ecg/route.ts": 1, - "app/api/insights/feedback/route.ts": 1, "app/api/insights/generate/route.ts": 1, "app/api/insights/layout/route.ts": 1, - "app/api/insights/provider-chain/route.ts": 1, "app/api/integrations/healthkit/route.ts": 1, "app/api/internal/deploy-webhook/route.ts": 1, - "app/api/labs/[id]/route.ts": 1, - "app/api/labs/ocr/commit/route.ts": 1, "app/api/labs/ocr/extract/route.ts": 1, - "app/api/labs/route.ts": 1, - "app/api/measurement-reminders/[id]/route.ts": 1, - "app/api/measurement-reminders/[id]/snooze/route.ts": 1, - "app/api/measurement-reminders/route.ts": 1, - "app/api/measurements/[id]/route.ts": 1, "app/api/measurements/batch/route.ts": 1, "app/api/measurements/by-external-ids/route.ts": 1, "app/api/measurements/route.ts": 1, "app/api/medications/[id]/intake/[eventId]/route.ts": 1, - "app/api/medications/[id]/intake/import/route.ts": 1, "app/api/medications/[id]/intake/route.ts": 1, - "app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts": 1, - "app/api/medications/[id]/schedule-revisions/route.ts": 1, - "app/api/medications/extract/route.ts": 1, "app/api/medications/intake/bulk/route.ts": 1, "app/api/medications/intake/route.ts": 1, "app/api/medications/layout/route.ts": 1, "app/api/medications/route.ts": 1, - "app/api/mental-health/assessments/route.ts": 1, - "app/api/mood-entries/[id]/route.ts": 1, "app/api/mood-entries/bulk/route.ts": 1, - "app/api/mood-entries/route.ts": 1, "app/api/mood/tags/layout/route.ts": 1, "app/api/nightscout/connect/route.ts": 1, "app/api/nutrients/batch/route.ts": 1, - "app/api/onboarding/answers/route.ts": 1, - "app/api/onboarding/complete/route.ts": 1, - "app/api/onboarding/disclaimer/route.ts": 1, - "app/api/onboarding/restart/route.ts": 1, - "app/api/onboarding/tour/route.ts": 1, "app/api/oura/credentials/route.ts": 1, "app/api/polar/credentials/route.ts": 1, "app/api/record-settings/[family]/route.ts": 1, - "app/api/search/route.ts": 1, "app/api/settings/email/route.ts": 1, "app/api/settings/ntfy/route.ts": 1, "app/api/settings/telegram/route.ts": 1, "app/api/settings/webhook/route.ts": 1, "app/api/strava/credentials/route.ts": 1, - "app/api/user/ai-provider/route.ts": 1, "app/api/user/profile/route.ts": 1, "app/api/whoop/credentials/route.ts": 1, "app/api/withings/credentials/route.ts": 1, diff --git a/src/__tests__/zod-refusal-issue-list-guard.test.ts b/src/__tests__/zod-refusal-issue-list-guard.test.ts index 242353000..96fce35f2 100644 --- a/src/__tests__/zod-refusal-issue-list-guard.test.ts +++ b/src/__tests__/zod-refusal-issue-list-guard.test.ts @@ -16,7 +16,10 @@ * 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. + * refusal and is left alone. A route that reads its body through + * `parseJsonBody` has no such block: each call is a site of its own, carrying + * the issue list by construction, and its `message` option is read as the + * refused string by the token sweep below. * * The second half of the same audit item is the dotted token in `error`, and * it is checked by its own sweep at the bottom of this file rather than by the @@ -94,11 +97,58 @@ interface RefusalSite { carriesIssues: boolean; } +/** + * Every `parseJsonBody(…)` call in a file, as its paren-balanced source text + * with the line it starts on. The helper reads, validates and refuses in one + * call (`src/lib/api/body.ts`), so a route that uses it has no + * `if (!x.success)` block for the matcher above to find; its refusal is the + * helper's envelope, which always carries `details.issues`. + */ +function parseJsonBodyCalls(src: string): { text: string; line: number }[] { + const calls: { text: string; line: number }[] = []; + for (const m of src.matchAll(/\bparseJsonBody\s*\(/g)) { + const start = m.index ?? 0; + let depth = 0; + let end = src.length; + for (let j = start + m[0].length - 1; j < src.length; j++) { + if (src[j] === "(") depth++; + else if (src[j] === ")") { + depth--; + if (depth === 0) { + end = j + 1; + break; + } + } + } + calls.push({ + text: src.slice(start, end), + line: src.slice(0, start).split("\n").length, + }); + } + return calls; +} + +/** The status a `parseJsonBody` call refuses with: its `status` option, else 422. */ +function parseJsonBodyStatus(call: string): number { + const m = /\bstatus:\s*(\d{3})\b/.exec(call); + return m ? Number(m[1]) : 422; +} + /** 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"); + // A `parseJsonBody` refusal is a 422 with the issue list unless the call + // names another status, which is then a different refusal, as below. + for (const call of parseJsonBodyCalls(src)) { + if (parseJsonBodyStatus(call.text) !== 422) continue; + sites.push({ + route: relative(process.cwd(), file).split(sep).join("/"), + line: call.line, + carriesIssues: true, + }); + } // 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(); @@ -228,12 +278,20 @@ function tokenRefusals(): string[] { /apiValidationError\(\s*"([^"]+)"/g, /new HttpError\(\s*422\s*,\s*"([^"]+)"/g, ]; + const refused: string[] = []; for (const shape of shapes) { - for (const m of src.matchAll(shape)) { - if (!DOTTED_TOKEN.test(m[1])) continue; - const key = `${route} ${m[1]}`; - if (!found.includes(key)) found.push(key); - } + for (const m of src.matchAll(shape)) refused.push(m[1]); + } + // `parseJsonBody`'s `message` option is the 422's `error` string. + for (const call of parseJsonBodyCalls(src)) { + if (parseJsonBodyStatus(call.text) !== 422) continue; + const m = /\bmessage:\s*"([^"]+)"/.exec(call.text); + if (m) refused.push(m[1]); + } + for (const text of refused) { + if (!DOTTED_TOKEN.test(text)) continue; + const key = `${route} ${text}`; + if (!found.includes(key)) found.push(key); } } return found; diff --git a/src/app/api/admin/encryption/key-backup/confirm/route.ts b/src/app/api/admin/encryption/key-backup/confirm/route.ts index b82977fd4..1a9f57724 100644 --- a/src/app/api/admin/encryption/key-backup/confirm/route.ts +++ b/src/app/api/admin/encryption/key-backup/confirm/route.ts @@ -11,13 +11,8 @@ import type { NextRequest } from "next/server"; import { z } from "zod/v4"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { annotate } from "@/lib/logging/context"; import { @@ -38,10 +33,10 @@ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAdmin(); annotate({ action: { name: "admin.encryption.keyBackup.confirm" } }); - const body = await safeJson(request, { maxBytes: 1024 }); - if (body.error) return body.error; - const parsed = confirmSchema.safeParse(body.data); - if (!parsed.success) return returnAllZodIssues(parsed.error); + const parsed = await parseJsonBody(request, confirmSchema, { + maxBytes: 1024, + }); + if (!parsed.ok) return parsed.error; const result = await confirmKeyBackup({ keyId: parsed.data.keyId, diff --git a/src/app/api/admin/invites/route.ts b/src/app/api/admin/invites/route.ts index 63552fea1..7f22909b4 100644 --- a/src/app/api/admin/invites/route.ts +++ b/src/app/api/admin/invites/route.ts @@ -13,12 +13,8 @@ */ import { prisma } from "@/lib/db"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; -import { - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { hashToken } from "@/lib/auth/hmac"; @@ -62,15 +58,11 @@ export const GET = apiHandler(async () => { export const POST = apiHandler(async (req: Request) => { const { user } = await requireAdmin(); - const { data: body, error: jsonError } = await safeJson(req, { + const parsed = await parseJsonBody(req, inviteCreateSchema, { maxBytes: 16 * 1024, + input: (body) => body ?? {}, }); - if (jsonError) return jsonError; - - const parsed = inviteCreateSchema.safeParse(body ?? {}); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const { expiresInDays, maxUses } = parsed.data; const rawToken = generateInviteToken(); 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 27ce847d4..9f7e7093b 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 @@ -1,14 +1,8 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; -import { - apiError, - apiSuccess, - apiValidationError, - getClientIp, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; import { z } from "zod/v4"; @@ -53,19 +47,12 @@ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAdmin(); annotate({ action: { name: "admin.webPushVapid.generate" } }); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, generateSchema, { maxBytes: 4 * 1024, + input: (body) => body ?? {}, + message: "Invalid request", }); - if (jsonError) return jsonError; - - const parsed = generateSchema.safeParse(body ?? {}); - if (!parsed.success) { - return apiValidationError( - "Invalid request", - sanitiseZodIssues(parsed.error.issues), - 422, - ); - } + if (!parsed.ok) return parsed.error; const existing = await prisma.appSettings.findUnique({ where: { id: "singleton" }, diff --git a/src/app/api/anamnesis/emergency/route.ts b/src/app/api/anamnesis/emergency/route.ts index faa8e1d1b..2247c7c36 100644 --- a/src/app/api/anamnesis/emergency/route.ts +++ b/src/app/api/anamnesis/emergency/route.ts @@ -1,12 +1,8 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { annotate } from "@/lib/logging/context"; import { @@ -31,13 +27,10 @@ export const GET = apiHandler(async () => { export const PATCH = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: rawBody, error } = await safeJson(request, { + const parsed = await parseJsonBody(request, emergencyProfileUpdateSchema, { maxBytes: 16 * 1024, }); - if (error) return error; - - const parsed = emergencyProfileUpdateSchema.safeParse(rawBody); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; const payload = await writeEmergencyProfile(user.id, parsed.data); await auditLog("anamnesis.emergency.update", { diff --git a/src/app/api/anamnesis/facts/[id]/route.ts b/src/app/api/anamnesis/facts/[id]/route.ts index 5551cd635..fc7f8f812 100644 --- a/src/app/api/anamnesis/facts/[id]/route.ts +++ b/src/app/api/anamnesis/facts/[id]/route.ts @@ -1,13 +1,8 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; @@ -39,13 +34,10 @@ export const PATCH = apiHandler( async (request: NextRequest, { params }: RouteParams) => { const { user } = await requireAuth(); const { id } = await params; - const { data: rawBody, error } = await safeJson(request, { + const parsed = await parseJsonBody(request, factCorrectionSchema, { maxBytes: 8 * 1024, }); - if (error) return error; - - const parsed = factCorrectionSchema.safeParse(rawBody); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; const current = await prisma.healthProfileFactRevision.findFirst({ where: { diff --git a/src/app/api/anamnesis/facts/route.ts b/src/app/api/anamnesis/facts/route.ts index 5d912b023..262c7d1d9 100644 --- a/src/app/api/anamnesis/facts/route.ts +++ b/src/app/api/anamnesis/facts/route.ts @@ -1,13 +1,8 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { withIdempotency } from "@/lib/idempotency"; import { annotate } from "@/lib/logging/context"; @@ -41,13 +36,10 @@ export const GET = apiHandler(async () => { async function postFact(request: NextRequest): Promise { const { user } = await requireAuth(); - const { data: rawBody, error } = await safeJson(request, { + const parsed = await parseJsonBody(request, factWriteSchema, { maxBytes: 8 * 1024, }); - if (error) return error; - - const parsed = factWriteSchema.safeParse(rawBody); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; const now = new Date(); const validFrom = parsed.data.validFrom diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index 17f4b07ca..864071182 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -5,10 +5,9 @@ import { apiError, apiSuccess, getClientIp, - returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { destroyedDetails, @@ -122,34 +121,29 @@ export const PUT = apiHandler( }); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, updateBiomarkerSchema, { maxBytes: 16 * 1024, + action: "labs.biomarker.update.validation-failed", + countIssues: true, + meta: { biomarkerId: id }, + errorCode: "labs.biomarker.update.invalid", + onInvalid: (error) => { + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only writer that stamps + // `actorUserId`. Filed under the resolved record either way; without + // the stamp a manager's malformed payload would read as the owner's own. + void auditLog("labs.biomarker.update.validation-failed", { + userId: user.id, + details: { issues: auditIssues, biomarkerId: id }, + }).catch(() => { + /* swallow — the 422 response is the contract */ + }); + }, }); - if (jsonError) return jsonError; - - const parsed = updateBiomarkerSchema.safeParse(body); - if (!parsed.success) { - annotate({ - action: { name: "labs.biomarker.update.validation-failed" }, - meta: { issue_count: parsed.error.issues.length, biomarkerId: id }, - }); - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog - // .create`, because that helper is the only writer that stamps - // `actorUserId`. Filed under the resolved record either way; without the - // stamp a manager's malformed payload would read as the owner's own. - void auditLog("labs.biomarker.update.validation-failed", { - userId: user.id, - details: { issues: auditIssues, biomarkerId: id }, - }).catch(() => { - /* swallow — the 422 response is the contract */ - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "labs.biomarker.update.invalid", - }); - } + if (!parsed.ok) return parsed.error; const d = parsed.data; diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts index a68d39152..3dca738f1 100644 --- a/src/app/api/biomarkers/route.ts +++ b/src/app/api/biomarkers/route.ts @@ -5,10 +5,9 @@ import { apiError, apiSuccess, getClientIp, - returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { withIdempotency } from "@/lib/idempotency"; @@ -77,31 +76,25 @@ async function postBiomarker(request: NextRequest) { // tracks gets the ordinary 409 rather than a second copy. const { user } = await requireRecordAuth("write", "labs"); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, createBiomarkerSchema, { maxBytes: 16 * 1024, + action: "labs.biomarker.create.validation-failed", + countIssues: true, + errorCode: "labs.biomarker.create.invalid", + onInvalid: (error) => { + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + // v1.36.x — through `auditLog()`, the only writer that stamps the actor. + void auditLog("labs.biomarker.create.validation-failed", { + userId: user.id, + details: { issues: auditIssues }, + }).catch(() => { + /* swallow — the 422 response is the contract */ + }); + }, }); - if (jsonError) return jsonError; - - const parsed = createBiomarkerSchema.safeParse(body); - if (!parsed.success) { - annotate({ - action: { name: "labs.biomarker.create.validation-failed" }, - meta: { issue_count: parsed.error.issues.length }, - }); - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - // v1.36.x — through `auditLog()`, the only writer that stamps the actor. - void auditLog("labs.biomarker.create.validation-failed", { - userId: user.id, - details: { issues: auditIssues }, - }).catch(() => { - /* swallow — the 422 response is the contract */ - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "labs.biomarker.create.invalid", - }); - } + if (!parsed.ok) return parsed.error; const { name, unit, lowerBound, upperBound, context, panel } = parsed.data; diff --git a/src/app/api/coach/about-me/questions/route.ts b/src/app/api/coach/about-me/questions/route.ts index b9639e24b..85c1fa9a2 100644 --- a/src/app/api/coach/about-me/questions/route.ts +++ b/src/app/api/coach/about-me/questions/route.ts @@ -13,7 +13,8 @@ import { z } from "zod/v4"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { apiSuccess } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { getPendingQuestionsForUser, @@ -48,15 +49,11 @@ export const DELETE = apiHandler(async (req: Request) => { const gate = await requireModuleEnabled(user.id, "coach"); if (!gate.enabled) return gate.response; - const { data: body, error: jsonError } = await safeJson(req, { + const parsed = await parseJsonBody(req, dismissSchema, { maxBytes: 4 * 1024, + input: (body) => body ?? {}, }); - if (jsonError) return jsonError; - - const parsed = dismissSchema.safeParse(body ?? {}); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const current = await getPendingQuestionsForUser(user.id); const remaining = diff --git a/src/app/api/consent/ai/route.ts b/src/app/api/consent/ai/route.ts index 5f460e415..86421e8ca 100644 --- a/src/app/api/consent/ai/route.ts +++ b/src/app/api/consent/ai/route.ts @@ -18,12 +18,8 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { fireAndForget } from "@/lib/logging/fire-and-forget"; -import { - apiError, - apiSuccess, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -50,16 +46,12 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } - const { data: body, error } = await safeJson(request, { + // v1.4.43 — multi-issue 400 (consent routes use 400 not 422). + const parsed = await parseJsonBody(request, consentPostBody, { maxBytes: 128 * 1024, + status: 400, }); - if (error) return error; - - const parsed = consentPostBody.safeParse(body); - if (!parsed.success) { - // v1.4.43 — multi-issue 400 (consent routes use 400 not 422). - return returnAllZodIssues(parsed.error, 400); - } + if (!parsed.ok) return parsed.error; const { kind, artefact, signedAt, confirm } = parsed.data; if (confirm !== true) { diff --git a/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts b/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts index eb3e9cfa1..2f1d171fe 100644 --- a/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts @@ -5,10 +5,9 @@ import { apiError, apiSuccess, getClientIp, - returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store"; @@ -49,33 +48,29 @@ export const PATCH = apiHandler( return apiError("Custom metric entry not found", 404); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, updateCustomMetricEntrySchema, { maxBytes: 16 * 1024, - }); - if (jsonError) return jsonError; - - const parsed = updateCustomMetricEntrySchema.safeParse(body); - if (!parsed.success) { - annotate({ - action: { name: "custom-metric.entry.update.validation-failed" }, - meta: { issue_count: parsed.error.issues.length, entryId }, - }); - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "custom-metric.entry.update.validation-failed", - details: JSON.stringify({ issues: auditIssues, entryId }), - }, - }) - .catch(() => { - /* swallow — the 422 response is the contract */ + action: "custom-metric.entry.update.validation-failed", + countIssues: true, + meta: { entryId }, + onInvalid: (error) => { + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, }); - return returnAllZodIssues(parsed.error, 422); - } + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "custom-metric.entry.update.validation-failed", + details: JSON.stringify({ issues: auditIssues, entryId }), + }, + }) + .catch(() => { + /* swallow — the 422 response is the contract */ + }); + }, + }); + if (!parsed.ok) return parsed.error; const d = parsed.data; diff --git a/src/app/api/custom-metrics/[id]/entries/route.ts b/src/app/api/custom-metrics/[id]/entries/route.ts index 2e1832ad5..d4e9ea3c9 100644 --- a/src/app/api/custom-metrics/[id]/entries/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/route.ts @@ -6,9 +6,9 @@ import { apiSuccess, getClientIp, returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store"; @@ -117,29 +117,25 @@ async function postCustomMetricEntry( return apiError("Custom metric not found", 404); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, createCustomMetricEntrySchema, { maxBytes: 16 * 1024, + action: "custom-metric.entry.create.validation-failed", + countIssues: true, + meta: { customMetricId: id }, + onInvalid: (error) => { + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + // v1.36.x — through `auditLog()`, the only writer that stamps the actor. + void auditLog("custom-metric.entry.create.validation-failed", { + userId: user.id, + details: { issues: auditIssues, customMetricId: id }, + }).catch(() => { + /* swallow — the 422 response is the contract */ + }); + }, }); - if (jsonError) return jsonError; - - const parsed = createCustomMetricEntrySchema.safeParse(body); - if (!parsed.success) { - annotate({ - action: { name: "custom-metric.entry.create.validation-failed" }, - meta: { issue_count: parsed.error.issues.length, customMetricId: id }, - }); - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - // v1.36.x — through `auditLog()`, the only writer that stamps the actor. - void auditLog("custom-metric.entry.create.validation-failed", { - userId: user.id, - details: { issues: auditIssues, customMetricId: id }, - }).catch(() => { - /* swallow — the 422 response is the contract */ - }); - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const { value, measuredAt, note } = parsed.data; diff --git a/src/app/api/custom-metrics/[id]/route.ts b/src/app/api/custom-metrics/[id]/route.ts index 6ba09b4c0..3a2ec7842 100644 --- a/src/app/api/custom-metrics/[id]/route.ts +++ b/src/app/api/custom-metrics/[id]/route.ts @@ -5,10 +5,9 @@ import { apiError, apiSuccess, getClientIp, - returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; import { @@ -81,36 +80,32 @@ export const PATCH = apiHandler( return apiError("Custom metric not found", 404); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, updateCustomMetricSchema, { maxBytes: 16 * 1024, - }); - if (jsonError) return jsonError; - - const parsed = updateCustomMetricSchema.safeParse(body); - if (!parsed.success) { - annotate({ - action: { name: "custom-metric.metric.update.validation-failed" }, - meta: { issue_count: parsed.error.issues.length, customMetricId: id }, - }); - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "custom-metric.metric.update.validation-failed", - details: JSON.stringify({ - issues: auditIssues, - customMetricId: id, - }), - }, - }) - .catch(() => { - /* swallow — the 422 response is the contract */ + action: "custom-metric.metric.update.validation-failed", + countIssues: true, + meta: { customMetricId: id }, + onInvalid: (error) => { + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, }); - return returnAllZodIssues(parsed.error, 422); - } + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "custom-metric.metric.update.validation-failed", + details: JSON.stringify({ + issues: auditIssues, + customMetricId: id, + }), + }, + }) + .catch(() => { + /* swallow — the 422 response is the contract */ + }); + }, + }); + if (!parsed.ok) return parsed.error; const d = parsed.data; diff --git a/src/app/api/custom-metrics/route.ts b/src/app/api/custom-metrics/route.ts index 3dd654c55..74bf526ba 100644 --- a/src/app/api/custom-metrics/route.ts +++ b/src/app/api/custom-metrics/route.ts @@ -5,10 +5,9 @@ import { apiError, apiSuccess, getClientIp, - returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; import { @@ -63,33 +62,28 @@ export const POST = apiHandler( async function postCustomMetric(request: NextRequest) { const { user } = await requireAuth(); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, createCustomMetricSchema, { maxBytes: 16 * 1024, - }); - if (jsonError) return jsonError; - - const parsed = createCustomMetricSchema.safeParse(body); - if (!parsed.success) { - annotate({ - action: { name: "custom-metric.metric.create.validation-failed" }, - meta: { issue_count: parsed.error.issues.length }, - }); - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "custom-metric.metric.create.validation-failed", - details: JSON.stringify({ issues: auditIssues }), - }, - }) - .catch(() => { - /* swallow — the 422 response is the contract */ + action: "custom-metric.metric.create.validation-failed", + countIssues: true, + onInvalid: (error) => { + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, }); - return returnAllZodIssues(parsed.error, 422); - } + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "custom-metric.metric.create.validation-failed", + details: JSON.stringify({ issues: auditIssues }), + }, + }) + .catch(() => { + /* swallow — the 422 response is the contract */ + }); + }, + }); + if (!parsed.ok) return parsed.error; const { name, diff --git a/src/app/api/cycle/day-logs/route.ts b/src/app/api/cycle/day-logs/route.ts index 3f776a0bc..159696030 100644 --- a/src/app/api/cycle/day-logs/route.ts +++ b/src/app/api/cycle/day-logs/route.ts @@ -24,8 +24,8 @@ import { apiError, getClientIp, returnAllZodIssues, - safeJson, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { withIdempotency } from "@/lib/idempotency"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { @@ -99,27 +99,23 @@ async function postDayLog(request: NextRequest): Promise { const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, cycleDayLogCreateSchema, { maxBytes: 64 * 1024, + errorCode: "cycle.day-log.invalid", + onInvalid: (error, rawBody) => { + const shape = unstableExternalIdShape(rawBody); + annotate({ + action: { name: "cycle.day-log.validation-failed" }, + meta: { + issue_count: error.issues.length, + ...(shape + ? unstableExternalIdMeta("cycle.day_log.create", [shape]) + : {}), + }, + }); + }, }); - if (jsonError) return jsonError; - - const parsed = cycleDayLogCreateSchema.safeParse(rawBody); - if (!parsed.success) { - const shape = unstableExternalIdShape(rawBody); - annotate({ - action: { name: "cycle.day-log.validation-failed" }, - meta: { - issue_count: parsed.error.issues.length, - ...(shape - ? unstableExternalIdMeta("cycle.day_log.create", [shape]) - : {}), - }, - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "cycle.day-log.invalid", - }); - } + if (!parsed.ok) return parsed.error; const entry = parsed.data; const tz = user.timezone ?? DEFAULT_TIMEZONE; diff --git a/src/app/api/cycle/period/route.ts b/src/app/api/cycle/period/route.ts index 24c8edbc6..c7872438b 100644 --- a/src/app/api/cycle/period/route.ts +++ b/src/app/api/cycle/period/route.ts @@ -18,12 +18,8 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { overwriteDetails } from "@/lib/sharing/audit-details"; -import { - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { withIdempotency } from "@/lib/idempotency"; import { cyclePeriodSchema } from "@/lib/validations/cycle"; @@ -63,25 +59,21 @@ async function postPeriod(request: NextRequest): Promise { const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, cyclePeriodSchema, { maxBytes: 64 * 1024, + errorCode: "cycle.period.invalid", + onInvalid: (error, rawBody) => { + const shape = unstableExternalIdShape(rawBody); + annotate({ + action: { name: "cycle.period.validation-failed" }, + meta: { + issue_count: error.issues.length, + ...(shape ? unstableExternalIdMeta("cycle.period", [shape]) : {}), + }, + }); + }, }); - if (jsonError) return jsonError; - - const parsed = cyclePeriodSchema.safeParse(rawBody); - if (!parsed.success) { - const shape = unstableExternalIdShape(rawBody); - annotate({ - action: { name: "cycle.period.validation-failed" }, - meta: { - issue_count: parsed.error.issues.length, - ...(shape ? unstableExternalIdMeta("cycle.period", [shape]) : {}), - }, - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "cycle.period.invalid", - }); - } + if (!parsed.ok) return parsed.error; const { action, date, externalId, loggedAt } = parsed.data; const tz = user.timezone ?? DEFAULT_TIMEZONE; diff --git a/src/app/api/dashboard/chart-overlay-prefs/route.ts b/src/app/api/dashboard/chart-overlay-prefs/route.ts index 0f3e5464b..ab5c51a89 100644 --- a/src/app/api/dashboard/chart-overlay-prefs/route.ts +++ b/src/app/api/dashboard/chart-overlay-prefs/route.ts @@ -16,12 +16,8 @@ * same request. */ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { - apiSuccess, - returnAllZodIssues, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiSuccess, sanitiseZodIssues } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { shouldEmitAuditRow } from "@/lib/audit-dedup"; import { annotate } from "@/lib/logging/context"; import { prisma, toJson } from "@/lib/db"; @@ -60,42 +56,37 @@ const prefsSchema = z export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: body, error: jsonError } = await safeJson(request, { + // v1.4.43 — sibling of `/api/dashboard/widgets`; the per-chart + // overlay popover hits this on every toggle so the multi-issue + // envelope matches widgets exactly. Audit breadcrumb keyed + // `dashboard.chart-overlay.validation-failed`, deduped via the + // shared `shouldEmitAuditRow` 60 s `(userId, action)` window so a + // misbehaving iOS client looping the popover cannot flood the + // audit ledger. + const parsed = await parseJsonBody(request, prefsSchema, { maxBytes: 64 * 1024, + action: "dashboard.chart-overlay.validation-failed", + countIssues: true, + onInvalid: (error) => { + if ( + shouldEmitAuditRow(user.id, "dashboard.chart-overlay.validation-failed") + ) { + const issues = sanitiseZodIssues(error.issues); + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "dashboard.chart-overlay.validation-failed", + details: JSON.stringify({ issues }), + }, + }) + .catch(() => { + /* swallow — 422 response is the contract */ + }); + } + }, }); - if (jsonError) return jsonError; - - const parsed = prefsSchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 — sibling of `/api/dashboard/widgets`; the per-chart - // overlay popover hits this on every toggle so the multi-issue - // envelope matches widgets exactly. Audit breadcrumb keyed - // `dashboard.chart-overlay.validation-failed`, deduped via the - // shared `shouldEmitAuditRow` 60 s `(userId, action)` window so a - // misbehaving iOS client looping the popover cannot flood the - // audit ledger. - const issues = sanitiseZodIssues(parsed.error.issues); - annotate({ - action: { name: "dashboard.chart-overlay.validation-failed" }, - meta: { issue_count: issues.length }, - }); - if ( - shouldEmitAuditRow(user.id, "dashboard.chart-overlay.validation-failed") - ) { - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "dashboard.chart-overlay.validation-failed", - details: JSON.stringify({ issues }), - }, - }) - .catch(() => { - /* swallow — 422 response is the contract */ - }); - } - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; // Read-modify-write under a row lock, so two concurrent toggles (two // tabs, two charts flipped in quick succession) can neither drop one diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts index 17cfb2d16..5ad6c53a1 100644 --- a/src/app/api/devices/route.ts +++ b/src/app/api/devices/route.ts @@ -24,13 +24,8 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - returnAllZodIssues, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiError, apiSuccess, sanitiseZodIssues } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; @@ -113,42 +108,36 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Too many device registrations, please slow down", 429); } - const { data: body, error } = await safeJson(request, { + // v1.4.43 — iOS device-registration multi-issue 422 + audit + // breadcrumb. APNs-token + bundleId + environment + model + locale + // can all fail simultaneously on a misconfigured client build. + const parsed = await parseJsonBody(request, deviceSchema, { maxBytes: 64 * 1024, - }); - if (error) return error; - - const parsed = deviceSchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 — iOS device-registration multi-issue 422 + audit - // breadcrumb. APNs-token + bundleId + environment + model + locale - // can all fail simultaneously on a misconfigured client build. - const issues = sanitiseZodIssues(parsed.error.issues); - annotate({ - action: { name: "devices.register.validation-failed" }, - meta: { issue_count: issues.length }, - }); - // v1.4.49 — strip `message` from the audit-ledger row. Device - // registration carries `token` + `apnsToken`; the default Zod - // message for `invalid_format` echoes the offending value, which - // would persist an APNs token (or a forged near-miss) into the - // audit ledger. - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "devices.register.validation-failed", - details: JSON.stringify({ issues: auditIssues }), - }, - }) - .catch(() => { - /* swallow — 422 response is the contract */ + action: "devices.register.validation-failed", + countIssues: true, + onInvalid: (error) => { + // v1.4.49 — strip `message` from the audit-ledger row. Device + // registration carries `token` + `apnsToken`; the default Zod + // message for `invalid_format` echoes the offending value, which + // would persist an APNs token (or a forged near-miss) into the + // audit ledger. + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, }); - return returnAllZodIssues(parsed.error, 422); - } + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "devices.register.validation-failed", + details: JSON.stringify({ issues: auditIssues }), + }, + }) + .catch(() => { + /* swallow — 422 response is the contract */ + }); + }, + }); + if (!parsed.ok) return parsed.error; const { token, diff --git a/src/app/api/illness/episodes/[id]/resolve/route.ts b/src/app/api/illness/episodes/[id]/resolve/route.ts index dab4ac6c6..2bb8be1b5 100644 --- a/src/app/api/illness/episodes/[id]/resolve/route.ts +++ b/src/app/api/illness/episodes/[id]/resolve/route.ts @@ -14,13 +14,8 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; -import { - apiSuccess, - apiError, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { requireIllnessEnabled } from "@/lib/illness/gate"; import { illnessEpisodeResolveSchema } from "@/lib/validations/illness"; import { toIllnessEpisodeDTO } from "@/lib/illness/dto"; @@ -60,17 +55,12 @@ export const PATCH = apiHandler( }); } - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, illnessEpisodeResolveSchema, { maxBytes: 4 * 1024, + input: (rawBody) => rawBody ?? {}, + errorCode: "illness.episode.invalid", }); - if (jsonError) return jsonError; - - const parsed = illnessEpisodeResolveSchema.safeParse(rawBody ?? {}); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "illness.episode.invalid", - }); - } + if (!parsed.ok) return parsed.error; const resolvedAt = parsed.data.resolvedAt ? new Date(parsed.data.resolvedAt) diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index e91694740..f39f89c08 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -6,10 +6,9 @@ import { apiSuccess, apiError, getClientIp, - returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { hashToken } from "@/lib/auth/hmac"; import { checkAuthSurfaceRateLimit, @@ -152,45 +151,43 @@ export const POST = apiHandler(async (request: NextRequest) => { data: { lastUsedAt: new Date() }, }); - const { data: body, error: jsonError } = await safeJson(request, { + // v1.4.43 — external native ingest is a bulk-source; surface + // every Zod issue so a Telegram-bridge or HomeAssistant + // integration sees the full validation shape at once. Audit + // breadcrumb keyed `ingest.medication.validation-failed`. + const parsed = await parseJsonBody(request, externalIntakeSchema, { maxBytes: 64 * 1024, - }); - - if (jsonError) return jsonError; - const parsed = externalIntakeSchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 — external native ingest is a bulk-source; surface - // every Zod issue so a Telegram-bridge or HomeAssistant - // integration sees the full validation shape at once. Audit - // breadcrumb keyed `ingest.medication.validation-failed`. - const issues = sanitiseZodIssues(parsed.error.issues); - const shape = unstableExternalIdShape(body, "idempotencyKey"); - annotate({ - action: { name: "ingest.medication.validation-failed" }, - meta: { - issue_count: issues.length, - ...(shape ? unstableExternalIdMeta("medication.ingest", [shape]) : {}), - }, - }); - // v1.4.49 — strip `message` from the audit-ledger row; external - // ingest carries free-text `medicationName` + opaque - // `idempotencyKey`. - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - prisma.auditLog - .create({ - data: { - userId: apiToken.userId, - action: "ingest.medication.validation-failed", - details: JSON.stringify({ issues: auditIssues }), + onInvalid: (error, body) => { + const shape = unstableExternalIdShape(body, "idempotencyKey"); + annotate({ + action: { name: "ingest.medication.validation-failed" }, + meta: { + issue_count: error.issues.length, + ...(shape + ? unstableExternalIdMeta("medication.ingest", [shape]) + : {}), }, - }) - .catch(() => { - /* swallow — 422 response is the contract */ }); - return returnAllZodIssues(parsed.error, 422); - } + // v1.4.49 — strip `message` from the audit-ledger row; external + // ingest carries free-text `medicationName` + opaque + // `idempotencyKey`. + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + prisma.auditLog + .create({ + data: { + userId: apiToken.userId, + action: "ingest.medication.validation-failed", + details: JSON.stringify({ issues: auditIssues }), + }, + }) + .catch(() => { + /* swallow — 422 response is the contract */ + }); + }, + }); + if (!parsed.ok) return parsed.error; const { medicationName, takenAt, idempotencyKey } = parsed.data; 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 344eb38ff..05c2410cc 100644 --- a/src/app/api/insights/chat/messages/[id]/feedback/route.ts +++ b/src/app/api/insights/chat/messages/[id]/feedback/route.ts @@ -31,14 +31,8 @@ import type { NextRequest } from "next/server"; import { z } from "zod/v4"; import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - apiValidationError, - getClientIp, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { Prisma } from "@/generated/prisma/client"; @@ -79,28 +73,19 @@ async function handlePost(request: NextRequest, ctx: RouteContext) { return apiError("Too many feedback submissions, try again later", 429); } - const { data: rawBody, error } = await safeJson(request, { + // The dotted token keeps its place in `error`: this refusal keeps its 422, + // so moving the string would be a wire change with nothing forcing it — + // the `invalid_json` siblings moved only because their status moved to 400 + // anyway. `meta.errorCode` publishes the same token in the field a machine + // code belongs in, and `details.issues` names the fields that were refused. + const parsed = await parseJsonBody(request, coachMessageFeedbackSchema, { maxBytes: 64 * 1024, + action: "insights.coach.message.feedback", + meta: { outcome: "validation_failed" }, + message: "feedback.body.invalid", + errorCode: "feedback.body.invalid", }); - if (error) return error; - const parsed = coachMessageFeedbackSchema.safeParse(rawBody); - if (!parsed.success) { - annotate({ - action: { name: "insights.coach.message.feedback" }, - meta: { outcome: "validation_failed" }, - }); - // The dotted token keeps its place in `error`: this refusal keeps its 422, - // so moving the string would be a wire change with nothing forcing it — - // the `invalid_json` siblings moved only because their status moved to 400 - // anyway. `meta.errorCode` publishes the same token 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" }, - ); - } + if (!parsed.ok) return parsed.error; const body = parsed.data; // Verify ownership only — we no longer touch encryptedContent. The diff --git a/src/app/api/insights/ecg/route.ts b/src/app/api/insights/ecg/route.ts index 4a9cfd219..65d8f897d 100644 --- a/src/app/api/insights/ecg/route.ts +++ b/src/app/api/insights/ecg/route.ts @@ -35,12 +35,8 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; -import { - apiError, - apiSuccess, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -223,27 +219,24 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Too many ECG submissions, try again later", 429); } - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, ecgIngestSchema, { maxBytes: MAX_BODY_BYTES, + onInvalid: (error) => { + // Which field was refused and why, never the value: without it a refused + // recording is a bare "invalid" in the log and the cause has to be guessed. + annotate({ + action: { name: "insights.ecg.ingest" }, + meta: { + outcome: "invalid", + invalidFields: error.issues + .map((issue) => `${issue.path.join(".") || "(body)"}:${issue.code}`) + .slice(0, 8) + .join(","), + }, + }); + }, }); - if (jsonError) return jsonError; - - const parsed = ecgIngestSchema.safeParse(rawBody); - if (!parsed.success) { - // Which field was refused and why, never the value: without it a refused - // recording is a bare "invalid" in the log and the cause has to be guessed. - annotate({ - action: { name: "insights.ecg.ingest" }, - meta: { - outcome: "invalid", - invalidFields: parsed.error.issues - .map((issue) => `${issue.path.join(".") || "(body)"}:${issue.code}`) - .slice(0, 8) - .join(","), - }, - }); - return returnAllZodIssues(parsed.error); - } + if (!parsed.ok) return parsed.error; const body = parsed.data; const result = await persistEcgRecording( diff --git a/src/app/api/insights/feedback/route.ts b/src/app/api/insights/feedback/route.ts index c98cab6bb..f1178254d 100644 --- a/src/app/api/insights/feedback/route.ts +++ b/src/app/api/insights/feedback/route.ts @@ -1,14 +1,8 @@ import type { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - apiValidationError, - getClientIp, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { Prisma } from "@/generated/prisma/client"; @@ -57,23 +51,17 @@ async function handlePost(request: NextRequest) { return apiError("Too many feedback submissions, try again later", 429); } - const { data: rawBody, error } = await safeJson(request, { - maxBytes: 64 * 1024, - }); - if (error) return error; - - const parsed = recommendationFeedbackRequestSchema.safeParse(rawBody); - if (!parsed.success) { - annotate({ - action: { name: "insights.recommendation.feedback" }, + const parsed = await parseJsonBody( + request, + recommendationFeedbackRequestSchema, + { + maxBytes: 64 * 1024, + action: "insights.recommendation.feedback", meta: { outcome: "validation_failed" }, - }); - return apiValidationError( - "Invalid feedback payload", - sanitiseZodIssues(parsed.error.issues), - 422, - ); - } + message: "Invalid feedback payload", + }, + ); + if (!parsed.ok) return parsed.error; const body = parsed.data; const attribution = await resolveFeedbackAttribution(user.id); diff --git a/src/app/api/insights/provider-chain/route.ts b/src/app/api/insights/provider-chain/route.ts index e2063b78e..4685916f1 100644 --- a/src/app/api/insights/provider-chain/route.ts +++ b/src/app/api/insights/provider-chain/route.ts @@ -1,11 +1,7 @@ import type { NextRequest } from "next/server"; import { apiHandler, HttpError, requireAuth } from "@/lib/api-handler"; -import { - apiSuccess, - apiValidationError, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiSuccess } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { probeProviderChain, resolveProviderChain } from "@/lib/ai/provider"; import { @@ -90,19 +86,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "insights.provider_chain.update" } }); - const { data: body, error } = await safeJson(request, { + const parsed = await parseJsonBody(request, providerChainPutSchema, { maxBytes: 64 * 1024, + message: "Invalid provider chain payload", }); - if (error) return error; - - const parsed = providerChainPutSchema.safeParse(body); - if (!parsed.success) { - return apiValidationError( - "Invalid provider chain payload", - sanitiseZodIssues(parsed.error.issues), - 422, - ); - } + if (!parsed.ok) return parsed.error; // Defence-in-depth: reject duplicate provider types so a stale-tab // resubmit cannot create a chain that walks the same entry twice. diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts index d7f7e0c3c..cd6fd1043 100644 --- a/src/app/api/labs/[id]/route.ts +++ b/src/app/api/labs/[id]/route.ts @@ -5,10 +5,9 @@ import { apiError, apiSuccess, getClientIp, - returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { overwriteDetails } from "@/lib/sharing/audit-details"; import { prisma } from "@/lib/db"; @@ -111,34 +110,29 @@ export const PUT = apiHandler( }); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, updateLabResultSchema, { maxBytes: 16 * 1024, + action: "labs.update.validation-failed", + countIssues: true, + meta: { labResultId: id }, + errorCode: "labs.update.invalid", + onInvalid: (error) => { + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only writer that stamps + // `actorUserId`. Filed under the resolved record either way; without + // the stamp a manager's malformed payload would read as the owner's own. + void auditLog("labs.update.validation-failed", { + userId: user.id, + details: { issues: auditIssues, labResultId: id }, + }).catch(() => { + /* swallow — the 422 response is the contract */ + }); + }, }); - if (jsonError) return jsonError; - - const parsed = updateLabResultSchema.safeParse(body); - if (!parsed.success) { - annotate({ - action: { name: "labs.update.validation-failed" }, - meta: { issue_count: parsed.error.issues.length, labResultId: id }, - }); - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog - // .create`, because that helper is the only writer that stamps - // `actorUserId`. Filed under the resolved record either way; without the - // stamp a manager's malformed payload would read as the owner's own. - void auditLog("labs.update.validation-failed", { - userId: user.id, - details: { issues: auditIssues, labResultId: id }, - }).catch(() => { - /* swallow — the 422 response is the contract */ - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "labs.update.invalid", - }); - } + if (!parsed.ok) return parsed.error; const d = parsed.data; diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index 4b49874ea..9db6f0e75 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -19,13 +19,8 @@ import { NextRequest } from "next/server"; import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiSuccess, getClientIp, sanitiseZodIssues } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; @@ -159,32 +154,27 @@ async function commitOcrRows(request: NextRequest) { // OCR_MAX_ROWS rows at every field's longest bound run past 256 KB; 512 // keeps a full report in one body with room to spare. - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, ocrCommitSchema, { maxBytes: 512 * 1024, + action: "labs.ocr.commit.validation-failed", + countIssues: true, + onInvalid: (error) => { + // Free-text analyte / unit could land in a Zod issue message — strip values. + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "labs.ocr.commit.validation-failed", + details: JSON.stringify({ issues: auditIssues }), + }, + }) + .catch(() => {}); + }, }); - if (jsonError) return jsonError; - - const parsed = ocrCommitSchema.safeParse(body); - if (!parsed.success) { - annotate({ - action: { name: "labs.ocr.commit.validation-failed" }, - meta: { issue_count: parsed.error.issues.length }, - }); - // Free-text analyte / unit could land in a Zod issue message — strip values. - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "labs.ocr.commit.validation-failed", - details: JSON.stringify({ issues: auditIssues }), - }, - }) - .catch(() => {}); - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const inserted: ReturnType[] = []; const skipped: OcrSkippedRowDto[] = []; diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 31bd837a1..4732acab9 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -7,9 +7,9 @@ import { apiSuccess, getClientIp, returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; @@ -157,35 +157,29 @@ async function postLabResult(request: NextRequest) { // that leaves the owner with a usable catalogue afterwards. const { user } = await requireRecordAuth("write", "labs"); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, createLabResultSchema, { maxBytes: 16 * 1024, + action: "labs.create.validation-failed", + countIssues: true, + errorCode: "labs.create.invalid", + onInvalid: (error) => { + // Free-text `analyte` / `unit` / `note` could land verbatim in a Zod + // issue message — strip values from the audit-ledger breadcrumb. + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + // v1.36.x — through `auditLog()`, the only writer that stamps + // `actorUserId`. A bare `prisma.auditLog.create` files a delegate's + // malformed payload as if the owner had typed it. + void auditLog("labs.create.validation-failed", { + userId: user.id, + details: { issues: auditIssues }, + }).catch(() => { + /* swallow — the 422 response is the contract */ + }); + }, }); - if (jsonError) return jsonError; - - const parsed = createLabResultSchema.safeParse(body); - if (!parsed.success) { - annotate({ - action: { name: "labs.create.validation-failed" }, - meta: { issue_count: parsed.error.issues.length }, - }); - // Free-text `analyte` / `unit` / `note` could land verbatim in a Zod - // issue message — strip values from the audit-ledger breadcrumb. - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - // v1.36.x — through `auditLog()`, the only writer that stamps - // `actorUserId`. A bare `prisma.auditLog.create` files a delegate's - // malformed payload as if the owner had typed it. - void auditLog("labs.create.validation-failed", { - userId: user.id, - details: { issues: auditIssues }, - }).catch(() => { - /* swallow — the 422 response is the contract */ - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "labs.create.invalid", - }); - } + if (!parsed.ok) return parsed.error; const { biomarkerId, diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts index 7e1b8dc9e..336e45d68 100644 --- a/src/app/api/measurement-reminders/[id]/route.ts +++ b/src/app/api/measurement-reminders/[id]/route.ts @@ -21,10 +21,9 @@ import { apiSuccess, apiError, getClientIp, - returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { updateMeasurementReminderSchema } from "@/lib/validations/measurement-reminders"; import { @@ -92,33 +91,32 @@ export const PATCH = apiHandler( return apiError("Measurement reminder not found", 404); } - const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: 16 * 1024, - }); - if (jsonError) return jsonError; - - const parsed = updateMeasurementReminderSchema.safeParse(body); - if (!parsed.success) { - const issues = sanitiseZodIssues(parsed.error.issues); - annotate({ - action: { name: "measurement-reminders.update.validation-failed" }, - meta: { issue_count: issues.length, reminderId: id }, - }); - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog - // .create`, because that helper is the only writer that stamps - // `actorUserId`. Filed under the resolved record either way; without the - // stamp a manager's malformed payload would read as the owner's own. - void auditLog("measurement-reminders.update.validation-failed", { - userId: user.id, - details: { issues: auditIssues, reminderId: id }, - }).catch(() => { - /* swallow — 422 response is the contract */ - }); - return returnAllZodIssues(parsed.error, 422); - } + const parsed = await parseJsonBody( + request, + updateMeasurementReminderSchema, + { + maxBytes: 16 * 1024, + action: "measurement-reminders.update.validation-failed", + countIssues: true, + meta: { reminderId: id }, + onInvalid: (error) => { + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only writer that stamps + // `actorUserId`. Filed under the resolved record either way; without + // the stamp a manager's malformed payload would read as the owner's own. + void auditLog("measurement-reminders.update.validation-failed", { + userId: user.id, + details: { issues: auditIssues, reminderId: id }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); + }, + }, + ); + if (!parsed.ok) return parsed.error; const data = parsed.data; diff --git a/src/app/api/measurement-reminders/[id]/snooze/route.ts b/src/app/api/measurement-reminders/[id]/snooze/route.ts index c61146547..f50fc4f26 100644 --- a/src/app/api/measurement-reminders/[id]/snooze/route.ts +++ b/src/app/api/measurement-reminders/[id]/snooze/route.ts @@ -18,13 +18,8 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; -import { - apiSuccess, - apiError, - getClientIp, - safeJson, - returnAllZodIssues, -} from "@/lib/api-response"; +import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; import { snoozeMeasurementReminderSchema } from "@/lib/validations/measurement-reminders"; @@ -43,12 +38,11 @@ export const POST = apiHandler( const { user } = await requireRecordAuth("manage", "measurements"); const { id } = await params; - const body = await safeJson(request); - if (body.error) return body.error; - const parsed = snoozeMeasurementReminderSchema.safeParse(body.data); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422); - } + const parsed = await parseJsonBody( + request, + snoozeMeasurementReminderSchema, + ); + if (!parsed.ok) return parsed.error; // An appointment reminder is not addressable here — see the sibling // routes; snoozing one would move a date the visit record still owns. diff --git a/src/app/api/measurement-reminders/route.ts b/src/app/api/measurement-reminders/route.ts index 150bbe793..e1bad22ac 100644 --- a/src/app/api/measurement-reminders/route.ts +++ b/src/app/api/measurement-reminders/route.ts @@ -9,13 +9,8 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; -import { - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiSuccess, getClientIp, sanitiseZodIssues } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { withIdempotency } from "@/lib/idempotency"; import { createMeasurementReminderSchema } from "@/lib/validations/measurement-reminders"; @@ -57,33 +52,27 @@ async function postReminder(request: NextRequest): Promise { // row is listed, editable and removable by the owner. const { user } = await requireRecordAuth("manage", "measurements"); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, createMeasurementReminderSchema, { maxBytes: 16 * 1024, + action: "measurement-reminders.create.validation-failed", + countIssues: true, + onInvalid: (error) => { + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only writer that stamps + // `actorUserId`. Filed under the resolved record either way; without the + // stamp a manager's malformed payload would read as the owner's own. + void auditLog("measurement-reminders.create.validation-failed", { + userId: user.id, + details: { issues: auditIssues }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); + }, }); - if (jsonError) return jsonError; - - const parsed = createMeasurementReminderSchema.safeParse(body); - if (!parsed.success) { - const issues = sanitiseZodIssues(parsed.error.issues); - annotate({ - action: { name: "measurement-reminders.create.validation-failed" }, - meta: { issue_count: issues.length }, - }); - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog - // .create`, because that helper is the only writer that stamps - // `actorUserId`. Filed under the resolved record either way; without the - // stamp a manager's malformed payload would read as the owner's own. - void auditLog("measurement-reminders.create.validation-failed", { - userId: user.id, - details: { issues: auditIssues }, - }).catch(() => { - /* swallow — 422 response is the contract */ - }); - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const data = parsed.data; diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index 821017691..e0fd951f8 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -8,9 +8,9 @@ import { apiError, getClientIp, returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { updateMeasurementSchema, USER_CORRECTABLE_MEASUREMENT_SOURCES, @@ -75,40 +75,35 @@ export const PUT = apiHandler( return apiError("Measurement not found", 404); } - const { data: body, error: jsonError } = await safeJson(request, { + // v1.4.43 — measurement edit hot path; multi-issue 422 + + // audit breadcrumb keyed `measurements.update.validation-failed`. + const parsed = await parseJsonBody(request, updateMeasurementSchema, { maxBytes: 64 * 1024, + action: "measurements.update.validation-failed", + countIssues: true, + meta: { measurement_id: id }, + onInvalid: (error) => { + // v1.4.49 — strip `message` from the audit-ledger row; the + // update schema carries free-text `notes`. + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only writer that stamps + // `actorUserId`. Filed under the resolved record either way; without + // the stamp a manager's malformed payload would read as the owner's own. + void auditLog("measurements.update.validation-failed", { + userId: user.id, + details: { + issues: auditIssues, + measurementId: id, + }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); + }, }); - - if (jsonError) return jsonError; - const parsed = updateMeasurementSchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 — measurement edit hot path; multi-issue 422 + - // audit breadcrumb keyed `measurements.update.validation-failed`. - const issues = sanitiseZodIssues(parsed.error.issues); - annotate({ - action: { name: "measurements.update.validation-failed" }, - meta: { issue_count: issues.length, measurement_id: id }, - }); - // v1.4.49 — strip `message` from the audit-ledger row; the - // update schema carries free-text `notes`. - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog - // .create`, because that helper is the only writer that stamps - // `actorUserId`. Filed under the resolved record either way; without the - // stamp a manager's malformed payload would read as the owner's own. - void auditLog("measurements.update.validation-failed", { - userId: user.id, - details: { - issues: auditIssues, - measurementId: id, - }, - }).catch(() => { - /* swallow — 422 response is the contract */ - }); - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const data = parsed.data; diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts index b57b4b41f..934f49d45 100644 --- a/src/app/api/medications/[id]/intake/import/route.ts +++ b/src/app/api/medications/[id]/intake/import/route.ts @@ -6,10 +6,9 @@ import { apiError, apiSuccess, getClientIp, - returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { type MedicationImportPayload, @@ -121,55 +120,48 @@ export const POST = apiHandler( return apiError("Too many import submissions, try again later", 429); } - const { data: body, error: jsonError } = await safeJson(request, { + // v1.4.43 — CSV import; preserve the `Invalid format:` prefix + // semantics for the existing client-side branch by setting the + // `errorCode: "medication.intake.import.invalid_format"` meta. + // The client UI can now branch on `meta.errorCode` while every + // Zod issue is also surfaced under `details.issues`. Bulk-import + // path → audit breadcrumb keyed + // `medications.intake.import.validation-failed`. + const parsed = await parseJsonBody(request, validatedImportSchema, { maxBytes: 1024 * 1024, + // Support both direct arrays and object-with-array payloads + input: (body) => + Array.isArray(body) + ? body + : typeof body === "object" && body !== null + ? Object.values(body).find((value) => Array.isArray(value)) + : null, + action: "medications.intake.import.validation-failed", + countIssues: true, + meta: { medication_id: id }, + errorCode: "medication.intake.import.invalid_format", + onInvalid: (error) => { + // v1.4.49 — strip `message` from the audit-ledger row; CSV + // imports carry caller-provided strings that Zod may echo. + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only writer that stamps + // `actorUserId`. Filed under the resolved record either way; without + // the stamp a manager's malformed payload would read as the owner's own. + void auditLog("medications.intake.import.validation-failed", { + userId: user.id, + details: { + issues: auditIssues, + medicationId: id, + }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); + }, }); - - if (jsonError) return jsonError; - - // Support both direct arrays and object-with-array payloads - const payload = Array.isArray(body) - ? body - : typeof body === "object" && body !== null - ? Object.values(body).find((value) => Array.isArray(value)) - : null; - - const parsed = validatedImportSchema.safeParse(payload); - if (!parsed.success) { - // v1.4.43 — CSV import; preserve the `Invalid format:` prefix - // semantics for the existing client-side branch by setting the - // `errorCode: "medication.intake.import.invalid_format"` meta. - // The client UI can now branch on `meta.errorCode` while every - // Zod issue is also surfaced under `details.issues`. Bulk-import - // path → audit breadcrumb keyed - // `medications.intake.import.validation-failed`. - const issues = sanitiseZodIssues(parsed.error.issues); - annotate({ - action: { name: "medications.intake.import.validation-failed" }, - meta: { issue_count: issues.length, medication_id: id }, - }); - // v1.4.49 — strip `message` from the audit-ledger row; CSV - // imports carry caller-provided strings that Zod may echo. - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog - // .create`, because that helper is the only writer that stamps - // `actorUserId`. Filed under the resolved record either way; without the - // stamp a manager's malformed payload would read as the owner's own. - void auditLog("medications.intake.import.validation-failed", { - userId: user.id, - details: { - issues: auditIssues, - medicationId: id, - }, - }).catch(() => { - /* swallow — 422 response is the contract */ - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "medication.intake.import.invalid_format", - }); - } + if (!parsed.ok) return parsed.error; const entries = parsed.data; const normalized: MedicationImportPayload = { diff --git a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts index afe537197..d35be08d9 100644 --- a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts +++ b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts @@ -32,13 +32,8 @@ import { destroyedDetails, overwriteDetails, } from "@/lib/sharing/audit-details"; -import { - apiError, - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { scheduleRevisionUpdateSchema } from "@/lib/validations/schedule-revision"; import { @@ -68,15 +63,11 @@ export const PATCH = apiHandler( return apiError("Medication not found", 404); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, scheduleRevisionUpdateSchema, { maxBytes: 16 * 1024, + input: (body) => body ?? {}, }); - if (jsonError) return jsonError; - - const parsed = scheduleRevisionUpdateSchema.safeParse(body ?? {}); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const validFrom = new Date(parsed.data.validFrom); const validUntil = new Date(parsed.data.validUntil); diff --git a/src/app/api/medications/[id]/schedule-revisions/route.ts b/src/app/api/medications/[id]/schedule-revisions/route.ts index 8f27bb9c6..18be90e7a 100644 --- a/src/app/api/medications/[id]/schedule-revisions/route.ts +++ b/src/app/api/medications/[id]/schedule-revisions/route.ts @@ -27,13 +27,8 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; -import { - apiError, - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { scheduleRevisionCreateSchema } from "@/lib/validations/schedule-revision"; import { @@ -151,15 +146,11 @@ export const POST = apiHandler( return apiError("Medication not found", 404); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, scheduleRevisionCreateSchema, { maxBytes: 16 * 1024, + input: (body) => body ?? {}, }); - if (jsonError) return jsonError; - - const parsed = scheduleRevisionCreateSchema.safeParse(body ?? {}); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const validFrom = new Date(parsed.data.validFrom); const validUntil = new Date(parsed.data.validUntil); diff --git a/src/app/api/medications/extract/route.ts b/src/app/api/medications/extract/route.ts index 3868a8ac3..4764d4d08 100644 --- a/src/app/api/medications/extract/route.ts +++ b/src/app/api/medications/extract/route.ts @@ -55,12 +55,8 @@ import type { NextRequest } from "next/server"; import { z } from "zod/v4"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { assertAiEgress } from "@/lib/ai/capabilities/egress"; @@ -164,18 +160,16 @@ async function handleExtract(request: NextRequest): Promise { const userId = auth.user.id; // Body parse + Zod validation. - const { data: body, error: jsonError } = await safeJson(request, { + const validated = await parseJsonBody(request, requestSchema, { maxBytes: 16 * 1024, + onInvalid: (error) => { + annotate({ + action: { name: "medications.extract.invalid" }, + meta: { issues: error.issues.length }, + }); + }, }); - if (jsonError) return jsonError; - const validated = requestSchema.safeParse(body); - if (!validated.success) { - annotate({ - action: { name: "medications.extract.invalid" }, - meta: { issues: validated.error.issues.length }, - }); - return returnAllZodIssues(validated.error); - } + if (!validated.ok) return validated.error; const { text, locale, today: todayOverride } = validated.data; diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index 02f57d119..e829f1f97 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -24,9 +24,9 @@ import { apiError, apiSuccess, returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; import { auditLog } from "@/lib/auth/audit"; @@ -239,28 +239,23 @@ async function postAssessment(request: NextRequest): Promise { } // Bounded body read BEFORE validation (the item answers are small; cap so an - // unbounded body can never reach `request.json()`). Destructure the - // `{ data, error }` envelope — passing the wrapper to `safeParse` would never - // match the schema, which is what left this route dead-on-arrival. - const { data: body, error: jsonError } = await safeJson(request, { + // unbounded body can never reach `request.json()`). + const parsed = await parseJsonBody(request, createAssessmentSchema, { maxBytes: 8 * 1024, + onInvalid: (error, body) => { + const shape = unstableExternalIdShape(body); + annotate({ + action: { name: "mental-health.create.validation-failed" }, + meta: { + issue_count: error.issues.length, + ...(shape + ? unstableExternalIdMeta("mental_health.assessment.create", [shape]) + : {}), + }, + }); + }, }); - if (jsonError) return jsonError; - - const parsed = createAssessmentSchema.safeParse(body); - if (!parsed.success) { - const shape = unstableExternalIdShape(body); - annotate({ - action: { name: "mental-health.create.validation-failed" }, - meta: { - issue_count: sanitiseZodIssues(parsed.error.issues).length, - ...(shape - ? unstableExternalIdMeta("mental_health.assessment.create", [shape]) - : {}), - }, - }); - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const { instrument, diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index 7730d4d01..ad656d953 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -5,10 +5,9 @@ import { apiSuccess, apiError, getClientIp, - returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { updateMoodEntrySchema, getScoreForMood } from "@/lib/validations/mood"; import { NextRequest } from "next/server"; import { Prisma } from "@/generated/prisma/client"; @@ -88,39 +87,33 @@ export const PUT = apiHandler( }); } - const { data: body, error: jsonError } = await safeJson(request, { + // v1.4.43 — mood edit hot path; multi-issue 422 + audit + // breadcrumb keyed `mood-entries.update.validation-failed`. + const parsed = await parseJsonBody(request, updateMoodEntrySchema, { maxBytes: 64 * 1024, + action: "mood-entries.update.validation-failed", + countIssues: true, + meta: { moodEntryId: id }, + errorCode: "mood.update.invalid", + onInvalid: (error) => { + // v1.4.49 — strip `message` from the audit-ledger row; mood + // update carries free-text `note` + `tags`. + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only writer that stamps + // `actorUserId`. Filed under the resolved record either way; without + // the stamp a manager's malformed payload would read as the owner's own. + void auditLog("mood-entries.update.validation-failed", { + userId: user.id, + details: { issues: auditIssues, moodEntryId: id }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); + }, }); - - if (jsonError) return jsonError; - const parsed = updateMoodEntrySchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 — mood edit hot path; multi-issue 422 + audit - // breadcrumb keyed `mood-entries.update.validation-failed`. - const issues = sanitiseZodIssues(parsed.error.issues); - annotate({ - action: { name: "mood-entries.update.validation-failed" }, - meta: { issue_count: issues.length, moodEntryId: id }, - }); - // v1.4.49 — strip `message` from the audit-ledger row; mood - // update carries free-text `note` + `tags`. - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog - // .create`, because that helper is the only writer that stamps - // `actorUserId`. Filed under the resolved record either way; without the - // stamp a manager's malformed payload would read as the owner's own. - void auditLog("mood-entries.update.validation-failed", { - userId: user.id, - details: { issues: auditIssues, moodEntryId: id }, - }).catch(() => { - /* swallow — 422 response is the contract */ - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "mood.update.invalid", - }); - } + if (!parsed.ok) return parsed.error; const data = parsed.data; diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 67f38fe4d..118a44724 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -5,9 +5,9 @@ import { apiError, getClientIp, returnAllZodIssues, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { createMoodEntrySchema, listMoodEntriesSchema, @@ -108,44 +108,39 @@ async function postMoodEntry(request: NextRequest) { // the `externalId` refusal below. const { user, actor } = await requireRecordAuth("manage", "mind"); - const { data: body, error: jsonError } = await safeJson(request, { + // v1.4.43 — iOS mood log hot path; multi-issue 422 + audit + // breadcrumb keyed `mood-entries.create.validation-failed`. + const parsed = await parseJsonBody(request, createMoodEntrySchema, { maxBytes: 64 * 1024, + errorCode: "mood.create.invalid", + onInvalid: (error, body) => { + const shape = unstableExternalIdShape(body); + annotate({ + action: { name: "mood-entries.create.validation-failed" }, + meta: { + issue_count: error.issues.length, + ...(shape ? unstableExternalIdMeta("mood.create", [shape]) : {}), + }, + }); + // v1.4.49 — strip `message` from the audit-ledger row; the + // mood-create schema's free-text `note` + `tags` could land in a + // Zod issue message verbatim. + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, + }); + // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog + // .create`, because that helper is the only writer that stamps + // `actorUserId`. Filed under the resolved record either way; without the + // stamp a manager's malformed payload would read as the owner's own. + void auditLog("mood-entries.create.validation-failed", { + userId: user.id, + details: { issues: auditIssues }, + }).catch(() => { + /* swallow — 422 response is the contract */ + }); + }, }); - - if (jsonError) return jsonError; - const parsed = createMoodEntrySchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 — iOS mood log hot path; multi-issue 422 + audit - // breadcrumb keyed `mood-entries.create.validation-failed`. - const issues = sanitiseZodIssues(parsed.error.issues); - const shape = unstableExternalIdShape(body); - annotate({ - action: { name: "mood-entries.create.validation-failed" }, - meta: { - issue_count: issues.length, - ...(shape ? unstableExternalIdMeta("mood.create", [shape]) : {}), - }, - }); - // v1.4.49 — strip `message` from the audit-ledger row; the - // mood-create schema's free-text `note` + `tags` could land in a - // Zod issue message verbatim. - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - // v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog - // .create`, because that helper is the only writer that stamps - // `actorUserId`. Filed under the resolved record either way; without the - // stamp a manager's malformed payload would read as the owner's own. - void auditLog("mood-entries.create.validation-failed", { - userId: user.id, - details: { issues: auditIssues }, - }).catch(() => { - /* swallow — 422 response is the contract */ - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "mood.create.invalid", - }); - } + if (!parsed.ok) return parsed.error; const { mood, diff --git a/src/app/api/onboarding/answers/route.ts b/src/app/api/onboarding/answers/route.ts index 9a318f4eb..f4c1fa4cd 100644 --- a/src/app/api/onboarding/answers/route.ts +++ b/src/app/api/onboarding/answers/route.ts @@ -24,13 +24,8 @@ import type { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { prisma, toJson } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; @@ -68,21 +63,13 @@ export const PATCH = apiHandler(async (request: NextRequest) => { }); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, onboardingAnswerSchema, { maxBytes: 16 * 1024, + action: "onboarding.needs.save", + meta: { outcome: "validation_failed" }, + errorCode: "onboarding.answers.invalid", }); - if (jsonError) return jsonError; - - const parsed = onboardingAnswerSchema.safeParse(body); - if (!parsed.success) { - annotate({ - action: { name: "onboarding.needs.save" }, - meta: { outcome: "validation_failed" }, - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "onboarding.answers.invalid", - }); - } + if (!parsed.ok) return parsed.error; const answer = parsed.data; const existing = await prisma.onboardingRecord.findUnique({ diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts index 635827dd1..f51f93d22 100644 --- a/src/app/api/onboarding/complete/route.ts +++ b/src/app/api/onboarding/complete/route.ts @@ -2,14 +2,8 @@ import { prisma, toJson } from "@/lib/db"; import { Prisma } from "@/generated/prisma/client"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { - apiError, - apiSuccess, - apiValidationError, - getClientIp, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { setOnboardingPendingCookie } from "@/lib/auth/session"; import { normalisePrefs } from "@/lib/modules/gate"; @@ -34,7 +28,6 @@ import { import { readManagedProfileForGuardian } from "@/lib/managed-profiles/lifecycle"; import { writeRecordModulePreferences } from "@/lib/record-settings/modules"; import { NextRequest } from "next/server"; -import { z } from "zod/v4"; import { onboardingCompleteSchema } from "@/lib/validations/onboarding"; /** @@ -75,22 +68,12 @@ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "onboarding.complete" } }); - const { data: body, error: jsonError } = await safeJson(request, { + const result = await parseJsonBody(request, onboardingCompleteSchema, { maxBytes: 64 * 1024, + message: "Invalid input", + errorCode: "onboarding.complete.invalid", }); - - if (jsonError) return jsonError; - const result = z.safeParse(onboardingCompleteSchema, body); - if (!result.success) { - return apiValidationError( - "Invalid input", - sanitiseZodIssues(result.error.issues), - 422, - { - errorCode: "onboarding.complete.invalid", - }, - ); - } + if (!result.ok) return result.error; const data: Record = { onboardingCompletedAt: new Date(), diff --git a/src/app/api/onboarding/disclaimer/route.ts b/src/app/api/onboarding/disclaimer/route.ts index ed89cc61c..7c10a018e 100644 --- a/src/app/api/onboarding/disclaimer/route.ts +++ b/src/app/api/onboarding/disclaimer/route.ts @@ -3,12 +3,8 @@ 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, - apiValidationError, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiSuccess } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { DISCLAIMER_VERSION } from "@/lib/onboarding/disclaimer"; /** @@ -36,22 +32,12 @@ const bodySchema = z export const POST = apiHandler(async (request) => { const { user } = await requireAuth(); - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, bodySchema, { maxBytes: 4 * 1024, + message: "Invalid input", + errorCode: "onboarding.disclaimer.invalid", }); - if (jsonError) return jsonError; - - const parsed = z.safeParse(bodySchema, rawBody); - if (!parsed.success) { - return apiValidationError( - "Invalid input", - sanitiseZodIssues(parsed.error.issues), - 422, - { - errorCode: "onboarding.disclaimer.invalid", - }, - ); - } + if (!parsed.ok) return parsed.error; // The server pins the canonical version it stamps — the body version is // only a freshness signal, never the source of truth. diff --git a/src/app/api/onboarding/restart/route.ts b/src/app/api/onboarding/restart/route.ts index 6c17bb117..f9929e18a 100644 --- a/src/app/api/onboarding/restart/route.ts +++ b/src/app/api/onboarding/restart/route.ts @@ -31,13 +31,8 @@ import type { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { prisma, toJson } from "@/lib/db"; import { Prisma } from "@/generated/prisma/client"; @@ -74,21 +69,14 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, onboardingRestartSchema, { maxBytes: 4 * 1024, + input: (body) => body ?? {}, + action: "onboarding.needs.restart", + meta: { outcome: "validation_failed" }, + errorCode: "onboarding.restart.invalid", }); - if (jsonError) return jsonError; - - const parsed = onboardingRestartSchema.safeParse(body ?? {}); - if (!parsed.success) { - annotate({ - action: { name: "onboarding.needs.restart" }, - meta: { outcome: "validation_failed" }, - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "onboarding.restart.invalid", - }); - } + if (!parsed.ok) return parsed.error; const steps = defaultOnboardingSteps(); const written = await prisma.onboardingRecord.upsert({ diff --git a/src/app/api/onboarding/tour/route.ts b/src/app/api/onboarding/tour/route.ts index dc04d6454..c22ded0c2 100644 --- a/src/app/api/onboarding/tour/route.ts +++ b/src/app/api/onboarding/tour/route.ts @@ -4,12 +4,8 @@ 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, - apiValidationError, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiSuccess } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { tourProgressSchema } from "@/lib/onboarding/tour-progress"; import { Prisma } from "@/generated/prisma/client"; @@ -54,22 +50,12 @@ const tourBodySchema = z export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, tourBodySchema, { maxBytes: 64 * 1024, + message: "Invalid input", + errorCode: "onboarding.tour.invalid", }); - if (jsonError) return jsonError; - - const parsed = z.safeParse(tourBodySchema, body); - if (!parsed.success) { - return apiValidationError( - "Invalid input", - sanitiseZodIssues(parsed.error.issues), - 422, - { - errorCode: "onboarding.tour.invalid", - }, - ); - } + if (!parsed.ok) return parsed.error; // Build the update field-by-field (no mass assignment). `completed` // drives the coarse boolean; a replay (`completed:false`) wipes the diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 0ffcd61df..dcdba0d56 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -28,12 +28,8 @@ import { NextRequest } from "next/server"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { prisma } from "@/lib/db"; import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; import { annotate } from "@/lib/logging/context"; @@ -71,24 +67,20 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Too many searches. Please retry shortly.", 429); } - const { data: body, error: jsonError } = await safeJson<{ - q?: unknown; - limit?: unknown; - }>(request, { maxBytes: SEARCH_BODY_MAX_BYTES }); - if (jsonError) return jsonError; - const parsed = searchQuerySchema.safeParse({ - q: body?.q ?? undefined, - limit: body?.limit ?? undefined, + const parsed = await parseJsonBody(request, searchQuerySchema, { + maxBytes: SEARCH_BODY_MAX_BYTES, + input: (raw) => { + const body = raw as { q?: unknown; limit?: unknown }; + return { + q: body?.q ?? undefined, + limit: body?.limit ?? undefined, + }; + }, + action: "search.query.invalid", + countIssues: true, + errorCode: "search.invalid", }); - if (!parsed.success) { - annotate({ - action: { name: "search.query.invalid" }, - meta: { issue_count: parsed.error.issues.length }, - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "search.invalid", - }); - } + if (!parsed.ok) return parsed.error; const slot = beginSearch(actor.id, user.id); try { diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts index 842e8e41b..3b72429cf 100644 --- a/src/app/api/user/ai-provider/route.ts +++ b/src/app/api/user/ai-provider/route.ts @@ -1,13 +1,8 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { prisma } from "@/lib/db"; -import { - apiSuccess, - apiError, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { isPublicUrl } from "@/lib/validations/notifications"; import { isLocalAiHostAllowed } from "@/lib/ai/local-host-allowlist"; @@ -170,30 +165,26 @@ export const PATCH = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "user.ai-provider.update" } }); - const { data: rawBody, error } = await safeJson(request, { - maxBytes: 64 * 1024, - }); - if (error) return error; - // Every accepted field states its type in the schema, so a wrongly-typed // value is a named issue rather than a key quietly dropped on the floor. // The multi-issue envelope is safe to return even though this body carries // plaintext credentials: Zod's type and range messages name the received // TYPE, never the received value. See the note in the validations module // before adding a `.refine()` here. - const parsed = aiProviderPatchSchema.safeParse(rawBody); - if (!parsed.success) { - annotate({ - action: { name: "user.ai-provider.update" }, - meta: { - outcome: "validation_failed", - issue_count: parsed.error.issues.length, - }, - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "ai_provider.invalid", - }); - } + const parsed = await parseJsonBody(request, aiProviderPatchSchema, { + maxBytes: 64 * 1024, + errorCode: "ai_provider.invalid", + onInvalid: (error) => { + annotate({ + action: { name: "user.ai-provider.update" }, + meta: { + outcome: "validation_failed", + issue_count: error.issues.length, + }, + }); + }, + }); + if (!parsed.ok) return parsed.error; const body = parsed.data; // v1.39.3 — whose configuration this is. Settings saved on an admin account