From f06df5fd71fd52dfc2881396c39a21370b94ec93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 11:25:36 +0200 Subject: [PATCH 1/3] test(api): count parseJsonBody refusals in the issue-list guard A route that reads its body through parseJsonBody has no `if (!parsed.success)` block, so the guard lost sight of every migrated site: its floor of 200 failed at 139 and the token inventory reported the coach message feedback entry as stale while that route still answers `feedback.body.invalid` in `error`, now through the `message` option. Each parseJsonBody call is now a site of its own, carrying the issue list by construction unless it names a status other than 422, and its `message` option is read as the refused string by the token sweep. --- .../zod-refusal-issue-list-guard.test.ts | 70 +++++++++++++++++-- 1 file changed, 64 insertions(+), 6 deletions(-) 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; From af14d0aa7e0fc297629dccc61d0db2ceea901839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 11:29:22 +0200 Subject: [PATCH 2/3] refactor(api): read the health-record route bodies through parseJsonBody Labs, biomarkers, custom metrics, cycle, anamnesis, illness, measurement reminders, the measurement edit, medication sub-routes, mood and mental health assessments still spelled out safeJson, safeParse and the 422 by hand. Each becomes one parseJsonBody call with the size cap, action, issue count, error code and status it had. The audit rows and the unstable-externalId breadcrumbs move into onInvalid, in the order they ran; where a breadcrumb reads the body, the wide-event annotate moves with it. A `body ?? {}` or a payload unwrapped from the body becomes `input`. The medication ingest route reads through the helper too; its Bearer resolution happens before the read and is unchanged. --- src/__tests__/baselines/route-safe-json.json | 31 +------ src/app/api/anamnesis/emergency/route.ts | 15 +--- src/app/api/anamnesis/facts/[id]/route.ts | 16 +--- src/app/api/anamnesis/facts/route.ts | 16 +--- src/app/api/biomarkers/[id]/route.ts | 50 +++++------ src/app/api/biomarkers/route.ts | 43 ++++----- .../[id]/entries/[entryId]/route.ts | 49 +++++----- .../api/custom-metrics/[id]/entries/route.ts | 40 ++++----- src/app/api/custom-metrics/[id]/route.ts | 55 ++++++------ src/app/api/custom-metrics/route.ts | 48 +++++----- src/app/api/cycle/day-logs/route.ts | 36 ++++---- src/app/api/cycle/period/route.ts | 38 ++++---- .../illness/episodes/[id]/resolve/route.ts | 22 ++--- src/app/api/ingest/medication/route.ts | 73 ++++++++------- src/app/api/labs/[id]/route.ts | 50 +++++------ src/app/api/labs/ocr/commit/route.ts | 52 +++++------ src/app/api/labs/route.ts | 50 +++++------ .../api/measurement-reminders/[id]/route.ts | 56 ++++++------ .../[id]/snooze/route.ts | 20 ++--- src/app/api/measurement-reminders/route.ts | 53 +++++------ src/app/api/measurements/[id]/route.ts | 61 ++++++------- .../medications/[id]/intake/import/route.ts | 90 +++++++++---------- .../schedule-revisions/[revisionId]/route.ts | 19 ++-- .../[id]/schedule-revisions/route.ts | 19 ++-- src/app/api/medications/extract/route.ts | 26 +++--- .../api/mental-health/assessments/route.ts | 37 ++++---- src/app/api/mood-entries/[id]/route.ts | 59 ++++++------ src/app/api/mood-entries/route.ts | 69 +++++++------- 28 files changed, 495 insertions(+), 698 deletions(-) diff --git a/src/__tests__/baselines/route-safe-json.json b/src/__tests__/baselines/route-safe-json.json index 3e1e6201b..3931f309f 100644 --- a/src/__tests__/baselines/route-safe-json.json +++ b/src/__tests__/baselines/route-safe-json.json @@ -1,7 +1,7 @@ { "$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": 79, + "total": 79, "entries": { "app/api/admin/ai-settings/route.ts": 1, "app/api/admin/drain-per-sample-cumulative/route.ts": 1, @@ -13,9 +13,6 @@ "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,18 +23,10 @@ "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, @@ -51,9 +40,7 @@ "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, @@ -62,31 +49,17 @@ "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, 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/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/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/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, From ca04ef01b3ddff26cf2838ceeec0b9faaad8f898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 11:29:23 +0200 Subject: [PATCH 3/3] refactor(api): read the remaining settings and admin route bodies through parseJsonBody Admin, coach, consent, chart-overlay, device registration, insights feedback and provider chain, ECG ingest, onboarding, search and the AI provider settings move to parseJsonBody with the same message, error code, status and wide-event action they answered with. A refusal whose annotate meta is computed from the issues, or puts its keys in another order than the helper would, keeps that annotate verbatim in onInvalid. The routes that still call safeJson do something between the read and the schema: an optimistic-concurrency token split off the body, a count cap answered first, a refunded quota slot, a conditional read, a non-Zod check, or the body handed to a service. --- src/__tests__/baselines/route-safe-json.json | 22 +----- .../encryption/key-backup/confirm/route.ts | 17 ++--- src/app/api/admin/invites/route.ts | 18 ++--- .../settings/web-push-vapid/generate/route.ts | 25 ++----- src/app/api/coach/about-me/questions/route.ts | 13 ++-- src/app/api/consent/ai/route.ts | 20 ++---- .../dashboard/chart-overlay-prefs/route.ts | 71 ++++++++----------- src/app/api/devices/route.ts | 71 ++++++++----------- .../chat/messages/[id]/feedback/route.ts | 41 ++++------- src/app/api/insights/ecg/route.ts | 43 +++++------ src/app/api/insights/feedback/route.ts | 36 ++++------ src/app/api/insights/provider-chain/route.ts | 22 ++---- src/app/api/onboarding/answers/route.ts | 27 ++----- src/app/api/onboarding/complete/route.ts | 29 ++------ src/app/api/onboarding/disclaimer/route.ts | 26 ++----- src/app/api/onboarding/restart/route.ts | 28 +++----- src/app/api/onboarding/tour/route.ts | 26 ++----- src/app/api/search/route.ts | 38 ++++------ src/app/api/user/ai-provider/route.ts | 41 +++++------ 19 files changed, 203 insertions(+), 411 deletions(-) diff --git a/src/__tests__/baselines/route-safe-json.json b/src/__tests__/baselines/route-safe-json.json index 3931f309f..ec52c3e59 100644 --- a/src/__tests__/baselines/route-safe-json.json +++ b/src/__tests__/baselines/route-safe-json.json @@ -1,16 +1,13 @@ { "$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": 79, - "total": 79, + "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/auth/login/route.ts": 1, @@ -23,17 +20,13 @@ "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/coach/about-me/questions/route.ts": 1, "app/api/coach/about-me/route.ts": 1, - "app/api/consent/ai/route.ts": 1, "app/api/cycle/day-logs/bulk/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, @@ -41,12 +34,8 @@ "app/api/fitbit/credentials/route.ts": 1, "app/api/google-health/credentials/route.ts": 1, "app/api/import/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/ocr/extract/route.ts": 1, @@ -63,21 +52,14 @@ "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/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/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/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/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/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