From 5b49fa668086c73e86c58c0ab06db0b86782c89a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 11:12:39 +0200 Subject: [PATCH] refactor(api): read route bodies through parseJsonBody 132 route handlers spelled out safeJson, safeParse and the 422 refusal by hand in one of six shapes. Each block becomes one parseJsonBody call with the same size cap, wide-event action, issue count, error code, message and status it had, so every response is the one it was. The comments inside the replaced refusal blocks go with them. A ratchet (route-body-parse-ratchet) counts the route files that still call safeJson themselves; the count may only fall. --- src/__tests__/baselines/route-safe-json.json | 113 ++++++++++++++++++ .../route-body-parse-ratchet.test.ts | 87 ++++++++++++++ src/app/api/account/grants/route.ts | 21 +--- .../api/account/handover-decision/route.ts | 14 +-- src/app/api/account/switch/route.ts | 21 +--- src/app/api/activity/goals/route.ts | 19 +-- .../admin/maintenance/pulse-day-mean/route.ts | 15 +-- .../admin/settings/assistant-flags/route.ts | 19 +-- .../settings/module-availability/route.ts | 18 +-- src/app/api/admin/settings/route.ts | 19 +-- src/app/api/admin/users/[id]/route.ts | 19 +-- src/app/api/allergies/[id]/route.ts | 21 +--- src/app/api/allergies/route.ts | 20 +--- src/app/api/anamnesis/audit-c/route.ts | 20 +--- .../life-circumstances/ai-sections/route.ts | 20 +--- .../life-circumstances/confirm/route.ts | 14 +-- src/app/api/auth/claim/preview/route.ts | 9 +- src/app/api/auth/claim/route.ts | 14 +-- src/app/api/auth/claim/sso/route.ts | 14 +-- src/app/api/auth/me/ai-usage/route.ts | 20 +--- src/app/api/auth/me/devices/[id]/route.ts | 16 +-- src/app/api/auth/me/diabetes/route.ts | 20 +--- .../auth/me/documents-auto-ai-read/route.ts | 22 +--- src/app/api/auth/me/glucose-unit/route.ts | 20 +--- .../api/auth/me/injection-site-prefs/route.ts | 22 +--- src/app/api/auth/me/labs-local-ocr/route.ts | 20 +--- .../api/auth/me/mfa/webauthn/[id]/route.ts | 24 +--- .../me/mfa/webauthn/register/verify/route.ts | 24 +--- src/app/api/auth/me/unit-preference/route.ts | 22 +--- .../api/auth/me/use-central-codex/route.ts | 22 +--- src/app/api/auth/me/workout-routes/route.ts | 20 +--- .../auth/mfa/webauthn/verify/options/route.ts | 23 +--- src/app/api/auth/native/token/route.ts | 22 +--- src/app/api/auth/oidc/native/token/route.ts | 22 +--- src/app/api/auth/passkeys/[id]/route.ts | 23 +--- src/app/api/auth/password/route.ts | 19 +-- src/app/api/auth/register/route.ts | 19 +-- src/app/api/auth/reproof/route.ts | 24 +--- src/app/api/auth/step-up/options/route.ts | 23 +--- src/app/api/auth/step-up/route.ts | 24 +--- src/app/api/biomarkers/[id]/merge/route.ts | 20 +--- src/app/api/c/[token]/unlock/route.ts | 15 +-- src/app/api/coach/about-me/adopt/route.ts | 18 +-- src/app/api/coach/plans/[id]/route.ts | 17 +-- .../api/coach/reminder-suggestions/route.ts | 22 ++-- src/app/api/coach/reminders/[id]/route.ts | 15 +-- src/app/api/coach/reminders/route.ts | 15 +-- src/app/api/coach/suggested-actions/route.ts | 16 +-- src/app/api/consent/ai/web/route.ts | 18 +-- .../[id]/entries/restore/route.ts | 17 +-- src/app/api/cycle/day-logs/[id]/route.ts | 27 ++--- .../api/cycle/symptoms/custom/[key]/route.ts | 20 +--- src/app/api/cycle/symptoms/custom/route.ts | 20 +--- src/app/api/daily/digest/dismiss/route.ts | 17 +-- .../api/documents/inbound/[id]/chat/route.ts | 19 +-- .../documents/inbound/[id]/confirm/route.ts | 16 +-- .../inbound/[id]/facts/[factId]/route.ts | 15 +-- src/app/api/documents/inbound/[id]/route.ts | 29 ++--- src/app/api/documents/inbound/bulk/route.ts | 18 +-- src/app/api/documents/inbound/layout/route.ts | 14 +-- .../sources/[system]/import/route.ts | 22 +--- .../api/documents/sources/[system]/route.ts | 23 +--- src/app/api/encounters/[id]/links/route.ts | 20 +--- src/app/api/encounters/[id]/route.ts | 21 +--- src/app/api/encounters/route.ts | 20 +--- src/app/api/environment/backfill/route.ts | 20 +--- src/app/api/environment/home/route.ts | 21 ++-- src/app/api/environment/preferences/route.ts | 15 +-- src/app/api/environment/travel/route.ts | 21 ++-- src/app/api/export/encrypted/route.ts | 17 +-- src/app/api/export/health-record/route.ts | 17 +-- src/app/api/family-history/[id]/route.ts | 21 +--- src/app/api/family-history/route.ts | 20 +--- .../illness/episodes/[id]/day-logs/route.ts | 20 +--- src/app/api/illness/episodes/[id]/route.ts | 21 +--- src/app/api/illness/episodes/route.ts | 20 +--- .../insights/chat/[id]/attachments/route.ts | 26 +--- src/app/api/insights/chat/[id]/route.ts | 21 +--- src/app/api/insights/chat/fenced/route.ts | 25 +--- .../api/insights/coach/facts/[id]/route.ts | 14 +-- src/app/api/insights/coach/facts/route.ts | 14 +-- src/app/api/insights/patterns/[id]/route.ts | 15 ++- src/app/api/labs/[id]/links/route.ts | 19 +-- src/app/api/labs/restore/route.ts | 21 +--- src/app/api/life-events/[id]/route.ts | 14 +-- src/app/api/life-events/route.ts | 20 +--- .../managed-profiles/[id]/guardians/route.ts | 20 ++-- .../managed-profiles/[id]/handover/route.ts | 14 +-- src/app/api/managed-profiles/[id]/route.ts | 15 +-- src/app/api/managed-profiles/route.ts | 15 +-- src/app/api/mcp/tokens/route.ts | 17 +-- src/app/api/measurements/bulk-delete/route.ts | 21 +--- src/app/api/measurements/restore/route.ts | 21 +--- .../[id]/courses/[courseId]/route.ts | 20 +--- src/app/api/medications/[id]/courses/route.ts | 20 +--- .../medications/[id]/efficacy/target/route.ts | 17 +-- src/app/api/medications/[id]/glp1/route.ts | 19 +-- .../api/medications/[id]/ingredients/route.ts | 20 +--- .../[id]/intake/bulk-delete/route.ts | 17 +-- .../[id]/inventory/[itemId]/route.ts | 19 +-- .../api/medications/[id]/inventory/route.ts | 19 +-- src/app/api/medications/[id]/links/route.ts | 19 +-- .../medications/[id]/phase-config/route.ts | 16 +-- src/app/api/medications/[id]/route.ts | 19 +-- .../medications/[id]/side-effects/route.ts | 12 +- .../api/medications/categories/[key]/route.ts | 25 ++-- src/app/api/medications/categories/route.ts | 25 ++-- src/app/api/mood-entries/bulk-delete/route.ts | 21 +--- src/app/api/mood-entries/restore/route.ts | 21 +--- src/app/api/mood/tags/[key]/hidden/route.ts | 14 +-- src/app/api/mood/tags/custom/[key]/route.ts | 15 +-- src/app/api/mood/tags/custom/route.ts | 15 +-- src/app/api/mood/tags/groups/[key]/route.ts | 15 +-- src/app/api/mood/tags/groups/route.ts | 15 +-- src/app/api/nutrients/water/route.ts | 21 +--- src/app/api/practitioners/[id]/route.ts | 21 +--- src/app/api/practitioners/route.ts | 20 +--- .../api/settings/module-list-layout/route.ts | 16 +-- .../api/settings/preview-features/route.ts | 16 +-- src/app/api/share-links/route.ts | 18 +-- .../api/symptoms/definitions/[id]/route.ts | 20 +--- src/app/api/symptoms/definitions/route.ts | 20 +--- src/app/api/symptoms/events/[id]/route.ts | 20 +--- src/app/api/symptoms/events/route.ts | 13 +- src/app/api/tokens/documents/route.ts | 18 +-- src/app/api/tokens/measurements/route.ts | 18 +-- src/app/api/tokens/workouts/route.ts | 18 +-- src/app/api/user/thresholds/route.ts | 19 +-- .../api/vaccinations/[id]/booster/route.ts | 21 +--- src/app/api/vaccinations/[id]/links/route.ts | 20 +--- src/app/api/vaccinations/[id]/route.ts | 21 +--- src/app/api/vaccinations/custom/[id]/route.ts | 21 +--- src/app/api/vaccinations/custom/route.ts | 21 +--- src/app/api/vaccinations/route.ts | 20 +--- 134 files changed, 838 insertions(+), 1874 deletions(-) create mode 100644 src/__tests__/baselines/route-safe-json.json create mode 100644 src/__tests__/route-body-parse-ratchet.test.ts diff --git a/src/__tests__/baselines/route-safe-json.json b/src/__tests__/baselines/route-safe-json.json new file mode 100644 index 000000000..3e1e6201b --- /dev/null +++ b/src/__tests__/baselines/route-safe-json.json @@ -0,0 +1,113 @@ +{ + "$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, + "entries": { + "app/api/admin/ai-settings/route.ts": 1, + "app/api/admin/drain-per-sample-cumulative/route.ts": 1, + "app/api/admin/encryption/key-backup/confirm/route.ts": 1, + "app/api/admin/encryption/key-backup/verify/route.ts": 1, + "app/api/admin/invites/route.ts": 1, + "app/api/admin/notifications/reminder-check/route.ts": 1, + "app/api/admin/rollups/recompute/route.ts": 1, + "app/api/admin/settings/web-push-vapid/generate/route.ts": 1, + "app/api/admin/users/[id]/reset-password/route.ts": 1, + "app/api/ai/test/route.ts": 1, + "app/api/anamnesis/emergency/route.ts": 1, + "app/api/anamnesis/facts/[id]/route.ts": 1, + "app/api/anamnesis/facts/route.ts": 1, + "app/api/auth/login/route.ts": 1, + "app/api/auth/me/locale/route.ts": 1, + "app/api/auth/me/mfa/disable/route.ts": 1, + "app/api/auth/me/mfa/totp/confirm/route.ts": 1, + "app/api/auth/me/timezone/route.ts": 1, + "app/api/auth/mfa/verify/route.ts": 1, + "app/api/auth/mfa/webauthn/verify/route.ts": 1, + "app/api/auth/passkey/login-options/route.ts": 1, + "app/api/auth/profile/route.ts": 1, + "app/api/auth/refresh/route.ts": 1, + "app/api/biomarkers/[id]/route.ts": 1, + "app/api/biomarkers/route.ts": 1, + "app/api/coach/about-me/questions/route.ts": 1, + "app/api/coach/about-me/route.ts": 1, + "app/api/consent/ai/route.ts": 1, + "app/api/custom-metrics/[id]/entries/[entryId]/route.ts": 1, + "app/api/custom-metrics/[id]/entries/route.ts": 1, + "app/api/custom-metrics/[id]/route.ts": 1, + "app/api/custom-metrics/route.ts": 1, + "app/api/cycle/day-logs/bulk/route.ts": 1, + "app/api/cycle/day-logs/route.ts": 1, + "app/api/cycle/period/route.ts": 1, + "app/api/dashboard/chart-overlay-prefs/route.ts": 1, + "app/api/dashboard/widgets/route.ts": 1, + "app/api/dawarich/connect/route.ts": 1, + "app/api/dawarich/disconnect/route.ts": 1, + "app/api/dawarich/settings/route.ts": 1, + "app/api/dawarich/sync/route.ts": 1, + "app/api/devices/route.ts": 1, + "app/api/documents/inbound/[id]/extract/route.ts": 1, + "app/api/documents/inbound/[id]/index/route.ts": 1, + "app/api/documents/inbound/[id]/suggest/route.ts": 1, + "app/api/documents/inbound/[id]/summary/route.ts": 1, + "app/api/fitbit/credentials/route.ts": 1, + "app/api/google-health/credentials/route.ts": 1, + "app/api/illness/episodes/[id]/resolve/route.ts": 1, + "app/api/import/route.ts": 1, + "app/api/ingest/medication/route.ts": 1, + "app/api/insights/chat/messages/[id]/feedback/route.ts": 1, + "app/api/insights/ecg/route.ts": 1, + "app/api/insights/feedback/route.ts": 1, + "app/api/insights/generate/route.ts": 1, + "app/api/insights/layout/route.ts": 1, + "app/api/insights/provider-chain/route.ts": 1, + "app/api/integrations/healthkit/route.ts": 1, + "app/api/internal/deploy-webhook/route.ts": 1, + "app/api/labs/[id]/route.ts": 1, + "app/api/labs/ocr/commit/route.ts": 1, + "app/api/labs/ocr/extract/route.ts": 1, + "app/api/labs/route.ts": 1, + "app/api/measurement-reminders/[id]/route.ts": 1, + "app/api/measurement-reminders/[id]/snooze/route.ts": 1, + "app/api/measurement-reminders/route.ts": 1, + "app/api/measurements/[id]/route.ts": 1, + "app/api/measurements/batch/route.ts": 1, + "app/api/measurements/by-external-ids/route.ts": 1, + "app/api/measurements/route.ts": 1, + "app/api/medications/[id]/intake/[eventId]/route.ts": 1, + "app/api/medications/[id]/intake/import/route.ts": 1, + "app/api/medications/[id]/intake/route.ts": 1, + "app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts": 1, + "app/api/medications/[id]/schedule-revisions/route.ts": 1, + "app/api/medications/extract/route.ts": 1, + "app/api/medications/intake/bulk/route.ts": 1, + "app/api/medications/intake/route.ts": 1, + "app/api/medications/layout/route.ts": 1, + "app/api/medications/route.ts": 1, + "app/api/mental-health/assessments/route.ts": 1, + "app/api/mood-entries/[id]/route.ts": 1, + "app/api/mood-entries/bulk/route.ts": 1, + "app/api/mood-entries/route.ts": 1, + "app/api/mood/tags/layout/route.ts": 1, + "app/api/nightscout/connect/route.ts": 1, + "app/api/nutrients/batch/route.ts": 1, + "app/api/onboarding/answers/route.ts": 1, + "app/api/onboarding/complete/route.ts": 1, + "app/api/onboarding/disclaimer/route.ts": 1, + "app/api/onboarding/restart/route.ts": 1, + "app/api/onboarding/tour/route.ts": 1, + "app/api/oura/credentials/route.ts": 1, + "app/api/polar/credentials/route.ts": 1, + "app/api/record-settings/[family]/route.ts": 1, + "app/api/search/route.ts": 1, + "app/api/settings/email/route.ts": 1, + "app/api/settings/ntfy/route.ts": 1, + "app/api/settings/telegram/route.ts": 1, + "app/api/settings/webhook/route.ts": 1, + "app/api/strava/credentials/route.ts": 1, + "app/api/user/ai-provider/route.ts": 1, + "app/api/user/profile/route.ts": 1, + "app/api/whoop/credentials/route.ts": 1, + "app/api/withings/credentials/route.ts": 1, + "app/api/workouts/batch/route.ts": 1 + } +} diff --git a/src/__tests__/route-body-parse-ratchet.test.ts b/src/__tests__/route-body-parse-ratchet.test.ts new file mode 100644 index 000000000..083ab923d --- /dev/null +++ b/src/__tests__/route-body-parse-ratchet.test.ts @@ -0,0 +1,87 @@ +/** + * Route handlers read their JSON body through `parseJsonBody`. + * + * `parseJsonBody` (`src/lib/api/body.ts`) is `safeJson`, the Zod + * `safeParse`, the validation-failed wide-event action and the multi-issue + * 422 envelope in one call. Before it, every body-accepting route spelled + * those steps out by hand, and the copies drifted: some annotated the + * refusal and some did not, some answered a flat 400 where the rest answered + * the multi-issue 422. + * + * The routes that still call `safeJson` themselves are counted per file in + * `baselines/route-safe-json.json`. Most of them do something between + * reading and validating (an optimistic-concurrency token split off the body, + * a count cap answered before the schema runs, a refund of a spent quota on a + * malformed body), which is a reason to keep the read separate, not a reason + * to copy it into a new route. A new route file that calls `safeJson`, or a + * listed one that gains a call, fails. A file that sheds one fails until the + * lower count is written back with + * `RATCHET_WRITE=1 pnpm vitest run src/__tests__/route-body-parse-ratchet.test.ts`. + */ +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { runRatchet, writeBackHint } from "./helpers/ratchet"; +import { + readSourceText, + requireFloor, + stripComments, + walkSourceFiles, +} from "./helpers/source-files"; + +const GUARD = "src/__tests__/route-body-parse-ratchet.test.ts"; +const APP = join(process.cwd(), "src/app"); + +const SAFE_JSON_CALL = /\bsafeJson\s*(?:<[^>()]*>)?\s*\(/g; +const PARSE_JSON_BODY_CALL = /\bparseJsonBody\s*\(/g; + +function count(text: string, pattern: RegExp): number { + return [...text.matchAll(pattern)].length; +} + +describe("route body parsing", () => { + const routes = walkSourceFiles(APP, { floor: 400 }).filter((rel) => + rel.endsWith("/route.ts"), + ); + const safeJson: Record = {}; + const helper: string[] = []; + for (const rel of routes) { + const code = stripComments(readSourceText(join(APP, rel))); + const n = count(code, SAFE_JSON_CALL); + if (n > 0) safeJson[`app/${rel}`] = n; + if (count(code, PARSE_JSON_BODY_CALL) > 0) helper.push(rel); + } + + it("finds the routes on both sides", () => { + requireFloor("route files", routes, 400); + requireFloor( + "routes reading their body through parseJsonBody", + helper, + 100, + ); + requireFloor("routes still calling safeJson", Object.keys(safeJson), 1); + }); + + it("adds no route that calls safeJson itself", () => { + const r = runRatchet("route-safe-json.json", safeJson, { + comment: `safeJson calls per route file under src/app; new body-reading routes use parseJsonBody. Maintained by ${GUARD}.`, + }); + expect( + r.grew, + "A route that reads its body with safeJson. Use parseJsonBody " + + "(src/lib/api/body.ts); its options cover the size cap, the " + + "validation-failed action, the error code and the message.", + ).toEqual([]); + expect(r.stale, writeBackHint(GUARD)).toEqual([]); + }); + + it("counts a call however it is spelled", () => { + expect(count("await safeJson(request)", SAFE_JSON_CALL)).toBe(1); + expect( + count("await safeJson(req, { maxBytes: 1 })", SAFE_JSON_CALL), + ).toBe(1); + expect(count("await safeJson (\n request,\n)", SAFE_JSON_CALL)).toBe(1); + expect(count("import { safeJson } from", SAFE_JSON_CALL)).toBe(0); + }); +}); diff --git a/src/app/api/account/grants/route.ts b/src/app/api/account/grants/route.ts index 74663d2e0..f201b089f 100644 --- a/src/app/api/account/grants/route.ts +++ b/src/app/api/account/grants/route.ts @@ -54,13 +54,8 @@ import { requireRecentProof, MFA_STEP_UP_MAX_AGE_SECONDS, } 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 { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup"; @@ -163,17 +158,11 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Too many invitations, try again later", 429); } - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, inviteGrantSchema, { maxBytes: 8 * 1024, + errorCode: "sharing.invite.invalid", }); - if (jsonError) return jsonError; - - const parsed = inviteGrantSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "sharing.invite.invalid", - }); - } + if (!parsed.ok) return parsed.error; const { identifier, expiresAt, access, scope } = parsed.data; diff --git a/src/app/api/account/handover-decision/route.ts b/src/app/api/account/handover-decision/route.ts index 0824d3ab2..d170b94fc 100644 --- a/src/app/api/account/handover-decision/route.ts +++ b/src/app/api/account/handover-decision/route.ts @@ -19,12 +19,8 @@ import { NextRequest } from "next/server"; 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 { decideHandover, @@ -55,12 +51,10 @@ export const GET = apiHandler(async (_request: NextRequest) => { export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, handoverDecisionSchema, { maxBytes: 16 * 1024, }); - if (jsonError) return jsonError; - const parsed = handoverDecisionSchema.safeParse(body); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; let result; try { diff --git a/src/app/api/account/switch/route.ts b/src/app/api/account/switch/route.ts index eecb91dc8..baa277da1 100644 --- a/src/app/api/account/switch/route.ts +++ b/src/app/api/account/switch/route.ts @@ -55,13 +55,8 @@ import { requireActorAuth, SharingAccessDeniedError, } 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"; @@ -83,17 +78,11 @@ export const POST = apiHandler(async (request: NextRequest) => { ); } - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, switchAccountSchema, { maxBytes: 4 * 1024, + errorCode: "sharing.switch.invalid", }); - if (jsonError) return jsonError; - - const parsed = switchAccountSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "sharing.switch.invalid", - }); - } + if (!parsed.ok) return parsed.error; const { accountId, expectedEpoch } = parsed.data; const ip = getClientIp(request); diff --git a/src/app/api/activity/goals/route.ts b/src/app/api/activity/goals/route.ts index 2a2ef4c5d..bde6acc44 100644 --- a/src/app/api/activity/goals/route.ts +++ b/src/app/api/activity/goals/route.ts @@ -8,12 +8,8 @@ * (`activity_goals_json`) and the activity calendar reads them. */ 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 { Prisma } from "@/generated/prisma/client"; import { prisma, toJson } from "@/lib/db"; @@ -56,16 +52,11 @@ export const GET = apiHandler(async () => { export const PATCH = apiHandler(async (request: Request) => { const { user } = await requireAuth(); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, activityGoalsSchema, { maxBytes: 1024, + action: "activity.goals.validation-failed", }); - if (jsonError) return jsonError; - - const parsed = activityGoalsSchema.safeParse(body); - if (!parsed.success) { - annotate({ action: { name: "activity.goals.validation-failed" } }); - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const next = mergeActivityGoals(await readGoals(user.id), parsed.data); await prisma.user.update({ diff --git a/src/app/api/admin/maintenance/pulse-day-mean/route.ts b/src/app/api/admin/maintenance/pulse-day-mean/route.ts index 12e0ebf5d..28c9b587b 100644 --- a/src/app/api/admin/maintenance/pulse-day-mean/route.ts +++ b/src/app/api/admin/maintenance/pulse-day-mean/route.ts @@ -15,13 +15,8 @@ import { NextRequest } from "next/server"; 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 { prisma } from "@/lib/db"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; @@ -99,12 +94,10 @@ export const GET = apiHandler(async () => { export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAdmin(); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, pulseDayMeanActionSchema, { maxBytes: 1024, }); - if (jsonError) return jsonError; - const parsed = pulseDayMeanActionSchema.safeParse(body); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; const action = parsed.data.action; annotate({ action: { name: "admin.pulse_day_mean.requested" }, diff --git a/src/app/api/admin/settings/assistant-flags/route.ts b/src/app/api/admin/settings/assistant-flags/route.ts index 2727c65ce..4f891d794 100644 --- a/src/app/api/admin/settings/assistant-flags/route.ts +++ b/src/app/api/admin/settings/assistant-flags/route.ts @@ -52,13 +52,8 @@ import type { NextRequest } from "next/server"; import { z } from "zod/v4"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; -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 { prisma } from "@/lib/db"; import { resolveAssistantFlags } from "@/lib/feature-flags"; @@ -180,16 +175,10 @@ export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAdmin(); annotate({ action: { name: "admin.settings.assistant-flags.update" } }); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, assistantFlagsSchema, { maxBytes: 64 * 1024, }); - if (jsonError) return jsonError; - - const parsed = assistantFlagsSchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 — multi-issue 422. - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; // Field by field from the parsed body; the schema is strict, so every key // here is one the route owns. diff --git a/src/app/api/admin/settings/module-availability/route.ts b/src/app/api/admin/settings/module-availability/route.ts index 2f751edc4..86a139659 100644 --- a/src/app/api/admin/settings/module-availability/route.ts +++ b/src/app/api/admin/settings/module-availability/route.ts @@ -33,13 +33,8 @@ import type { NextRequest } from "next/server"; import { z } from "zod/v4"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; -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 { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; @@ -95,15 +90,10 @@ export const PATCH = apiHandler(async (request: NextRequest) => { const { user } = await requireAdmin(); annotate({ action: { name: "admin.settings.module-availability.update" } }); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, moduleAvailabilitySchema, { maxBytes: 64 * 1024, }); - if (jsonError) return jsonError; - - const parsed = moduleAvailabilitySchema.safeParse(body); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const patch: Record = {}; for (const [key, value] of Object.entries(parsed.data)) { diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index 1a0dfec68..3ecce887d 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -1,13 +1,8 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; 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 { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; import { adminSettingsSchema } from "@/lib/validations/admin"; @@ -109,16 +104,10 @@ export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAdmin(); annotate({ action: { name: "admin.settings.update" } }); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, adminSettingsSchema, { maxBytes: 64 * 1024, }); - - if (jsonError) return jsonError; - const parsed = adminSettingsSchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 — multi-issue 422. - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const data = parsed.data; const updates: Record = {}; diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index f55cb71fc..95349dc68 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -1,13 +1,8 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; 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 { annotate } from "@/lib/logging/context"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -49,16 +44,10 @@ export const PUT = apiHandler( }, }); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, updateUserSchema, { maxBytes: 64 * 1024, }); - - if (jsonError) return jsonError; - const parsed = updateUserSchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 — multi-issue 422. - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const target = await prisma.user.findUnique({ where: { id }, diff --git a/src/app/api/allergies/[id]/route.ts b/src/app/api/allergies/[id]/route.ts index c7de92ec8..f4b0170c4 100644 --- a/src/app/api/allergies/[id]/route.ts +++ b/src/app/api/allergies/[id]/route.ts @@ -19,13 +19,8 @@ import { destroyedDetails, overwriteDetails, } from "@/lib/sharing/audit-details"; -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 { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { allergyUpdateSchema } from "@/lib/validations/allergy"; import { toAllergyDTO } from "@/lib/records/dto"; @@ -82,17 +77,11 @@ export const PATCH = apiHandler( return apiError("Allergy not found", 404); } - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, allergyUpdateSchema, { maxBytes: 16 * 1024, + errorCode: "allergy.invalid", }); - if (jsonError) return jsonError; - - const parsed = allergyUpdateSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "allergy.invalid", - }); - } + if (!parsed.ok) return parsed.error; const entry = parsed.data; // Field-by-field — only fields actually present in the body are written. diff --git a/src/app/api/allergies/route.ts b/src/app/api/allergies/route.ts index 57a6b0153..fc00c70f9 100644 --- a/src/app/api/allergies/route.ts +++ b/src/app/api/allergies/route.ts @@ -18,8 +18,8 @@ import { apiSuccess, getClientIp, returnAllZodIssues, - safeJson, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { withIdempotency } from "@/lib/idempotency"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { @@ -92,21 +92,13 @@ async function postAllergy(request: NextRequest): Promise { // regardless. const { user } = await requireRecordAuth("manage", "profile"); - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, allergyCreateSchema, { maxBytes: 16 * 1024, + action: "allergy.validation-failed", + countIssues: true, + errorCode: "allergy.invalid", }); - if (jsonError) return jsonError; - - const parsed = allergyCreateSchema.safeParse(rawBody); - if (!parsed.success) { - annotate({ - action: { name: "allergy.validation-failed" }, - meta: { issue_count: parsed.error.issues.length }, - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "allergy.invalid", - }); - } + if (!parsed.ok) return parsed.error; const entry = parsed.data; diff --git a/src/app/api/anamnesis/audit-c/route.ts b/src/app/api/anamnesis/audit-c/route.ts index cae7171d6..44e7b2334 100644 --- a/src/app/api/anamnesis/audit-c/route.ts +++ b/src/app/api/anamnesis/audit-c/route.ts @@ -16,13 +16,8 @@ import { NextRequest } from "next/server"; import { apiHandler, 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"; @@ -46,16 +41,11 @@ async function postAuditC(request: NextRequest): Promise { }); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, auditCRequestSchema, { maxBytes: 4 * 1024, + errorCode: "anamnesis.auditC.invalid", }); - if (jsonError) return jsonError; - const parsed = auditCRequestSchema.safeParse(body); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "anamnesis.auditC.invalid", - }); - } + if (!parsed.ok) return parsed.error; const input = parsed.data; diff --git a/src/app/api/anamnesis/life-circumstances/ai-sections/route.ts b/src/app/api/anamnesis/life-circumstances/ai-sections/route.ts index d0dd292bf..59be72a63 100644 --- a/src/app/api/anamnesis/life-circumstances/ai-sections/route.ts +++ b/src/app/api/anamnesis/life-circumstances/ai-sections/route.ts @@ -12,13 +12,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 { annotate } from "@/lib/logging/context"; import { updateLifeAiSections } from "@/lib/profile/life-circumstances"; @@ -27,16 +22,11 @@ import { lifeAiSectionsPatchSchema } from "@/lib/validations/life-circumstances" export const PATCH = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, lifeAiSectionsPatchSchema, { maxBytes: 4 * 1024, + errorCode: "anamnesis.lifeSections.invalid", }); - if (jsonError) return jsonError; - const parsed = lifeAiSectionsPatchSchema.safeParse(body); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "anamnesis.lifeSections.invalid", - }); - } + if (!parsed.ok) return parsed.error; const outcome = await updateLifeAiSections( user.id, diff --git a/src/app/api/anamnesis/life-circumstances/confirm/route.ts b/src/app/api/anamnesis/life-circumstances/confirm/route.ts index 5ea35ed85..a4b1b8c05 100644 --- a/src/app/api/anamnesis/life-circumstances/confirm/route.ts +++ b/src/app/api/anamnesis/life-circumstances/confirm/route.ts @@ -8,7 +8,8 @@ import { NextRequest } from "next/server"; 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 { confirmLifeSection } from "@/lib/profile/life-circumstances"; import { lifeConfirmSchema } from "@/lib/validations/life-circumstances"; @@ -16,16 +17,11 @@ import { lifeConfirmSchema } from "@/lib/validations/life-circumstances"; export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, lifeConfirmSchema, { maxBytes: 1024, + errorCode: "anamnesis.lifeConfirm.invalid", }); - if (jsonError) return jsonError; - const parsed = lifeConfirmSchema.safeParse(body); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "anamnesis.lifeConfirm.invalid", - }); - } + if (!parsed.ok) return parsed.error; const lastConfirmedAt = await confirmLifeSection( user.id, diff --git a/src/app/api/auth/claim/preview/route.ts b/src/app/api/auth/claim/preview/route.ts index b5cb0b924..ab61b2ec8 100644 --- a/src/app/api/auth/claim/preview/route.ts +++ b/src/app/api/auth/claim/preview/route.ts @@ -14,7 +14,8 @@ import { NextRequest } from "next/server"; import { apiHandler } 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 { getSession } from "@/lib/auth/session"; import { annotate } from "@/lib/logging/context"; import { @@ -35,12 +36,10 @@ export const POST = apiHandler(async (request: NextRequest) => { ); if (gate.refusal) return gate.refusal; - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, claimPreviewSchema, { maxBytes: 4 * 1024, }); - if (jsonError) return jsonError; - const parsed = claimPreviewSchema.safeParse(body); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; const preview = await previewHandover(parsed.data.token); annotate({ diff --git a/src/app/api/auth/claim/route.ts b/src/app/api/auth/claim/route.ts index 9ca4e09c2..949d404ee 100644 --- a/src/app/api/auth/claim/route.ts +++ b/src/app/api/auth/claim/route.ts @@ -24,12 +24,8 @@ import { NextRequest } from "next/server"; import { apiHandler } 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 { auditLog } from "@/lib/auth/audit"; import { recordSignInDevice } from "@/lib/auth/login-alert"; import { checkPasswordStrength, hashPassword } from "@/lib/auth/password"; @@ -59,12 +55,10 @@ export const POST = apiHandler(async (request: NextRequest) => { if (gate.refusal) return gate.refusal; const ip = gate.ip ?? "unknown"; - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, claimSchema, { maxBytes: 16 * 1024, }); - if (jsonError) return jsonError; - const parsed = claimSchema.safeParse(body); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; const { token, username, email, password } = parsed.data; // The link first. Without this the collision probe below would answer 409 diff --git a/src/app/api/auth/claim/sso/route.ts b/src/app/api/auth/claim/sso/route.ts index 21fd4938b..b9f009a67 100644 --- a/src/app/api/auth/claim/sso/route.ts +++ b/src/app/api/auth/claim/sso/route.ts @@ -20,12 +20,8 @@ import { NextRequest } from "next/server"; import { apiHandler } 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 { auditLog } from "@/lib/auth/audit"; import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; @@ -44,12 +40,10 @@ export const POST = apiHandler(async (request: NextRequest) => { const gate = await claimGate(request, "sso", (await getSession()) !== null); if (gate.refusal) return gate.refusal; - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, claimSsoSchema, { maxBytes: 4 * 1024, }); - if (jsonError) return jsonError; - const parsed = claimSsoSchema.safeParse(body); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; const { token, username } = parsed.data; // The link first, so the username probe below is no oracle for anybody diff --git a/src/app/api/auth/me/ai-usage/route.ts b/src/app/api/auth/me/ai-usage/route.ts index 7d5fd3d57..b74047ef5 100644 --- a/src/app/api/auth/me/ai-usage/route.ts +++ b/src/app/api/auth/me/ai-usage/route.ts @@ -10,13 +10,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"; @@ -40,16 +35,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { }); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, aiUsagePutSchema, { maxBytes: 1024, + errorCode: "ai.usage.invalid", }); - if (jsonError) return jsonError; - const parsed = aiUsagePutSchema.safeParse(body); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "ai.usage.invalid", - }); - } + if (!parsed.ok) return parsed.error; const { level } = parsed.data; const settings = await loadAiUsageSettings(); diff --git a/src/app/api/auth/me/devices/[id]/route.ts b/src/app/api/auth/me/devices/[id]/route.ts index 9930072e5..04a9e3421 100644 --- a/src/app/api/auth/me/devices/[id]/route.ts +++ b/src/app/api/auth/me/devices/[id]/route.ts @@ -25,12 +25,8 @@ import { 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 { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -56,14 +52,10 @@ export const PATCH = apiHandler( const { user } = await requireAuth(); const { id } = await context.params; - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, devicePatchSchema, { maxBytes: 64 * 1024, }); - if (jsonError) return jsonError; - const parsed = devicePatchSchema.safeParse(body); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; // Ownership boundary — scope the update by userId so a cross-user id // can't be patched, and 404 rather than leak existence. diff --git a/src/app/api/auth/me/diabetes/route.ts b/src/app/api/auth/me/diabetes/route.ts index dcb42f992..5133d57e3 100644 --- a/src/app/api/auth/me/diabetes/route.ts +++ b/src/app/api/auth/me/diabetes/route.ts @@ -20,13 +20,8 @@ import { z } from "zod"; 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 { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -79,16 +74,11 @@ export const PATCH = apiHandler(async (req: Request) => { // The body is a single boolean — bound the parse so a malformed or // oversized payload is rejected before it is materialised. - const { data: body, error: jsonError } = await safeJson(req, { + const parsed = await parseJsonBody(req, patchBodySchema, { maxBytes: 1024, + action: "auth.me.diabetes.patch.invalid_shape", }); - if (jsonError) return jsonError; - - const parsed = patchBodySchema.safeParse(body); - if (!parsed.success) { - annotate({ action: { name: "auth.me.diabetes.patch.invalid_shape" } }); - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const next = parsed.data.hasDiabetes; diff --git a/src/app/api/auth/me/documents-auto-ai-read/route.ts b/src/app/api/auth/me/documents-auto-ai-read/route.ts index ee1161184..5ae552536 100644 --- a/src/app/api/auth/me/documents-auto-ai-read/route.ts +++ b/src/app/api/auth/me/documents-auto-ai-read/route.ts @@ -26,13 +26,8 @@ * resolved next state so the client can hard-set the optimistic update. */ 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 { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { getAiCapability } from "@/lib/ai/capabilities/gate"; @@ -134,18 +129,11 @@ export const PATCH = apiHandler(async (req: Request) => { // The body is a single boolean — bound the parse so a malformed or oversized // payload is rejected before it is materialised. - const { data: body, error: jsonError } = await safeJson(req, { + const parsed = await parseJsonBody(req, documentsAutoAiReadPatchSchema, { maxBytes: 1024, + action: "auth.me.documentsAutoAiRead.patch.invalid_shape", }); - if (jsonError) return jsonError; - - const parsed = documentsAutoAiReadPatchSchema.safeParse(body); - if (!parsed.success) { - annotate({ - action: { name: "auth.me.documentsAutoAiRead.patch.invalid_shape" }, - }); - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const next = parsed.data.documentsAutoAiRead; diff --git a/src/app/api/auth/me/glucose-unit/route.ts b/src/app/api/auth/me/glucose-unit/route.ts index b11373224..719d3f45a 100644 --- a/src/app/api/auth/me/glucose-unit/route.ts +++ b/src/app/api/auth/me/glucose-unit/route.ts @@ -25,13 +25,8 @@ * update without an extra round-trip. */ 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 { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -81,16 +76,11 @@ export const PATCH = apiHandler(async (req: Request) => { // The body is a single enum value — bound the parse so a malformed or // oversized payload is rejected before it is materialised. - const { data: body, error: jsonError } = await safeJson(req, { + const parsed = await parseJsonBody(req, glucoseUnitPatchSchema, { maxBytes: 1024, + action: "auth.me.glucose-unit.patch.invalid_shape", }); - if (jsonError) return jsonError; - - const parsed = glucoseUnitPatchSchema.safeParse(body); - if (!parsed.success) { - annotate({ action: { name: "auth.me.glucose-unit.patch.invalid_shape" } }); - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const next = parsed.data.glucoseUnit; diff --git a/src/app/api/auth/me/injection-site-prefs/route.ts b/src/app/api/auth/me/injection-site-prefs/route.ts index 2d38d5d2c..b7836300d 100644 --- a/src/app/api/auth/me/injection-site-prefs/route.ts +++ b/src/app/api/auth/me/injection-site-prefs/route.ts @@ -19,13 +19,8 @@ * field-by-field write (no mass assignment). */ 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 { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -77,18 +72,11 @@ export const PATCH = apiHandler(async (request: NextRequest) => { return response; } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, injectionSitePrefsPatchSchema, { maxBytes: 64 * 1024, + action: "auth.me.injection-site-prefs.patch.invalid_shape", }); - if (jsonError) return jsonError; - - const parsed = injectionSitePrefsPatchSchema.safeParse(body); - if (!parsed.success) { - annotate({ - action: { name: "auth.me.injection-site-prefs.patch.invalid_shape" }, - }); - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; // Dedup while preserving canonical order from the enum tuple. const submitted = new Set(parsed.data.globalExcludedInjectionSites); diff --git a/src/app/api/auth/me/labs-local-ocr/route.ts b/src/app/api/auth/me/labs-local-ocr/route.ts index dfa330ca8..ef1e829b5 100644 --- a/src/app/api/auth/me/labs-local-ocr/route.ts +++ b/src/app/api/auth/me/labs-local-ocr/route.ts @@ -17,13 +17,8 @@ * resolved next state so the client can hard-set the optimistic update. */ 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 { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -71,16 +66,11 @@ export const PATCH = apiHandler(async (req: Request) => { // The body is a single boolean — bound the parse so a malformed or oversized // payload is rejected before it is materialised. - const { data: body, error: jsonError } = await safeJson(req, { + const parsed = await parseJsonBody(req, labsLocalOcrPatchSchema, { maxBytes: 1024, + action: "auth.me.labsLocalOcr.patch.invalid_shape", }); - if (jsonError) return jsonError; - - const parsed = labsLocalOcrPatchSchema.safeParse(body); - if (!parsed.success) { - annotate({ action: { name: "auth.me.labsLocalOcr.patch.invalid_shape" } }); - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const next = parsed.data.labsLocalOcrEnabled; diff --git a/src/app/api/auth/me/mfa/webauthn/[id]/route.ts b/src/app/api/auth/me/mfa/webauthn/[id]/route.ts index cff4259d6..7baf7c004 100644 --- a/src/app/api/auth/me/mfa/webauthn/[id]/route.ts +++ b/src/app/api/auth/me/mfa/webauthn/[id]/route.ts @@ -10,14 +10,8 @@ */ import { NextRequest } from "next/server"; import { apiHandler, requireMfaManagementAuth } 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 { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -34,19 +28,11 @@ export const PATCH = apiHandler( const { user } = auth; const { id } = await params; - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, mfaWebauthnRenameSchema, { maxBytes: 4 * 1024, + message: "Invalid request", }); - if (jsonError) return jsonError; - - const parsed = mfaWebauthnRenameSchema.safeParse(body); - if (!parsed.success) { - return apiValidationError( - "Invalid request", - sanitiseZodIssues(parsed.error.issues), - 422, - ); - } + if (!parsed.ok) return parsed.error; const existing = await prisma.webauthnMfaCredential.findUnique({ where: { id }, diff --git a/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts index 975731d76..d3dd09fe5 100644 --- a/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts +++ b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts @@ -8,14 +8,8 @@ */ import { NextRequest } from "next/server"; import { apiHandler, requireMfaManagementAuth } 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 { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -30,19 +24,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const auth = await requireMfaManagementAuth(); const { user } = auth; - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, mfaWebauthnRegisterVerifySchema, { maxBytes: 64 * 1024, + message: "Invalid request", }); - if (jsonError) return jsonError; - - const parsed = mfaWebauthnRegisterVerifySchema.safeParse(body); - if (!parsed.success) { - return apiValidationError( - "Invalid request", - sanitiseZodIssues(parsed.error.issues), - 422, - ); - } + if (!parsed.ok) return parsed.error; const { challengeId, credential, name } = parsed.data; const verification = await verifyMfaRegistration( diff --git a/src/app/api/auth/me/unit-preference/route.ts b/src/app/api/auth/me/unit-preference/route.ts index fe17915fa..8b001f5cb 100644 --- a/src/app/api/auth/me/unit-preference/route.ts +++ b/src/app/api/auth/me/unit-preference/route.ts @@ -25,13 +25,8 @@ * `res.ok` and never the status. */ 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 { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -85,18 +80,11 @@ export const PATCH = apiHandler(async (req: Request) => { // The body is a single enum value — bound the parse so a malformed or // oversized payload is rejected before it is materialised. - const { data: body, error: jsonError } = await safeJson(req, { + const parsed = await parseJsonBody(req, unitPreferencePatchSchema, { maxBytes: 1024, + action: "auth.me.unit-preference.patch.invalid_shape", }); - if (jsonError) return jsonError; - - const parsed = unitPreferencePatchSchema.safeParse(body); - if (!parsed.success) { - annotate({ - action: { name: "auth.me.unit-preference.patch.invalid_shape" }, - }); - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const next = parsed.data.unitPreference; diff --git a/src/app/api/auth/me/use-central-codex/route.ts b/src/app/api/auth/me/use-central-codex/route.ts index 57d1c803e..c8b0128ad 100644 --- a/src/app/api/auth/me/use-central-codex/route.ts +++ b/src/app/api/auth/me/use-central-codex/route.ts @@ -17,13 +17,8 @@ * assignment). Idempotent — always returns the resolved next state. */ 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 { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -69,18 +64,11 @@ export const PATCH = apiHandler(async (req: Request) => { return response; } - const { data: body, error: jsonError } = await safeJson(req, { + const parsed = await parseJsonBody(req, useCentralCodexPatchSchema, { maxBytes: 1024, + action: "auth.me.useCentralCodex.patch.invalid_shape", }); - if (jsonError) return jsonError; - - const parsed = useCentralCodexPatchSchema.safeParse(body); - if (!parsed.success) { - annotate({ - action: { name: "auth.me.useCentralCodex.patch.invalid_shape" }, - }); - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const next = parsed.data.useCentralCodex; diff --git a/src/app/api/auth/me/workout-routes/route.ts b/src/app/api/auth/me/workout-routes/route.ts index aea7aba69..1da862ee7 100644 --- a/src/app/api/auth/me/workout-routes/route.ts +++ b/src/app/api/auth/me/workout-routes/route.ts @@ -15,13 +15,8 @@ * the resolved next state in the response. */ 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 { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -67,16 +62,11 @@ export const PATCH = apiHandler(async (req: Request) => { return response; } - const { data: body, error: jsonError } = await safeJson(req, { + const parsed = await parseJsonBody(req, workoutRoutesPatchSchema, { maxBytes: 1024, + action: "auth.me.workoutRoutes.patch.invalid_shape", }); - if (jsonError) return jsonError; - - const parsed = workoutRoutesPatchSchema.safeParse(body); - if (!parsed.success) { - annotate({ action: { name: "auth.me.workoutRoutes.patch.invalid_shape" } }); - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const next = parsed.data.storeWorkoutRoutes; diff --git a/src/app/api/auth/mfa/webauthn/verify/options/route.ts b/src/app/api/auth/mfa/webauthn/verify/options/route.ts index 36992dd24..2cd02aa6d 100644 --- a/src/app/api/auth/mfa/webauthn/verify/options/route.ts +++ b/src/app/api/auth/mfa/webauthn/verify/options/route.ts @@ -13,13 +13,8 @@ */ import { NextRequest } from "next/server"; import { apiHandler } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - apiValidationError, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { ensureDbCompatibility } from "@/lib/db-compat"; import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -44,19 +39,11 @@ export const POST = apiHandler(async (request: NextRequest) => { await ensureDbCompatibility(); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, mfaWebauthnLoginOptionsSchema, { maxBytes: 16 * 1024, + message: "Invalid request", }); - if (jsonError) return jsonError; - - const parsed = mfaWebauthnLoginOptionsSchema.safeParse(body); - if (!parsed.success) { - return apiValidationError( - "Invalid request", - sanitiseZodIssues(parsed.error.issues), - 422, - ); - } + if (!parsed.ok) return parsed.error; const challenge = await loadActiveChallenge(parsed.data.mfaTicket); if (!challenge) { diff --git a/src/app/api/auth/native/token/route.ts b/src/app/api/auth/native/token/route.ts index 0d74291b3..bc14efe31 100644 --- a/src/app/api/auth/native/token/route.ts +++ b/src/app/api/auth/native/token/route.ts @@ -20,12 +20,8 @@ * `requireFreshMfa` step-up. */ import { NextRequest } from "next/server"; -import { - apiError, - apiValidationError, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiError } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -81,19 +77,11 @@ export const POST = apiHandler(async (request: NextRequest) => { await ensureDbCompatibility(); // (3) Body — bounded, then the exact wire contract. - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, nativeHandoffTokenSchema, { maxBytes: 16 * 1024, + message: "Invalid request", }); - if (jsonError) return jsonError; - - const parsed = nativeHandoffTokenSchema.safeParse(body); - if (!parsed.success) { - return apiValidationError( - "Invalid request", - sanitiseZodIssues(parsed.error.issues), - 422, - ); - } + if (!parsed.ok) return parsed.error; const { code, codeVerifier } = parsed.data; // (4) Lookup + flow check + replay/expiry/PKCE + atomic single-use consume. diff --git a/src/app/api/auth/oidc/native/token/route.ts b/src/app/api/auth/oidc/native/token/route.ts index bc28bed5e..623f3235f 100644 --- a/src/app/api/auth/oidc/native/token/route.ts +++ b/src/app/api/auth/oidc/native/token/route.ts @@ -23,12 +23,8 @@ * `requireFreshMfa` step-up, so an SSO login can never satisfy step-up. */ import { NextRequest } from "next/server"; -import { - apiError, - apiValidationError, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiError } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -87,19 +83,11 @@ export const POST = apiHandler(async (request: NextRequest) => { await ensureDbCompatibility(); // (3) Body — bounded, then the exact wire contract. - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, oidcNativeTokenSchema, { maxBytes: 16 * 1024, + message: "Invalid request", }); - if (jsonError) return jsonError; - - const parsed = oidcNativeTokenSchema.safeParse(body); - if (!parsed.success) { - return apiValidationError( - "Invalid request", - sanitiseZodIssues(parsed.error.issues), - 422, - ); - } + if (!parsed.ok) return parsed.error; const { code, codeVerifier } = parsed.data; // (4–8) Lookup + replay/expiry/PKCE + atomic single-use consume. diff --git a/src/app/api/auth/passkeys/[id]/route.ts b/src/app/api/auth/passkeys/[id]/route.ts index 38fb8ed38..206d166cd 100644 --- a/src/app/api/auth/passkeys/[id]/route.ts +++ b/src/app/api/auth/passkeys/[id]/route.ts @@ -1,12 +1,7 @@ import { prisma } from "@/lib/db"; 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 { NextRequest } from "next/server"; import { apiHandler, @@ -24,20 +19,10 @@ export const PATCH = apiHandler( const { user } = await requireAuth(); const { id } = await params; - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, passkeyRenameSchema, { maxBytes: 4 * 1024, }); - if (jsonError) return jsonError; - - const parsed = passkeyRenameSchema.safeParse(body); - if (!parsed.success) { - // Multi-issue, like every sibling that parses a body. The flat - // `apiError("Invalid request", 422)` this replaces discarded the issues, - // so a person renaming a passkey could not tell an empty name from one - // over the 64-character limit — the form had nothing to put beside the - // field. - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const passkey = await prisma.passkey.findUnique({ where: { id }, diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts index db421e863..f8f2643be 100644 --- a/src/app/api/auth/password/route.ts +++ b/src/app/api/auth/password/route.ts @@ -7,13 +7,8 @@ import { } from "@/lib/auth/password"; import { changePasswordSchema } from "@/lib/validations/auth"; 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 { recordReproofFailure, refundReproof, @@ -46,16 +41,10 @@ export const POST = apiHandler(async (request: NextRequest) => { const limited = await throttleReproof(user.id, ip, "password_change"); if (limited) return limited; - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, changePasswordSchema, { maxBytes: 64 * 1024, }); - - if (jsonError) return jsonError; - const parsed = changePasswordSchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 — multi-issue 422. - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const { currentPassword, newPassword } = parsed.data; const credential = await prisma.user.findUnique({ diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 3de681e1f..6e374033b 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -9,12 +9,8 @@ import { hashPassword, checkPasswordStrength } from "@/lib/auth/password"; import { createSession, getSession } from "@/lib/auth/session"; import { recordSignInDevice } from "@/lib/auth/login-alert"; import { auditLog } from "@/lib/auth/audit"; -import { - apiSuccess, - apiError, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { ensureDbCompatibility } from "@/lib/db-compat"; import { NextRequest, NextResponse } from "next/server"; @@ -100,17 +96,10 @@ export const POST = apiHandler(async (request: NextRequest) => { } catch { // Table may not exist yet; allow registration } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, registerSchema, { maxBytes: 64 * 1024, }); - - if (jsonError) return jsonError; - const parsed = registerSchema.safeParse(body); - - if (!parsed.success) { - // v1.4.43 — multi-issue 422. - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const { email, diff --git a/src/app/api/auth/reproof/route.ts b/src/app/api/auth/reproof/route.ts index 776e5d8a3..806dac15f 100644 --- a/src/app/api/auth/reproof/route.ts +++ b/src/app/api/auth/reproof/route.ts @@ -42,14 +42,8 @@ import { recentProofMethods, requireCookieAuth, } 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 { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { annotate } from "@/lib/logging/context"; @@ -68,19 +62,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const { user, session } = await requireCookieAuth(); const ip = getClientIp(request); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, stepUpMintSchema, { maxBytes: 64 * 1024, + message: "Invalid request", }); - if (jsonError) return jsonError; - - const parsed = stepUpMintSchema.safeParse(body); - if (!parsed.success) { - return apiValidationError( - "Invalid request", - sanitiseZodIssues(parsed.error.issues), - 422, - ); - } + if (!parsed.ok) return parsed.error; const proof = parsed.data; // Refuse a proof that could not satisfy the gate BEFORE spending a guess on diff --git a/src/app/api/auth/step-up/options/route.ts b/src/app/api/auth/step-up/options/route.ts index 6ec2f67ac..12280fa29 100644 --- a/src/app/api/auth/step-up/options/route.ts +++ b/src/app/api/auth/step-up/options/route.ts @@ -18,13 +18,8 @@ */ import { NextRequest } from "next/server"; import { apiHandler, requireBearerAuth } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - apiValidationError, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -49,19 +44,11 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, stepUpOptionsSchema, { maxBytes: 4 * 1024, + message: "Invalid request", }); - if (jsonError) return jsonError; - - const parsed = stepUpOptionsSchema.safeParse(body); - if (!parsed.success) { - return apiValidationError( - "Invalid request", - sanitiseZodIssues(parsed.error.issues), - 422, - ); - } + if (!parsed.ok) return parsed.error; if (parsed.data.method === "webauthn") { const result = await createMfaAuthenticationOptions(user.id); diff --git a/src/app/api/auth/step-up/route.ts b/src/app/api/auth/step-up/route.ts index 42e564c41..2c9d6dfcc 100644 --- a/src/app/api/auth/step-up/route.ts +++ b/src/app/api/auth/step-up/route.ts @@ -30,14 +30,8 @@ */ import { NextRequest } from "next/server"; import { apiHandler, requireBearerAuth } 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 { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -110,19 +104,11 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, stepUpMintSchema, { maxBytes: 64 * 1024, + message: "Invalid request", }); - if (jsonError) return jsonError; - - const parsed = stepUpMintSchema.safeParse(body); - if (!parsed.success) { - return apiValidationError( - "Invalid request", - sanitiseZodIssues(parsed.error.issues), - 422, - ); - } + if (!parsed.ok) return parsed.error; const method: StepUpMethod = parsed.data.method; // The shared verifier: the same challenge ownership and ceremony checks, and diff --git a/src/app/api/biomarkers/[id]/merge/route.ts b/src/app/api/biomarkers/[id]/merge/route.ts index 3c0e55465..37df9e8f1 100644 --- a/src/app/api/biomarkers/[id]/merge/route.ts +++ b/src/app/api/biomarkers/[id]/merge/route.ts @@ -19,13 +19,8 @@ import { NextRequest } from "next/server"; import { apiHandler, 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 { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { withIdempotency } from "@/lib/idempotency"; @@ -47,16 +42,11 @@ async function mergeBiomarkers( const { user } = await requireRecordAuth("manage", "labs"); const { id } = await params; - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, biomarkerMergeSchema, { maxBytes: 4 * 1024, + errorCode: "labs.biomarker.merge.invalid", }); - if (jsonError) return jsonError; - const parsed = biomarkerMergeSchema.safeParse(body); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "labs.biomarker.merge.invalid", - }); - } + if (!parsed.ok) return parsed.error; if (parsed.data.sourceIds.includes(id)) { return apiError("The target cannot be one of the sources", 422, { errorCode: "labs.biomarker.merge.invalid", diff --git a/src/app/api/c/[token]/unlock/route.ts b/src/app/api/c/[token]/unlock/route.ts index 9c602abe0..c76cddeb7 100644 --- a/src/app/api/c/[token]/unlock/route.ts +++ b/src/app/api/c/[token]/unlock/route.ts @@ -23,12 +23,8 @@ import { NextRequest } from "next/server"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { - apiError, - apiSuccess, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; import { resolveShareGateState } from "@/lib/clinician-share/resolve-share-token"; @@ -69,13 +65,10 @@ export const POST = apiHandler( return apiError("Too many attempts", 429); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, unlockShareLinkSchema, { maxBytes: 4 * 1024, }); - if (jsonError) return jsonError; - - const parsed = unlockShareLinkSchema.safeParse(body); - if (!parsed.success) return returnAllZodIssues(parsed.error); + if (!parsed.ok) return parsed.error; // A token with no live gate, or one carrying no passphrase, cannot be // unlocked. `verifyPassphrase` is constant-time and returns false for a diff --git a/src/app/api/coach/about-me/adopt/route.ts b/src/app/api/coach/about-me/adopt/route.ts index 5ffb8692d..1246c0248 100644 --- a/src/app/api/coach/about-me/adopt/route.ts +++ b/src/app/api/coach/about-me/adopt/route.ts @@ -28,13 +28,8 @@ * prose and must not land in plaintext audit details. */ 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 { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -111,15 +106,10 @@ export const POST = apiHandler(async (req: Request) => { return response; } - const { data: body, error: jsonError } = await safeJson(req, { + const parsed = await parseJsonBody(req, aboutMeAdoptSchema, { maxBytes: 16 * 1024, }); - if (jsonError) return jsonError; - - const parsed = aboutMeAdoptSchema.safeParse(body); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const { question, answer } = parsed.data; const normAnswer = normalise(answer); diff --git a/src/app/api/coach/plans/[id]/route.ts b/src/app/api/coach/plans/[id]/route.ts index 615db688e..4f9cfea36 100644 --- a/src/app/api/coach/plans/[id]/route.ts +++ b/src/app/api/coach/plans/[id]/route.ts @@ -25,12 +25,8 @@ import type { NextRequest } from "next/server"; 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 { prisma } from "@/lib/db"; import { requireModuleEnabled } from "@/lib/modules/gate"; @@ -77,15 +73,10 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => { const { id } = await ctx.params; - const { data: body, error: jsonError } = await safeJson(req, { + const parsed = await parseJsonBody(req, coachPlanPatchSchema, { maxBytes: 4 * 1024, }); - if (jsonError) return jsonError; - - const parsed = coachPlanPatchSchema.safeParse(body); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; // Field-by-field data assembly (no mass assignment): only the lifecycle // fields the body carried are written; the metric + encrypted text are never diff --git a/src/app/api/coach/reminder-suggestions/route.ts b/src/app/api/coach/reminder-suggestions/route.ts index eaafd4844..b55a5477b 100644 --- a/src/app/api/coach/reminder-suggestions/route.ts +++ b/src/app/api/coach/reminder-suggestions/route.ts @@ -24,13 +24,8 @@ import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { checkRateLimit } from "@/lib/rate-limit"; 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 { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { @@ -65,13 +60,12 @@ async function postAction(request: NextRequest): Promise { return apiError("coach.suggestion.rateLimited", 429); } - const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: 4 * 1024, - }); - if (jsonError) return jsonError; - - const parsed = coachReminderSuggestionActionSchema.safeParse(body); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + const parsed = await parseJsonBody( + request, + coachReminderSuggestionActionSchema, + { maxBytes: 4 * 1024 }, + ); + if (!parsed.ok) return parsed.error; const { cadenceId, action } = parsed.data; if (!isCadenceId(cadenceId)) { diff --git a/src/app/api/coach/reminders/[id]/route.ts b/src/app/api/coach/reminders/[id]/route.ts index 986b7f2ca..4944fc8be 100644 --- a/src/app/api/coach/reminders/[id]/route.ts +++ b/src/app/api/coach/reminders/[id]/route.ts @@ -23,12 +23,8 @@ import type { NextRequest } from "next/server"; 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 { prisma } from "@/lib/db"; import { requireModuleEnabled } from "@/lib/modules/gate"; @@ -71,13 +67,10 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => { const { id } = await ctx.params; - const { data: body, error: jsonError } = await safeJson(req, { + const parsed = await parseJsonBody(req, coachReminderPatchSchema, { maxBytes: 4 * 1024, }); - if (jsonError) return jsonError; - - const parsed = coachReminderPatchSchema.safeParse(body); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; // Field-by-field (no mass assignment): only the lifecycle / timing fields the // body carried are written; the note + encrypted text are never touched here. diff --git a/src/app/api/coach/reminders/route.ts b/src/app/api/coach/reminders/route.ts index dbbf065f8..0590f8e44 100644 --- a/src/app/api/coach/reminders/route.ts +++ b/src/app/api/coach/reminders/route.ts @@ -22,12 +22,8 @@ * / due / surfaced), soonest-due first. */ import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { requireModuleEnabled } from "@/lib/modules/gate"; @@ -151,13 +147,10 @@ export const POST = apiHandler(async (req: Request) => { ); if (!rate.allowed) return apiError("Too many requests", 429); - const { data: body, error: jsonError } = await safeJson(req, { + const parsed = await parseJsonBody(req, coachReminderCreateSchema, { maxBytes: 4 * 1024, }); - if (jsonError) return jsonError; - - const parsed = coachReminderCreateSchema.safeParse(body); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; // Resolve the optional `when` against the closed grammar; an invalid token // 422s rather than silently dropping the timing the user asked for. diff --git a/src/app/api/coach/suggested-actions/route.ts b/src/app/api/coach/suggested-actions/route.ts index 06ac2375c..151b35da4 100644 --- a/src/app/api/coach/suggested-actions/route.ts +++ b/src/app/api/coach/suggested-actions/route.ts @@ -26,13 +26,8 @@ import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { checkRateLimit } from "@/lib/rate-limit"; 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 { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; @@ -63,13 +58,10 @@ async function postAction(request: NextRequest): Promise { ); if (!rate.allowed) return apiError("coach.action.rateLimited", 429); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, coachSuggestedActionSchema, { maxBytes: 4 * 1024, }); - if (jsonError) return jsonError; - - const parsed = coachSuggestedActionSchema.safeParse(body); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; const action = parsed.data; // ── checkup.create → a preventive-care Vorsorge reminder ───── diff --git a/src/app/api/consent/ai/web/route.ts b/src/app/api/consent/ai/web/route.ts index 2229432b0..2e2bb87e4 100644 --- a/src/app/api/consent/ai/web/route.ts +++ b/src/app/api/consent/ai/web/route.ts @@ -17,12 +17,8 @@ * grants. */ 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 { auditLog } from "@/lib/auth/audit"; import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -52,15 +48,11 @@ export const POST = apiHandler(async (req: Request) => { let confirmed = false; const contentType = req.headers.get("content-type") ?? ""; if (contentType.includes("application/json")) { - const { data: body, error: jsonError } = await safeJson(req, { + const parsed = await parseJsonBody(req, webConsentGrantBody, { maxBytes: 1024, + action: "consent.ai.web-grant.invalid_shape", }); - if (jsonError) return jsonError; - const parsed = webConsentGrantBody.safeParse(body); - if (!parsed.success) { - annotate({ action: { name: "consent.ai.web-grant.invalid_shape" } }); - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; confirmed = parsed.data.confirm === true; } if (!confirmed) { diff --git a/src/app/api/custom-metrics/[id]/entries/restore/route.ts b/src/app/api/custom-metrics/[id]/entries/restore/route.ts index fe0f79d2f..80e8b078d 100644 --- a/src/app/api/custom-metrics/[id]/entries/restore/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/restore/route.ts @@ -18,12 +18,8 @@ import { z } from "zod/v4"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; -import { - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; @@ -43,15 +39,10 @@ export const POST = apiHandler( const { user } = await requireAuth(); const { id } = await params; - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, restoreSchema, { maxBytes: 64 * 1024, }); - if (jsonError) return jsonError; - - const parsed = restoreSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const { count } = await prisma.customMetricEntry.updateMany({ where: { diff --git a/src/app/api/cycle/day-logs/[id]/route.ts b/src/app/api/cycle/day-logs/[id]/route.ts index 4db316019..7947bb9b0 100644 --- a/src/app/api/cycle/day-logs/[id]/route.ts +++ b/src/app/api/cycle/day-logs/[id]/route.ts @@ -17,13 +17,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, - apiError, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { ensureCycleForBleedingDay, @@ -68,21 +63,13 @@ export const PATCH = apiHandler( return apiError("Day-log not found", 404); } - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, cycleDayLogPatchSchema, { maxBytes: 64 * 1024, + action: "cycle.day-log.patch.validation-failed", + countIssues: true, + errorCode: "cycle.day-log.invalid", }); - if (jsonError) return jsonError; - - const parsed = cycleDayLogPatchSchema.safeParse(rawBody); - if (!parsed.success) { - annotate({ - action: { name: "cycle.day-log.patch.validation-failed" }, - meta: { issue_count: parsed.error.issues.length }, - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "cycle.day-log.invalid", - }); - } + if (!parsed.ok) return parsed.error; const body = parsed.data; diff --git a/src/app/api/cycle/symptoms/custom/[key]/route.ts b/src/app/api/cycle/symptoms/custom/[key]/route.ts index d11b7df09..c1dc5e90a 100644 --- a/src/app/api/cycle/symptoms/custom/[key]/route.ts +++ b/src/app/api/cycle/symptoms/custom/[key]/route.ts @@ -9,13 +9,8 @@ import { NextRequest } from "next/server"; 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 { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -53,16 +48,11 @@ export const PATCH = apiHandler( const { key } = await params; if (!isCustomSymptomKey(key)) return apiError("Not a custom symptom", 404); - const { data: rawJsonBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, updateCustomSymptomSchema, { maxBytes: 64 * 1024, + errorCode: "cycle.symptom.custom.invalid", }); - if (jsonError) return jsonError; - const parsed = updateCustomSymptomSchema.safeParse(rawJsonBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "cycle.symptom.custom.invalid", - }); - } + if (!parsed.ok) return parsed.error; const owned = await prisma.cycleSymptom.findFirst({ where: { key, userId: user.id }, diff --git a/src/app/api/cycle/symptoms/custom/route.ts b/src/app/api/cycle/symptoms/custom/route.ts index ac4d56572..40ad54a09 100644 --- a/src/app/api/cycle/symptoms/custom/route.ts +++ b/src/app/api/cycle/symptoms/custom/route.ts @@ -17,13 +17,8 @@ import { NextRequest } from "next/server"; 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 { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { withIdempotency } from "@/lib/idempotency"; @@ -107,16 +102,11 @@ async function postCustomSymptom(request: NextRequest): Promise { return apiError("Too many requests, try again later", 429); } - const { data: rawJsonBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, createCustomSymptomSchema, { maxBytes: 64 * 1024, + errorCode: "cycle.symptom.custom.invalid", }); - if (jsonError) return jsonError; - const parsed = createCustomSymptomSchema.safeParse(rawJsonBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "cycle.symptom.custom.invalid", - }); - } + if (!parsed.ok) return parsed.error; const activeCount = await prisma.cycleSymptom.count({ where: { userId: user.id, isActive: true }, diff --git a/src/app/api/daily/digest/dismiss/route.ts b/src/app/api/daily/digest/dismiss/route.ts index 05a8d1f24..d2e6e0ea4 100644 --- a/src/app/api/daily/digest/dismiss/route.ts +++ b/src/app/api/daily/digest/dismiss/route.ts @@ -18,12 +18,8 @@ import type { NextRequest } from "next/server"; 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 { prisma } from "@/lib/db"; import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; @@ -52,15 +48,10 @@ export const POST = apiHandler(async (req: NextRequest) => { return response; } - const { data: body, error: jsonError } = await safeJson(req, { + const parsed = await parseJsonBody(req, dismissPriorityItemSchema, { maxBytes: 1024, }); - if (jsonError) return jsonError; - - const parsed = dismissPriorityItemSchema.safeParse(body); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const { itemKey } = parsed.data; diff --git a/src/app/api/documents/inbound/[id]/chat/route.ts b/src/app/api/documents/inbound/[id]/chat/route.ts index 49a24fdd7..96bf2a782 100644 --- a/src/app/api/documents/inbound/[id]/chat/route.ts +++ b/src/app/api/documents/inbound/[id]/chat/route.ts @@ -29,9 +29,9 @@ import { apiError, apiSuccess, apiValidationError, - safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -101,21 +101,12 @@ export const POST = apiHandler( }); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, documentChatRequestSchema, { maxBytes: 64 * 1024, + errorCode: "documents.chat.invalid", + message: "Invalid document chat request", }); - if (jsonError) return jsonError; - const parsed = documentChatRequestSchema.safeParse(body); - if (!parsed.success) { - return apiValidationError( - "Invalid document chat request", - sanitiseZodIssues(parsed.error.issues), - 422, - { - errorCode: "documents.chat.invalid", - }, - ); - } + if (!parsed.ok) return parsed.error; const { conversationId, message, locale: bodyLocale } = parsed.data; const rl = await checkRateLimit( diff --git a/src/app/api/documents/inbound/[id]/confirm/route.ts b/src/app/api/documents/inbound/[id]/confirm/route.ts index bfbf82d94..564af8e62 100644 --- a/src/app/api/documents/inbound/[id]/confirm/route.ts +++ b/src/app/api/documents/inbound/[id]/confirm/route.ts @@ -17,13 +17,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 { commitApprovedFact, FactCommitError } from "@/lib/documents/commit"; @@ -56,13 +51,10 @@ async function confirmDocument( }); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, inboundConfirmSchema, { maxBytes: 64 * 1024, }); - if (jsonError) return jsonError; - - const parsed = inboundConfirmSchema.safeParse(body); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; const approved: { factId: string; recordType: string; recordId: string }[] = []; diff --git a/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts b/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts index 36b2f68aa..e59ea6267 100644 --- a/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts +++ b/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts @@ -11,12 +11,8 @@ import { NextRequest } from "next/server"; 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 { prisma } from "@/lib/db"; import { encryptFactData, @@ -100,13 +96,10 @@ export const PATCH = apiHandler( }); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, inboundFactEditSchema, { maxBytes: 16 * 1024, }); - if (jsonError) return jsonError; - - const parsed = inboundFactEditSchema.safeParse(body); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; // The edit cannot change the fact's resource type. if (parsed.data.factType !== fact.factType) { diff --git a/src/app/api/documents/inbound/[id]/route.ts b/src/app/api/documents/inbound/[id]/route.ts index d4eff24b6..8be42e314 100644 --- a/src/app/api/documents/inbound/[id]/route.ts +++ b/src/app/api/documents/inbound/[id]/route.ts @@ -10,14 +10,8 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth, requireRecordAuth } 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 { @@ -164,20 +158,11 @@ export const PATCH = apiHandler( const { id } = await params; - const { data: body, error: jsonError } = await safeJson(request); - if (jsonError) return jsonError; - - const parsed = documentUpdateSchema.safeParse(body); - if (!parsed.success) { - return apiValidationError( - "Invalid document update", - sanitiseZodIssues(parsed.error.issues), - 422, - { - errorCode: "documents.inbound.invalidUpdate", - }, - ); - } + const parsed = await parseJsonBody(request, documentUpdateSchema, { + errorCode: "documents.inbound.invalidUpdate", + message: "Invalid document update", + }); + if (!parsed.ok) return parsed.error; const existing = await prisma.inboundDocument.findFirst({ where: { id, userId: user.id, deletedAt: null }, diff --git a/src/app/api/documents/inbound/bulk/route.ts b/src/app/api/documents/inbound/bulk/route.ts index a57f49df9..dbd3591c4 100644 --- a/src/app/api/documents/inbound/bulk/route.ts +++ b/src/app/api/documents/inbound/bulk/route.ts @@ -13,13 +13,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 { linkTargets, unlinkTargets } from "@/lib/links"; @@ -61,15 +56,10 @@ export const POST = apiHandler(async (request: NextRequest) => { return response; } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, documentBulkSchema, { maxBytes: 64 * 1024, }); - if (jsonError) return jsonError; - - const parsed = documentBulkSchema.safeParse(body); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const { ids, action, kind, episodeId, encounterId } = parsed.data; const uniqueIds = [...new Set(ids)]; diff --git a/src/app/api/documents/inbound/layout/route.ts b/src/app/api/documents/inbound/layout/route.ts index e81e5bca8..5d2bdd0ea 100644 --- a/src/app/api/documents/inbound/layout/route.ts +++ b/src/app/api/documents/inbound/layout/route.ts @@ -14,7 +14,8 @@ import { z } from "zod/v4"; import { apiHandler, requireAuth, requireRecordAuth } 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 { prisma, toJson } from "@/lib/db"; import { DOCUMENTS_LAYOUT_ARRANGEMENTS, @@ -67,16 +68,11 @@ export const PUT = apiHandler(async (request: Request) => { const gate = await requireModuleEnabled(user.id, "inboundDocuments"); if (!gate.enabled) return gate.response; - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, layoutPutSchema, { maxBytes: 1024, + action: "documents.layout.validation-failed", }); - if (jsonError) return jsonError; - - const parsed = layoutPutSchema.safeParse(body); - if (!parsed.success) { - annotate({ action: { name: "documents.layout.validation-failed" } }); - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; // Preserve-when-absent: one stored read fills whichever field the body // left out. Only fields the user has chosen are written, so a field never diff --git a/src/app/api/documents/sources/[system]/import/route.ts b/src/app/api/documents/sources/[system]/import/route.ts index 7da5933af..a5f4cb90f 100644 --- a/src/app/api/documents/sources/[system]/import/route.ts +++ b/src/app/api/documents/sources/[system]/import/route.ts @@ -36,13 +36,8 @@ import { Buffer } from "node:buffer"; import { NextRequest, NextResponse } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { - apiError, - apiValidationError, - getClientIp, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiError, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { getAiCapability } from "@/lib/ai/capabilities/gate"; import { prisma } from "@/lib/db"; import { @@ -176,18 +171,11 @@ export const POST = apiHandler( if (system instanceof Response) return system; const userId = auth.user.id; - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, documentSourceImportSchema, { maxBytes: 4 * 1024, + message: "Invalid import request", }); - if (jsonError) return jsonError; - const parsed = documentSourceImportSchema.safeParse(body); - if (!parsed.success) { - return apiValidationError( - "Invalid import request", - sanitiseZodIssues(parsed.error.issues), - 422, - ); - } + if (!parsed.ok) return parsed.error; const { sourceId, kind, link } = parsed.data; // Only an id this system could have issued goes into a request path. if (!isSourceDocumentId(system, sourceId)) { diff --git a/src/app/api/documents/sources/[system]/route.ts b/src/app/api/documents/sources/[system]/route.ts index 91cfb0dba..eb9e86910 100644 --- a/src/app/api/documents/sources/[system]/route.ts +++ b/src/app/api/documents/sources/[system]/route.ts @@ -18,14 +18,8 @@ import { 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 { decrypt, encrypt } from "@/lib/crypto"; import { prisma } from "@/lib/db"; @@ -70,18 +64,11 @@ export const PUT = apiHandler( ); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, documentSourceSaveSchema, { maxBytes: 8 * 1024, + message: "Invalid connection details", }); - if (jsonError) return jsonError; - const parsed = documentSourceSaveSchema.safeParse(body); - if (!parsed.success) { - return apiValidationError( - "Invalid connection details", - sanitiseZodIssues(parsed.error.issues), - 422, - ); - } + if (!parsed.ok) return parsed.error; const verdict = evaluateSourceBaseUrl(parsed.data.baseUrl); if (!verdict.ok) { diff --git a/src/app/api/encounters/[id]/links/route.ts b/src/app/api/encounters/[id]/links/route.ts index 37251a8ed..35383a3a7 100644 --- a/src/app/api/encounters/[id]/links/route.ts +++ b/src/app/api/encounters/[id]/links/route.ts @@ -20,12 +20,8 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { - apiSuccess, - apiError, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { encounterLinkSchema } from "@/lib/validations/encounters"; import { linkTargets, unlinkTargets } from "@/lib/links"; import { loadEncounterLinks } from "@/lib/encounters/service"; @@ -42,17 +38,11 @@ async function handle( const { id } = await params; - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, encounterLinkSchema, { maxBytes: 16 * 1024, + errorCode: "encounter.link.invalid", }); - if (jsonError) return jsonError; - - const parsed = encounterLinkSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "encounter.link.invalid", - }); - } + if (!parsed.ok) return parsed.error; const apply = verb === "link" ? linkTargets : unlinkTargets; const result = await apply(prisma, { diff --git a/src/app/api/encounters/[id]/route.ts b/src/app/api/encounters/[id]/route.ts index a954f3a9b..32c0fb9e2 100644 --- a/src/app/api/encounters/[id]/route.ts +++ b/src/app/api/encounters/[id]/route.ts @@ -22,13 +22,8 @@ import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { overwriteDetails } from "@/lib/sharing/audit-details"; -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 { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { encounterUpdateSchema } from "@/lib/validations/encounters"; import { encounterKindLabel } from "@/lib/encounters/kind-label"; @@ -107,17 +102,11 @@ export const PATCH = apiHandler( return apiError("Visit not found", 404); } - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, encounterUpdateSchema, { maxBytes: 32 * 1024, + errorCode: "encounter.invalid", }); - if (jsonError) return jsonError; - - const parsed = encounterUpdateSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "encounter.invalid", - }); - } + if (!parsed.ok) return parsed.error; const entry = parsed.data; const { timezone, locale } = await resolveOwnerNotificationContext(user.id); diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index 5c73973e6..f6b235e5d 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -23,8 +23,8 @@ import { apiError, getClientIp, returnAllZodIssues, - safeJson, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { apiListSuccess } from "@/lib/api/list-cap"; import { combinedCap, listPage } from "@/lib/http/list-query"; import { withIdempotency } from "@/lib/idempotency"; @@ -180,21 +180,13 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postEncounter)); async function postEncounter(request: NextRequest): Promise { const { user, grantId } = await requireRecordAuth("write", "profile"); - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, encounterCreateSchema, { maxBytes: 32 * 1024, + action: "encounter.visit.validation-failed", + countIssues: true, + errorCode: "encounter.invalid", }); - if (jsonError) return jsonError; - - const parsed = encounterCreateSchema.safeParse(rawBody); - if (!parsed.success) { - annotate({ - action: { name: "encounter.visit.validation-failed" }, - meta: { issue_count: parsed.error.issues.length }, - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "encounter.invalid", - }); - } + if (!parsed.ok) return parsed.error; const entry = parsed.data; const occurredAt = new Date(entry.occurredAt); diff --git a/src/app/api/environment/backfill/route.ts b/src/app/api/environment/backfill/route.ts index 8e809b193..1355e2c2b 100644 --- a/src/app/api/environment/backfill/route.ts +++ b/src/app/api/environment/backfill/route.ts @@ -15,12 +15,8 @@ import { NextRequest } from "next/server"; 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 { requireModuleEnabled } from "@/lib/modules/gate"; import { @@ -49,17 +45,11 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Too many requests. Please retry shortly.", 429); } - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, environmentBackfillSchema, { maxBytes: 4 * 1024, + errorCode: "environment.invalid", }); - if (jsonError) return jsonError; - - const parsed = environmentBackfillSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "environment.invalid", - }); - } + if (!parsed.ok) return parsed.error; const profile = await prisma.user.findUnique({ where: { id: user.id }, diff --git a/src/app/api/environment/home/route.ts b/src/app/api/environment/home/route.ts index 82cad0d8f..46cff45b6 100644 --- a/src/app/api/environment/home/route.ts +++ b/src/app/api/environment/home/route.ts @@ -10,7 +10,8 @@ import { NextRequest } from "next/server"; 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 { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; @@ -26,21 +27,13 @@ export const PUT = apiHandler(async (request: NextRequest) => { const gate = await requireModuleEnabled(user.id, "environment"); if (!gate.enabled) return gate.response; - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, homeLocationSchema, { maxBytes: 4 * 1024, + action: "environment.home.validation-failed", + countIssues: true, + errorCode: "environment.invalid", }); - if (jsonError) return jsonError; - - const parsed = homeLocationSchema.safeParse(rawBody); - if (!parsed.success) { - annotate({ - action: { name: "environment.home.validation-failed" }, - meta: { issue_count: parsed.error.issues.length }, - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "environment.invalid", - }); - } + if (!parsed.ok) return parsed.error; const { lat, lon, label, timezone } = parsed.data; // Stamp the effective-from instant: from now on this home resolves days; days diff --git a/src/app/api/environment/preferences/route.ts b/src/app/api/environment/preferences/route.ts index e8e30f43b..5d329f1fa 100644 --- a/src/app/api/environment/preferences/route.ts +++ b/src/app/api/environment/preferences/route.ts @@ -13,7 +13,8 @@ import { NextRequest } from "next/server"; 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 { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; @@ -27,17 +28,11 @@ export const PATCH = apiHandler(async (request: NextRequest) => { const gate = await requireModuleEnabled(user.id, "environment"); if (!gate.enabled) return gate.response; - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, environmentPreferencesSchema, { maxBytes: 1024, + errorCode: "environment.invalid", }); - if (jsonError) return jsonError; - - const parsed = environmentPreferencesSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "environment.invalid", - }); - } + if (!parsed.ok) return parsed.error; const before = await prisma.user.findUnique({ where: { id: user.id }, diff --git a/src/app/api/environment/travel/route.ts b/src/app/api/environment/travel/route.ts index 9510a740b..1622607b3 100644 --- a/src/app/api/environment/travel/route.ts +++ b/src/app/api/environment/travel/route.ts @@ -14,7 +14,8 @@ import { NextRequest } from "next/server"; 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 { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; @@ -29,21 +30,13 @@ export const POST = apiHandler(async (request: NextRequest) => { const gate = await requireModuleEnabled(user.id, "environment"); if (!gate.enabled) return gate.response; - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, travelLocationSchema, { maxBytes: 4 * 1024, + action: "environment.travel.validation-failed", + countIssues: true, + errorCode: "environment.invalid", }); - if (jsonError) return jsonError; - - const parsed = travelLocationSchema.safeParse(rawBody); - if (!parsed.success) { - annotate({ - action: { name: "environment.travel.validation-failed" }, - meta: { issue_count: parsed.error.issues.length }, - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "environment.invalid", - }); - } + if (!parsed.ok) return parsed.error; const entry = parsed.data; const location = { diff --git a/src/app/api/export/encrypted/route.ts b/src/app/api/export/encrypted/route.ts index 3cc347b00..7b28f78b7 100644 --- a/src/app/api/export/encrypted/route.ts +++ b/src/app/api/export/encrypted/route.ts @@ -31,12 +31,8 @@ import { z } from "zod/v4"; import { apiHandler, requireRecentProof } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; -import { - apiError, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { checkRateLimit } from "@/lib/rate-limit"; import { randomBytes } from "node:crypto"; import { createReadStream } from "node:fs"; @@ -105,15 +101,10 @@ export const POST = apiHandler(async (request: NextRequest) => { // The body is a single passphrase string — small and bounded. 8 KB is far // above any legitimate passphrase while rejecting a large body before parse. - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, encryptedExportSchema, { maxBytes: 8 * 1024, }); - if (jsonError) return jsonError; - - const parsed = encryptedExportSchema.safeParse(body); - if (!parsed.success) { - return returnAllZodIssues(parsed.error); - } + if (!parsed.ok) return parsed.error; const { passphrase } = parsed.data; await auth.commitElevation(); diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts index 067c04888..99de75333 100644 --- a/src/app/api/export/health-record/route.ts +++ b/src/app/api/export/health-record/route.ts @@ -29,12 +29,8 @@ import { } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; -import { - apiError, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { checkRateLimit } from "@/lib/rate-limit"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma, toJson } from "@/lib/db"; @@ -122,15 +118,10 @@ export const POST = apiHandler(async (request: NextRequest) => { // The selection payload is small and bounded. 64 KB is far above any // legitimate one while still rejecting a multi-megabyte body before it // reaches `JSON.parse`. A DoS ceiling, not a tight bound. - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, exportSelectionSchema, { maxBytes: 64 * 1024, }); - if (jsonError) return jsonError; - - const parsed = exportSelectionSchema.safeParse(body); - if (!parsed.success) { - return returnAllZodIssues(parsed.error); - } + if (!parsed.ok) return parsed.error; const input = parsed.data; const minted = selectionFromRequest(input.selection); diff --git a/src/app/api/family-history/[id]/route.ts b/src/app/api/family-history/[id]/route.ts index 928cb87ed..67364dc12 100644 --- a/src/app/api/family-history/[id]/route.ts +++ b/src/app/api/family-history/[id]/route.ts @@ -14,13 +14,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, - apiError, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { familyHistoryUpdateSchema } from "@/lib/validations/family-history"; import { toFamilyHistoryEntryDTO } from "@/lib/records/dto"; @@ -76,17 +71,11 @@ export const PATCH = apiHandler( return apiError("Family history entry not found", 404); } - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, familyHistoryUpdateSchema, { maxBytes: 16 * 1024, + errorCode: "familyHistory.invalid", }); - if (jsonError) return jsonError; - - const parsed = familyHistoryUpdateSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "familyHistory.invalid", - }); - } + if (!parsed.ok) return parsed.error; const entry = parsed.data; // Field-by-field — only fields actually present in the body are written. diff --git a/src/app/api/family-history/route.ts b/src/app/api/family-history/route.ts index a8937161b..58853c8ae 100644 --- a/src/app/api/family-history/route.ts +++ b/src/app/api/family-history/route.ts @@ -19,8 +19,8 @@ import { apiSuccess, getClientIp, returnAllZodIssues, - safeJson, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { withIdempotency } from "@/lib/idempotency"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { @@ -80,21 +80,13 @@ async function postFamilyHistory(request: NextRequest): Promise { // it (`recordSharing.anamnesis.relativeNote`). const { user } = await requireRecordAuth("manage", "profile"); - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, familyHistoryCreateSchema, { maxBytes: 16 * 1024, + action: "family-history.validation-failed", + countIssues: true, + errorCode: "familyHistory.invalid", }); - if (jsonError) return jsonError; - - const parsed = familyHistoryCreateSchema.safeParse(rawBody); - if (!parsed.success) { - annotate({ - action: { name: "family-history.validation-failed" }, - meta: { issue_count: parsed.error.issues.length }, - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "familyHistory.invalid", - }); - } + if (!parsed.ok) return parsed.error; const entry = parsed.data; diff --git a/src/app/api/illness/episodes/[id]/day-logs/route.ts b/src/app/api/illness/episodes/[id]/day-logs/route.ts index 56b18f31d..629cad92d 100644 --- a/src/app/api/illness/episodes/[id]/day-logs/route.ts +++ b/src/app/api/illness/episodes/[id]/day-logs/route.ts @@ -24,8 +24,8 @@ import { apiError, getClientIp, returnAllZodIssues, - safeJson, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { requireIllnessEnabled } from "@/lib/illness/gate"; import { illnessDayLogInputSchema, @@ -152,21 +152,13 @@ export const POST = apiHandler( const owned = await loadOwnedEpisode(id, user.id); if (!owned.ok) return owned.response; - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, illnessDayLogInputSchema, { maxBytes: 16 * 1024, + action: "illness.day-log.validation-failed", + countIssues: true, + errorCode: "illness.day-log.invalid", }); - if (jsonError) return jsonError; - - const parsed = illnessDayLogInputSchema.safeParse(rawBody); - if (!parsed.success) { - annotate({ - action: { name: "illness.day-log.validation-failed" }, - meta: { issue_count: parsed.error.issues.length }, - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "illness.day-log.invalid", - }); - } + if (!parsed.ok) return parsed.error; const tz = user.timezone ?? DEFAULT_TIMEZONE; const result = await upsertIllnessDayLog(user.id, id, parsed.data, tz); diff --git a/src/app/api/illness/episodes/[id]/route.ts b/src/app/api/illness/episodes/[id]/route.ts index 5aa7639f0..b502f195a 100644 --- a/src/app/api/illness/episodes/[id]/route.ts +++ b/src/app/api/illness/episodes/[id]/route.ts @@ -15,13 +15,8 @@ import { invalidateUserHealthContext } from "@/lib/cache/invalidate"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { overwriteDetails } from "@/lib/sharing/audit-details"; -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 { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { illnessEpisodeUpdateSchema } from "@/lib/validations/illness"; @@ -98,17 +93,11 @@ export const PATCH = apiHandler( return apiError("Episode not found", 404); } - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, illnessEpisodeUpdateSchema, { maxBytes: 16 * 1024, + errorCode: "illness.episode.invalid", }); - if (jsonError) return jsonError; - - const parsed = illnessEpisodeUpdateSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "illness.episode.invalid", - }); - } + if (!parsed.ok) return parsed.error; const entry = parsed.data; // Window invariant against the MERGED state: a partial edit touching only diff --git a/src/app/api/illness/episodes/route.ts b/src/app/api/illness/episodes/route.ts index 2c16236b9..424713c6a 100644 --- a/src/app/api/illness/episodes/route.ts +++ b/src/app/api/illness/episodes/route.ts @@ -22,8 +22,8 @@ import { apiError, getClientIp, returnAllZodIssues, - safeJson, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { requireIllnessEnabled } from "@/lib/illness/gate"; import { withIdempotency } from "@/lib/idempotency"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; @@ -108,21 +108,13 @@ async function postEpisode(request: NextRequest): Promise { const gate = await requireIllnessEnabled(user.id); if (!gate.enabled) return gate.response; - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, illnessEpisodeCreateSchema, { maxBytes: 16 * 1024, + action: "illness.episode.validation-failed", + countIssues: true, + errorCode: "illness.episode.invalid", }); - if (jsonError) return jsonError; - - const parsed = illnessEpisodeCreateSchema.safeParse(rawBody); - if (!parsed.success) { - annotate({ - action: { name: "illness.episode.validation-failed" }, - meta: { issue_count: parsed.error.issues.length }, - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "illness.episode.invalid", - }); - } + if (!parsed.ok) return parsed.error; const entry = parsed.data; diff --git a/src/app/api/insights/chat/[id]/attachments/route.ts b/src/app/api/insights/chat/[id]/attachments/route.ts index 895e61608..bc1bde3c0 100644 --- a/src/app/api/insights/chat/[id]/attachments/route.ts +++ b/src/app/api/insights/chat/[id]/attachments/route.ts @@ -11,13 +11,8 @@ import { type NextRequest } from "next/server"; import { requireAiCapability } from "@/lib/ai/capabilities/gate"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - apiValidationError, - safeJson, - sanitiseZodIssues, -} 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 { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -72,21 +67,12 @@ export const POST = apiHandler( return response; } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, coachAttachmentCreateSchema, { maxBytes: 4 * 1024, + errorCode: "coach.fenced.invalid", + message: "Invalid attachment request", }); - if (jsonError) return jsonError; - const parsed = coachAttachmentCreateSchema.safeParse(body); - if (!parsed.success) { - return apiValidationError( - "Invalid attachment request", - sanitiseZodIssues(parsed.error.issues), - 422, - { - errorCode: "coach.fenced.invalid", - }, - ); - } + if (!parsed.ok) return parsed.error; const { documentId } = parsed.data; // Ownership of the conversation — NOT filtered on `documentScoped` (this is diff --git a/src/app/api/insights/chat/[id]/route.ts b/src/app/api/insights/chat/[id]/route.ts index ff1371c15..72df349a4 100644 --- a/src/app/api/insights/chat/[id]/route.ts +++ b/src/app/api/insights/chat/[id]/route.ts @@ -13,13 +13,8 @@ import type { NextRequest } from "next/server"; import { z } from "zod/v4"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -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 { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { COACH_CONVERSATION_TITLE_MAX } from "@/lib/ai/coach/types"; @@ -75,17 +70,11 @@ export const PATCH = apiHandler(async (request: NextRequest, ctx: RouteCtx) => { const { id } = await ctx.params; if (!id) return apiError("coach.conversation.notFound", 404); - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, renameConversationSchema, { maxBytes: 4 * 1024, + errorCode: "coach.conversation.invalidTitle", }); - if (jsonError) return jsonError; - - const parsed = renameConversationSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "coach.conversation.invalidTitle", - }); - } + if (!parsed.ok) return parsed.error; const renamed = await renameConversation(auth.user.id, id, parsed.data.title); if (!renamed) { diff --git a/src/app/api/insights/chat/fenced/route.ts b/src/app/api/insights/chat/fenced/route.ts index 66c5f1dc6..166a82e10 100644 --- a/src/app/api/insights/chat/fenced/route.ts +++ b/src/app/api/insights/chat/fenced/route.ts @@ -17,12 +17,8 @@ import { type NextRequest } from "next/server"; import { requireAiCapability } from "@/lib/ai/capabilities/gate"; import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; -import { - apiError, - apiValidationError, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiError } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -65,21 +61,12 @@ export const POST = apiHandler(async (request: NextRequest) => { await requireAiCapability("coach", { pickDecides: true }); await requireAiCapability("documentAi", { pickDecides: true }); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, fencedChatRequestSchema, { maxBytes: 64 * 1024, + errorCode: "coach.fenced.invalid", + message: "Invalid fenced chat request", }); - if (jsonError) return jsonError; - const parsed = fencedChatRequestSchema.safeParse(body); - if (!parsed.success) { - return apiValidationError( - "Invalid fenced chat request", - sanitiseZodIssues(parsed.error.issues), - 422, - { - errorCode: "coach.fenced.invalid", - }, - ); - } + if (!parsed.ok) return parsed.error; const { conversationId, message, diff --git a/src/app/api/insights/coach/facts/[id]/route.ts b/src/app/api/insights/coach/facts/[id]/route.ts index b3222a406..cb39cdf30 100644 --- a/src/app/api/insights/coach/facts/[id]/route.ts +++ b/src/app/api/insights/coach/facts/[id]/route.ts @@ -30,12 +30,8 @@ import type { NextRequest } from "next/server"; 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 { prisma } from "@/lib/db"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; @@ -73,12 +69,10 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => { } const { id } = await ctx.params; - const { data: body, error: jsonError } = await safeJson(req, { + const parsed = await parseJsonBody(req, coachFactPatchSchema, { maxBytes: 2 * 1024, }); - if (jsonError) return jsonError; - const parsed = coachFactPatchSchema.safeParse(body); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; const text = parsed.data.fact.replace(/\s+/g, " "); const live = { diff --git a/src/app/api/insights/coach/facts/route.ts b/src/app/api/insights/coach/facts/route.ts index fa45b596b..600a019fb 100644 --- a/src/app/api/insights/coach/facts/route.ts +++ b/src/app/api/insights/coach/facts/route.ts @@ -31,12 +31,8 @@ * Erasure that depends on the feature being on is no erasure. */ 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 { prisma } from "@/lib/db"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; @@ -142,12 +138,10 @@ export const POST = apiHandler(async (req: Request) => { return response; } - const { data: body, error: jsonError } = await safeJson(req, { + const parsed = await parseJsonBody(req, coachFactCreateSchema, { maxBytes: 1024, }); - if (jsonError) return jsonError; - const parsed = coachFactCreateSchema.safeParse(body); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; const remembered = await rememberMessageAsFact({ userId: user.id, diff --git a/src/app/api/insights/patterns/[id]/route.ts b/src/app/api/insights/patterns/[id]/route.ts index 84c2ea72f..79a137252 100644 --- a/src/app/api/insights/patterns/[id]/route.ts +++ b/src/app/api/insights/patterns/[id]/route.ts @@ -7,8 +7,8 @@ import { apiSuccess, getClientIp, returnAllZodIssues, - safeJson, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; import { prisma } from "@/lib/db"; @@ -28,13 +28,12 @@ export const PATCH = apiHandler( const parsedId = patternIdSchema.safeParse((await params).id); if (!parsedId.success) return returnAllZodIssues(parsedId.error, 422); const id = parsedId.data; - const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: 1024, - }); - if (jsonError) return jsonError; - - const parsed = updateCorrelationPatternSchema.safeParse(body); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + const parsed = await parseJsonBody( + request, + updateCorrelationPatternSchema, + { maxBytes: 1024 }, + ); + if (!parsed.ok) return parsed.error; const pattern = await prisma.correlationPattern.findFirst({ where: { id, userId: user.id, isCurrent: true }, diff --git a/src/app/api/labs/[id]/links/route.ts b/src/app/api/labs/[id]/links/route.ts index 114b4a23f..9be6c9178 100644 --- a/src/app/api/labs/[id]/links/route.ts +++ b/src/app/api/labs/[id]/links/route.ts @@ -11,12 +11,8 @@ import { NextRequest } from "next/server"; 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 { prisma } from "@/lib/db"; import { linkTargets, unlinkTargets } from "@/lib/links"; import { annotate } from "@/lib/logging/context"; @@ -32,16 +28,11 @@ async function handle( const { user } = await requireAuth(); const { id } = await params; - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, labResultLinkSchema, { maxBytes: 16 * 1024, + errorCode: "labs.link.invalid", }); - if (jsonError) return jsonError; - const parsed = labResultLinkSchema.safeParse(body); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "labs.link.invalid", - }); - } + if (!parsed.ok) return parsed.error; const apply = verb === "link" ? linkTargets : unlinkTargets; const result = await apply(prisma, { diff --git a/src/app/api/labs/restore/route.ts b/src/app/api/labs/restore/route.ts index b008b2fa6..27e198a40 100644 --- a/src/app/api/labs/restore/route.ts +++ b/src/app/api/labs/restore/route.ts @@ -15,13 +15,8 @@ import { z } from "zod/v4"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; 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 { prisma } from "@/lib/db"; import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { withIdempotency } from "@/lib/idempotency"; @@ -59,17 +54,11 @@ async function postRestore(request: NextRequest): Promise { return apiError("Too many restore requests, try again later", 429); } - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, restoreSchema, { maxBytes: 64 * 1024, + errorCode: "labs.restore.invalid", }); - if (jsonError) return jsonError; - - const parsed = restoreSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "labs.restore.invalid", - }); - } + if (!parsed.ok) return parsed.error; const { ids } = parsed.data; diff --git a/src/app/api/life-events/[id]/route.ts b/src/app/api/life-events/[id]/route.ts index 6a7d6cd12..e0fa182c9 100644 --- a/src/app/api/life-events/[id]/route.ts +++ b/src/app/api/life-events/[id]/route.ts @@ -16,8 +16,8 @@ import { apiSuccess, getClientIp, returnAllZodIssues, - safeJson, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { lifeEventIdPathSchema, @@ -48,17 +48,11 @@ export const PATCH = apiHandler( const existing = await findOwnLifeEvent(user.id, path.data.id); if (!existing) return notFound(); - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, lifeEventUpdateSchema, { maxBytes: 16 * 1024, + errorCode: "life_event.invalid", }); - if (jsonError) return jsonError; - - const parsed = lifeEventUpdateSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "life_event.invalid", - }); - } + if (!parsed.ok) return parsed.error; const merged = mergeLifeEventEdit(existing, parsed.data); if (!merged.ok) { return returnAllZodIssues(merged.error, 422, { diff --git a/src/app/api/life-events/route.ts b/src/app/api/life-events/route.ts index 3d9dd4ee4..49508b3d2 100644 --- a/src/app/api/life-events/route.ts +++ b/src/app/api/life-events/route.ts @@ -20,12 +20,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 { lifeEventCreateSchema } from "@/lib/day/wire-schemas"; import { withIdempotency } from "@/lib/idempotency"; @@ -51,17 +47,11 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postLifeEvent)); async function postLifeEvent(request: NextRequest): Promise { const { user } = await requireAuth(); - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, lifeEventCreateSchema, { maxBytes: 16 * 1024, + errorCode: "life_event.invalid", }); - if (jsonError) return jsonError; - - const parsed = lifeEventCreateSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "life_event.invalid", - }); - } + if (!parsed.ok) return parsed.error; const created = await createLifeEvent(user.id, parsed.data); diff --git a/src/app/api/managed-profiles/[id]/guardians/route.ts b/src/app/api/managed-profiles/[id]/guardians/route.ts index 01e51b55b..f49bdba64 100644 --- a/src/app/api/managed-profiles/[id]/guardians/route.ts +++ b/src/app/api/managed-profiles/[id]/guardians/route.ts @@ -6,12 +6,8 @@ import { requireCookieAuth, requireFreshMfa, } 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 { annotate } from "@/lib/logging/context"; import { @@ -92,12 +88,12 @@ export const POST = apiHandler( if (!rateLimit.allowed) { return apiError("Too many invitations, try again later", 429); } - const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: 8 * 1024, - }); - if (jsonError) return jsonError; - const parsed = inviteManagedProfileGuardianSchema.safeParse(body); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + const parsed = await parseJsonBody( + request, + inviteManagedProfileGuardianSchema, + { maxBytes: 8 * 1024 }, + ); + if (!parsed.ok) return parsed.error; const { id } = await params; const invitee = await prisma.user.findFirst({ diff --git a/src/app/api/managed-profiles/[id]/handover/route.ts b/src/app/api/managed-profiles/[id]/handover/route.ts index 1177988b2..43a61bb51 100644 --- a/src/app/api/managed-profiles/[id]/handover/route.ts +++ b/src/app/api/managed-profiles/[id]/handover/route.ts @@ -23,12 +23,8 @@ import { requireCookieAuth, requireFreshMfa, } 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 { buildHandoverUrl } from "@/lib/auth/handover-token"; import { annotate } from "@/lib/logging/context"; import { handoverMethods } from "@/lib/managed-profiles/claim-gate"; @@ -111,12 +107,10 @@ export const POST = apiHandler( if (handoverMethods().length === 0) return noMethodRefusal(); const { id } = await params; - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, createHandoverSchema, { maxBytes: 16 * 1024, }); - if (jsonError) return jsonError; - const parsed = createHandoverSchema.safeParse(body); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; let created; try { diff --git a/src/app/api/managed-profiles/[id]/route.ts b/src/app/api/managed-profiles/[id]/route.ts index b713e7ad1..0ce6501e8 100644 --- a/src/app/api/managed-profiles/[id]/route.ts +++ b/src/app/api/managed-profiles/[id]/route.ts @@ -6,12 +6,8 @@ import { requireCookieAuth, requireFreshMfa, } 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 { deleteManagedProfile, ManagedProfileLifecycleError, @@ -132,13 +128,10 @@ export const PATCH = apiHandler( } const { id } = await params; - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, updateManagedProfileSchema, { maxBytes: 64 * 1024, }); - if (jsonError) return jsonError; - - const parsed = updateManagedProfileSchema.safeParse(body); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; // The parsed patch goes to the service, which assembles the Prisma payload // field by field. One assembly rather than one here and one there: two diff --git a/src/app/api/managed-profiles/route.ts b/src/app/api/managed-profiles/route.ts index 9cb3fe433..11f027f26 100644 --- a/src/app/api/managed-profiles/route.ts +++ b/src/app/api/managed-profiles/route.ts @@ -5,12 +5,8 @@ import { MFA_STEP_UP_MAX_AGE_SECONDS, requireFreshMfa, } 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 { auditLog } from "@/lib/auth/audit"; import { createManagedProfile } from "@/lib/managed-profiles/create"; import { toManagedProfileView } from "@/lib/managed-profiles/lifecycle"; @@ -48,13 +44,10 @@ export const POST = apiHandler(async (request: NextRequest) => { if (!rateLimit.allowed) { return apiError("Too many profiles created, try again later", 429); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, createManagedProfileSchema, { maxBytes: 64 * 1024, }); - if (jsonError) return jsonError; - - const parsed = createManagedProfileSchema.safeParse(body); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; const { profile, creatorGrant } = await createManagedProfile({ creatorId: user.id, diff --git a/src/app/api/mcp/tokens/route.ts b/src/app/api/mcp/tokens/route.ts index 9b54dbc2f..10586347b 100644 --- a/src/app/api/mcp/tokens/route.ts +++ b/src/app/api/mcp/tokens/route.ts @@ -22,12 +22,8 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth, requireRecentProof } 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 { auditLog } from "@/lib/auth/audit"; import { issueApiToken } from "@/lib/auth/issue-token"; @@ -84,15 +80,10 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("API is globally disabled", 403); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, createMcpTokenSchema, { maxBytes: 16 * 1024, }); - if (jsonError) return jsonError; - - const parsed = createMcpTokenSchema.safeParse(body); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; // Build the permission array explicitly from the closed scope choice — // never spread, never wildcard. `read` → read-only; `read_write` → read plus diff --git a/src/app/api/measurements/bulk-delete/route.ts b/src/app/api/measurements/bulk-delete/route.ts index ae87d1361..03ea9abc3 100644 --- a/src/app/api/measurements/bulk-delete/route.ts +++ b/src/app/api/measurements/bulk-delete/route.ts @@ -20,13 +20,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 { withIdempotency } from "@/lib/idempotency"; import { checkRateLimit } from "@/lib/rate-limit"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; @@ -64,17 +59,11 @@ async function postBulkDelete(request: NextRequest): Promise { return apiError("Too many bulk deletions, try again later", 429); } - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, bulkDeleteSchema, { maxBytes: 256 * 1024, + errorCode: "measurement.bulk-delete.invalid", }); - if (jsonError) return jsonError; - - const parsed = bulkDeleteSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "measurement.bulk-delete.invalid", - }); - } + if (!parsed.ok) return parsed.error; const { ids } = parsed.data; const now = new Date(); diff --git a/src/app/api/measurements/restore/route.ts b/src/app/api/measurements/restore/route.ts index 5f3433411..3c2a7b92d 100644 --- a/src/app/api/measurements/restore/route.ts +++ b/src/app/api/measurements/restore/route.ts @@ -22,13 +22,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 { withIdempotency } from "@/lib/idempotency"; import { checkRateLimit } from "@/lib/rate-limit"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; @@ -65,17 +60,11 @@ async function postRestore(request: NextRequest): Promise { return apiError("Too many restore requests, try again later", 429); } - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, restoreSchema, { maxBytes: 256 * 1024, + errorCode: "measurement.restore.invalid", }); - if (jsonError) return jsonError; - - const parsed = restoreSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "measurement.restore.invalid", - }); - } + if (!parsed.ok) return parsed.error; const { ids } = parsed.data; diff --git a/src/app/api/medications/[id]/courses/[courseId]/route.ts b/src/app/api/medications/[id]/courses/[courseId]/route.ts index e4b99dddc..ba2f3c203 100644 --- a/src/app/api/medications/[id]/courses/[courseId]/route.ts +++ b/src/app/api/medications/[id]/courses/[courseId]/route.ts @@ -5,13 +5,8 @@ */ import { NextRequest } from "next/server"; -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 { apiHandler, requireAuth } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; @@ -44,16 +39,11 @@ export const PATCH = apiHandler( const { user } = await requireAuth(); const { id, courseId } = await params; - const { data: raw, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, updateMedicationCourseSchema, { maxBytes: 16 * 1024, + errorCode: "medications.course.invalid", }); - if (jsonError) return jsonError; - const parsed = updateMedicationCourseSchema.safeParse(raw); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "medications.course.invalid", - }); - } + if (!parsed.ok) return parsed.error; try { const updated = await updateCourse({ diff --git a/src/app/api/medications/[id]/courses/route.ts b/src/app/api/medications/[id]/courses/route.ts index 1af87a726..ff526c4cc 100644 --- a/src/app/api/medications/[id]/courses/route.ts +++ b/src/app/api/medications/[id]/courses/route.ts @@ -13,13 +13,8 @@ */ import { NextRequest } from "next/server"; -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 { apiHandler, requireAuth } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; @@ -71,16 +66,11 @@ async function postCourse( const { user } = await requireAuth(); const { id } = await params; - const { data: raw, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, createMedicationCourseSchema, { maxBytes: 16 * 1024, + errorCode: "medications.course.invalid", }); - if (jsonError) return jsonError; - const parsed = createMedicationCourseSchema.safeParse(raw); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "medications.course.invalid", - }); - } + if (!parsed.ok) return parsed.error; try { const created = await createCourse({ diff --git a/src/app/api/medications/[id]/efficacy/target/route.ts b/src/app/api/medications/[id]/efficacy/target/route.ts index 31474f446..dab53df28 100644 --- a/src/app/api/medications/[id]/efficacy/target/route.ts +++ b/src/app/api/medications/[id]/efficacy/target/route.ts @@ -1,12 +1,8 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { - apiSuccess, - apiError, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { efficacyTargetOverrideSchema } from "@/lib/validations/medication-efficacy"; @@ -32,15 +28,10 @@ export const PUT = apiHandler( const guard = await assertMedicationOwnership(id, user.id); if (guard) return guard; - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, efficacyTargetOverrideSchema, { maxBytes: 8 * 1024, }); - if (jsonError) return jsonError; - - const parsed = efficacyTargetOverrideSchema.safeParse(body); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const { clear, measurementType, biomarkerId, primary } = parsed.data; // Clearing reverts to the derived resolution — drop every override row. diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts index eb5781d23..2f0b00f1e 100644 --- a/src/app/api/medications/[id]/glp1/route.ts +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -18,13 +18,8 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; import { annotate } from "@/lib/logging/context"; -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 { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { glp1PostBodySchema } from "@/lib/validations/medication"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; @@ -283,16 +278,10 @@ async function postGlp1Update( }); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, glp1PostBodySchema, { maxBytes: 64 * 1024, }); - if (jsonError) return jsonError; - - const parsed = glp1PostBodySchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 — multi-issue 422. - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const ip = getClientIp(request); diff --git a/src/app/api/medications/[id]/ingredients/route.ts b/src/app/api/medications/[id]/ingredients/route.ts index 6ef260b4a..397e3303d 100644 --- a/src/app/api/medications/[id]/ingredients/route.ts +++ b/src/app/api/medications/[id]/ingredients/route.ts @@ -17,13 +17,8 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, 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 { invalidateUserMedications } from "@/lib/cache/invalidate"; import { withIdempotency } from "@/lib/idempotency"; @@ -91,16 +86,11 @@ async function putIngredients( }); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, medicationIngredientsPutSchema, { maxBytes: 16 * 1024, + errorCode: "medications.ingredients.invalid", }); - if (jsonError) return jsonError; - const parsed = medicationIngredientsPutSchema.safeParse(body); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "medications.ingredients.invalid", - }); - } + if (!parsed.ok) return parsed.error; const check = canonicalComposition(parsed.data.ingredients); if (!check.ok) { diff --git a/src/app/api/medications/[id]/intake/bulk-delete/route.ts b/src/app/api/medications/[id]/intake/bulk-delete/route.ts index 75b0c5f98..e85f03923 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/route.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/route.ts @@ -2,13 +2,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 { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { bulkDeleteIntakeEventsSchema } from "@/lib/validations/medication"; import { restoreForIntake } from "@/lib/medications/inventory/consumption"; @@ -84,14 +79,10 @@ export const POST = apiHandler( }); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, bulkDeleteIntakeEventsSchema, { maxBytes: 256 * 1024, }); - if (jsonError) return jsonError; - const parsed = bulkDeleteIntakeEventsSchema.safeParse(body); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const { eventIds } = parsed.data; // Tz the rollups are anchored on. The detail-page caller persists diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts index 259a058e2..c7f788912 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts @@ -28,13 +28,8 @@ import { overwriteDetails, } from "@/lib/sharing/audit-details"; import { annotate } from "@/lib/logging/context"; -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 { updateInventoryItemSchema } from "@/lib/validations/medication"; import { computeExpiresAt, @@ -53,16 +48,10 @@ export const PATCH = apiHandler( const { user } = await requireRecordAuth("manage", "medications"); const { id, itemId } = await params; - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, updateInventoryItemSchema, { maxBytes: 64 * 1024, }); - if (jsonError) return jsonError; - - const parsed = updateInventoryItemSchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 — multi-issue 422. - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const { markAsFirstUseAt, diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts index d9ac6115f..79836f72e 100644 --- a/src/app/api/medications/[id]/inventory/route.ts +++ b/src/app/api/medications/[id]/inventory/route.ts @@ -20,13 +20,8 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; import { annotate } from "@/lib/logging/context"; -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 { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { createInventoryItemSchema } from "@/lib/validations/medication"; import { @@ -130,16 +125,10 @@ async function postInventoryItem( }); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, createInventoryItemSchema, { maxBytes: 64 * 1024, }); - if (jsonError) return jsonError; - - const parsed = createInventoryItemSchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 — multi-issue 422. - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const { unitsTotal, diff --git a/src/app/api/medications/[id]/links/route.ts b/src/app/api/medications/[id]/links/route.ts index 7535fe98d..cd8269a11 100644 --- a/src/app/api/medications/[id]/links/route.ts +++ b/src/app/api/medications/[id]/links/route.ts @@ -11,12 +11,8 @@ import { NextRequest } from "next/server"; 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 { prisma } from "@/lib/db"; import { linkTargets, unlinkTargets } from "@/lib/links"; import { annotate } from "@/lib/logging/context"; @@ -32,16 +28,11 @@ async function handle( const { user } = await requireAuth(); const { id } = await params; - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, medicationLinkSchema, { maxBytes: 16 * 1024, + errorCode: "medication.link.invalid", }); - if (jsonError) return jsonError; - const parsed = medicationLinkSchema.safeParse(body); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "medication.link.invalid", - }); - } + if (!parsed.ok) return parsed.error; const apply = verb === "link" ? linkTargets : unlinkTargets; const result = await apply(prisma, { diff --git a/src/app/api/medications/[id]/phase-config/route.ts b/src/app/api/medications/[id]/phase-config/route.ts index e3ae932c6..ef4c3310c 100644 --- a/src/app/api/medications/[id]/phase-config/route.ts +++ b/src/app/api/medications/[id]/phase-config/route.ts @@ -1,7 +1,8 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { apiSuccess } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { phaseConfigSchema } from "@/lib/validations/phase-config"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { NextRequest } from "next/server"; @@ -54,19 +55,10 @@ export const PUT = apiHandler( const guard = await assertMedicationOwnership(id, user.id); if (guard) return guard; - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, phaseConfigSchema, { maxBytes: 64 * 1024, }); - - if (jsonError) return jsonError; - const parsed = phaseConfigSchema.safeParse(body); - if (!parsed.success) { - // v1.5.5 — every v1.5.5 route returns the multi-issue - // 422 envelope so iOS sees per-field feedback the same way the - // bulk-delete and intake routes already do. The old `400 Invalid - // input` flat response was the last hold-out in the surface. - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; // v1.5.5 — build the Prisma payload field-by-field rather // than spreading `parsed.data`. A future schema extension would diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index a85ee9561..211413d6a 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -9,13 +9,8 @@ import { import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { overwriteDetails } from "@/lib/sharing/audit-details"; -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 { updateMedicationSchema } from "@/lib/validations/medication"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { @@ -372,16 +367,10 @@ export const PUT = apiHandler( return apiError("Medication not found", 404); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, updateMedicationSchema, { maxBytes: 64 * 1024, }); - - if (jsonError) return jsonError; - const parsed = updateMedicationSchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 — multi-issue 422. - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; // ── v1.39.1 (#1033) — a record-only schedule is only replaced knowingly ── // diff --git a/src/app/api/medications/[id]/side-effects/route.ts b/src/app/api/medications/[id]/side-effects/route.ts index 5efd00c8d..5ebd900c9 100644 --- a/src/app/api/medications/[id]/side-effects/route.ts +++ b/src/app/api/medications/[id]/side-effects/route.ts @@ -28,8 +28,8 @@ import { apiSuccess, getClientIp, returnAllZodIssues, - safeJson, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { createSideEffectSchema, @@ -149,16 +149,10 @@ async function postSideEffect( }); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, createSideEffectSchema, { maxBytes: 64 * 1024, }); - if (jsonError) return jsonError; - - const parsed = createSideEffectSchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 — multi-issue 422. - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const { entry, severity, occurredAt, notes } = parsed.data; diff --git a/src/app/api/medications/categories/[key]/route.ts b/src/app/api/medications/categories/[key]/route.ts index 85974a8bc..20454cc4e 100644 --- a/src/app/api/medications/categories/[key]/route.ts +++ b/src/app/api/medications/categories/[key]/route.ts @@ -14,13 +14,8 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -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 { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -60,16 +55,12 @@ export const PATCH = apiHandler( return apiError("Category not found", 404); } - const { data: raw, error: jsonError } = await safeJson(request, { - maxBytes: 16 * 1024, - }); - if (jsonError) return jsonError; - const parsed = updateMedicationCategoryLabelSchema.safeParse(raw); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "medications.category.invalid", - }); - } + const parsed = await parseJsonBody( + request, + updateMedicationCategoryLabelSchema, + { maxBytes: 16 * 1024, errorCode: "medications.category.invalid" }, + ); + if (!parsed.ok) return parsed.error; const owned = await prisma.medicationCategoryLabel.findFirst({ where: { key, userId: user.id }, diff --git a/src/app/api/medications/categories/route.ts b/src/app/api/medications/categories/route.ts index 313012672..ad907cb46 100644 --- a/src/app/api/medications/categories/route.ts +++ b/src/app/api/medications/categories/route.ts @@ -14,13 +14,8 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -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 { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { withIdempotency } from "@/lib/idempotency"; @@ -71,16 +66,12 @@ async function postMedicationCategory(request: NextRequest): Promise { return apiError("Too many requests, try again later", 429); } - const { data: raw, error: jsonError } = await safeJson(request, { - maxBytes: 16 * 1024, - }); - if (jsonError) return jsonError; - const parsed = createMedicationCategoryLabelSchema.safeParse(raw); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "medications.category.invalid", - }); - } + const parsed = await parseJsonBody( + request, + createMedicationCategoryLabelSchema, + { maxBytes: 16 * 1024, errorCode: "medications.category.invalid" }, + ); + if (!parsed.ok) return parsed.error; const count = await prisma.medicationCategoryLabel.count({ where: { userId: user.id }, diff --git a/src/app/api/mood-entries/bulk-delete/route.ts b/src/app/api/mood-entries/bulk-delete/route.ts index 292c2f57c..1fd64dbcd 100644 --- a/src/app/api/mood-entries/bulk-delete/route.ts +++ b/src/app/api/mood-entries/bulk-delete/route.ts @@ -20,13 +20,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 { withIdempotency } from "@/lib/idempotency"; import { checkRateLimit } from "@/lib/rate-limit"; import { invalidateUserMood } from "@/lib/cache/invalidate"; @@ -62,17 +57,11 @@ async function postBulkDelete(request: NextRequest): Promise { return apiError("Too many bulk deletions, try again later", 429); } - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, bulkDeleteSchema, { maxBytes: 256 * 1024, + errorCode: "mood.bulk-delete.invalid", }); - if (jsonError) return jsonError; - - const parsed = bulkDeleteSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "mood.bulk-delete.invalid", - }); - } + if (!parsed.ok) return parsed.error; const { ids } = parsed.data; const now = new Date(); diff --git a/src/app/api/mood-entries/restore/route.ts b/src/app/api/mood-entries/restore/route.ts index a550865c3..82268a011 100644 --- a/src/app/api/mood-entries/restore/route.ts +++ b/src/app/api/mood-entries/restore/route.ts @@ -22,13 +22,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 { withIdempotency } from "@/lib/idempotency"; import { checkRateLimit } from "@/lib/rate-limit"; import { invalidateUserMood } from "@/lib/cache/invalidate"; @@ -64,17 +59,11 @@ async function postRestore(request: NextRequest): Promise { return apiError("Too many restore requests, try again later", 429); } - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, restoreSchema, { maxBytes: 256 * 1024, + errorCode: "mood.restore.invalid", }); - if (jsonError) return jsonError; - - const parsed = restoreSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "mood.restore.invalid", - }); - } + if (!parsed.ok) return parsed.error; const { ids } = parsed.data; diff --git a/src/app/api/mood/tags/[key]/hidden/route.ts b/src/app/api/mood/tags/[key]/hidden/route.ts index c1ec8157c..3cea84336 100644 --- a/src/app/api/mood/tags/[key]/hidden/route.ts +++ b/src/app/api/mood/tags/[key]/hidden/route.ts @@ -1,12 +1,8 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { - apiSuccess, - apiError, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { hideCatalogueTagSchema, isCustomTagKey } from "@/lib/mood/custom-tags"; @@ -34,12 +30,10 @@ export const PUT = apiHandler( ); } - const { data: rawJsonBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, hideCatalogueTagSchema, { maxBytes: 64 * 1024, }); - if (jsonError) return jsonError; - const parsed = hideCatalogueTagSchema.safeParse(rawJsonBody); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; // Resolve against the catalogue (user_id NULL) only. const tag = await prisma.moodTag.findFirst({ diff --git a/src/app/api/mood/tags/custom/[key]/route.ts b/src/app/api/mood/tags/custom/[key]/route.ts index 2c46dd878..7b4a30974 100644 --- a/src/app/api/mood/tags/custom/[key]/route.ts +++ b/src/app/api/mood/tags/custom/[key]/route.ts @@ -1,13 +1,8 @@ import { NextRequest } from "next/server"; 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 { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -44,12 +39,10 @@ export const PATCH = apiHandler( const { key } = await params; if (!isCustomTagKey(key)) return apiError("Not a custom tag", 404); - const { data: rawJsonBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, updateCustomTagSchema, { maxBytes: 64 * 1024, }); - if (jsonError) return jsonError; - const parsed = updateCustomTagSchema.safeParse(rawJsonBody); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; const owned = await prisma.moodTag.findFirst({ where: { key, userId: user.id }, diff --git a/src/app/api/mood/tags/custom/route.ts b/src/app/api/mood/tags/custom/route.ts index 713099674..ff66fdaa9 100644 --- a/src/app/api/mood/tags/custom/route.ts +++ b/src/app/api/mood/tags/custom/route.ts @@ -1,13 +1,8 @@ import { NextRequest } from "next/server"; 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 { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -36,12 +31,10 @@ export const dynamic = "force-dynamic"; export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: rawJsonBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, createCustomTagSchema, { maxBytes: 64 * 1024, }); - if (jsonError) return jsonError; - const parsed = createCustomTagSchema.safeParse(rawJsonBody); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; const activeCount = await prisma.moodTag.count({ where: { userId: user.id, isActive: true }, diff --git a/src/app/api/mood/tags/groups/[key]/route.ts b/src/app/api/mood/tags/groups/[key]/route.ts index 61bbf92e5..b846a5865 100644 --- a/src/app/api/mood/tags/groups/[key]/route.ts +++ b/src/app/api/mood/tags/groups/[key]/route.ts @@ -1,13 +1,8 @@ import { NextRequest } from "next/server"; import { prisma, toJson } 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 { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -42,12 +37,10 @@ export const PATCH = apiHandler( const { key } = await params; if (!isCustomCategoryKey(key)) return apiError("Not a custom group", 404); - const { data: rawJsonBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, updateCustomGroupSchema, { maxBytes: 64 * 1024, }); - if (jsonError) return jsonError; - const parsed = updateCustomGroupSchema.safeParse(rawJsonBody); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; const owned = await prisma.moodTagCategory.findFirst({ where: { key, userId: user.id }, diff --git a/src/app/api/mood/tags/groups/route.ts b/src/app/api/mood/tags/groups/route.ts index beb867ba0..f9d06e099 100644 --- a/src/app/api/mood/tags/groups/route.ts +++ b/src/app/api/mood/tags/groups/route.ts @@ -1,13 +1,8 @@ import { NextRequest } from "next/server"; 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 { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -33,12 +28,10 @@ export const dynamic = "force-dynamic"; export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: rawJsonBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, createCustomGroupSchema, { maxBytes: 64 * 1024, }); - if (jsonError) return jsonError; - const parsed = createCustomGroupSchema.safeParse(rawJsonBody); - if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + if (!parsed.ok) return parsed.error; const activeCount = await prisma.moodTagCategory.count({ where: { userId: user.id, isActive: true }, diff --git a/src/app/api/nutrients/water/route.ts b/src/app/api/nutrients/water/route.ts index 34610a703..3377f3bd7 100644 --- a/src/app/api/nutrients/water/route.ts +++ b/src/app/api/nutrients/water/route.ts @@ -21,13 +21,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 { - apiError, - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { withIdempotency } from "@/lib/idempotency"; import { checkRateLimit } from "@/lib/rate-limit"; import { requireModuleEnabled } from "@/lib/modules/gate"; @@ -73,17 +68,11 @@ async function postWater(request: NextRequest): Promise { return apiError("Too many requests, try again later", 429); } - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, nutrientWaterWriteSchema, { maxBytes: 4 * 1024, + errorCode: "nutrient.water.invalid", }); - if (jsonError) return jsonError; - - const parsed = nutrientWaterWriteSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "nutrient.water.invalid", - }); - } + if (!parsed.ok) return parsed.error; const { amountMl, mode } = parsed.data; const userTz = user.timezone || DEFAULT_TIMEZONE; diff --git a/src/app/api/practitioners/[id]/route.ts b/src/app/api/practitioners/[id]/route.ts index 0d1f8819d..7bb6c2ffb 100644 --- a/src/app/api/practitioners/[id]/route.ts +++ b/src/app/api/practitioners/[id]/route.ts @@ -19,13 +19,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, - apiError, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { practitionerUpdateSchema } from "@/lib/validations/practitioners"; import { encryptNote } from "@/lib/crypto/note-cipher"; @@ -73,17 +68,11 @@ export const PATCH = apiHandler( return apiError("Practitioner not found", 404); } - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, practitionerUpdateSchema, { maxBytes: 16 * 1024, + errorCode: "practitioner.invalid", }); - if (jsonError) return jsonError; - - const parsed = practitionerUpdateSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "practitioner.invalid", - }); - } + if (!parsed.ok) return parsed.error; const entry = parsed.data; diff --git a/src/app/api/practitioners/route.ts b/src/app/api/practitioners/route.ts index 8cc7ac970..a78b24fea 100644 --- a/src/app/api/practitioners/route.ts +++ b/src/app/api/practitioners/route.ts @@ -23,8 +23,8 @@ import { apiSuccess, getClientIp, returnAllZodIssues, - safeJson, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { withIdempotency } from "@/lib/idempotency"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { encryptNote } from "@/lib/crypto/note-cipher"; @@ -110,21 +110,13 @@ export const POST = apiHandler( async function postPractitioner(request: NextRequest): Promise { const { user } = await requireRecordAuth("write", "profile"); - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, practitionerCreateSchema, { maxBytes: 16 * 1024, + action: "practitioner.contact.validation-failed", + countIssues: true, + errorCode: "practitioner.invalid", }); - if (jsonError) return jsonError; - - const parsed = practitionerCreateSchema.safeParse(rawBody); - if (!parsed.success) { - annotate({ - action: { name: "practitioner.contact.validation-failed" }, - meta: { issue_count: parsed.error.issues.length }, - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "practitioner.invalid", - }); - } + if (!parsed.ok) return parsed.error; const entry = parsed.data; diff --git a/src/app/api/settings/module-list-layout/route.ts b/src/app/api/settings/module-list-layout/route.ts index 481e38867..afb291c57 100644 --- a/src/app/api/settings/module-list-layout/route.ts +++ b/src/app/api/settings/module-list-layout/route.ts @@ -19,7 +19,8 @@ * cannot each start from the same stored value and drop the other's change. */ import { apiHandler, requireActorAuth } 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 { prisma, toJson } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { @@ -58,18 +59,11 @@ export const PUT = apiHandler(async (request: Request) => { const { user } = await requireActorAuth(); // 500 ids of up to 64 characters plus the envelope. - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, moduleListLayoutPutSchema, { maxBytes: 48 * 1024, + action: "settings.module-list-layout.validation-failed", }); - if (jsonError) return jsonError; - - const parsed = moduleListLayoutPutSchema.safeParse(body); - if (!parsed.success) { - annotate({ - action: { name: "settings.module-list-layout.validation-failed" }, - }); - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const { list, view, order, sortDir } = parsed.data; // Preserve-when-absent: one stored read fills whichever field the body diff --git a/src/app/api/settings/preview-features/route.ts b/src/app/api/settings/preview-features/route.ts index 408b40a2f..8a6859f9a 100644 --- a/src/app/api/settings/preview-features/route.ts +++ b/src/app/api/settings/preview-features/route.ts @@ -12,7 +12,8 @@ * flags switched at once both stay switched. */ import { apiHandler, requireActorAuth } 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 { prisma, toJson } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { @@ -52,18 +53,11 @@ export const GET = apiHandler(async () => { export const PATCH = apiHandler(async (request: Request) => { const { user } = await requireActorAuth(); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, previewFeaturesPatchSchema, { maxBytes: 1024, + action: "settings.preview-features.validation-failed", }); - if (jsonError) return jsonError; - - const parsed = previewFeaturesPatchSchema.safeParse(body); - if (!parsed.success) { - annotate({ - action: { name: "settings.preview-features.validation-failed" }, - }); - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const next = await prisma.$transaction(async (tx) => { await tx.$queryRaw`SELECT 1 FROM "users" WHERE "id" = ${user.id} FOR UPDATE`; diff --git a/src/app/api/share-links/route.ts b/src/app/api/share-links/route.ts index e3c9fada9..4afffe33a 100644 --- a/src/app/api/share-links/route.ts +++ b/src/app/api/share-links/route.ts @@ -19,13 +19,8 @@ import { apiHandler, requireAuth, requireRecentProof } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { hashToken } from "@/lib/auth/hmac"; -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 { checkRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; import { @@ -149,15 +144,10 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Maximum 20 share-link operations per hour", 429); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, createShareLinkSchema, { maxBytes: 16 * 1024, }); - if (jsonError) return jsonError; - - const parsed = createShareLinkSchema.safeParse(body); - if (!parsed.success) { - return returnAllZodIssues(parsed.error); - } + if (!parsed.ok) return parsed.error; const input = parsed.data; // v1.28.13 — a documents-only share carries NO report scope. Force it empty diff --git a/src/app/api/symptoms/definitions/[id]/route.ts b/src/app/api/symptoms/definitions/[id]/route.ts index e578f4a30..efb0ebbc4 100644 --- a/src/app/api/symptoms/definitions/[id]/route.ts +++ b/src/app/api/symptoms/definitions/[id]/route.ts @@ -12,13 +12,8 @@ import { NextRequest } from "next/server"; 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 { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -63,16 +58,11 @@ export const PATCH = apiHandler( } const { id } = await params; - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, symptomDefinitionUpdateSchema, { maxBytes: 16 * 1024, + errorCode: "symptoms.invalid", }); - if (jsonError) return jsonError; - const parsed = symptomDefinitionUpdateSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "symptoms.invalid", - }); - } + if (!parsed.ok) return parsed.error; const existing = await prisma.symptomDefinition.findFirst({ where: { id, userId: user.id }, diff --git a/src/app/api/symptoms/definitions/route.ts b/src/app/api/symptoms/definitions/route.ts index ddcc7df04..2c6bdab91 100644 --- a/src/app/api/symptoms/definitions/route.ts +++ b/src/app/api/symptoms/definitions/route.ts @@ -14,13 +14,8 @@ import { NextRequest } from "next/server"; 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 { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { withIdempotency } from "@/lib/idempotency"; @@ -79,16 +74,11 @@ async function postDefinition(request: NextRequest): Promise { return apiError("Too many requests, try again later", 429); } - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, symptomDefinitionCreateSchema, { maxBytes: 16 * 1024, + errorCode: "symptoms.invalid", }); - if (jsonError) return jsonError; - const parsed = symptomDefinitionCreateSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "symptoms.invalid", - }); - } + if (!parsed.ok) return parsed.error; const activeCount = await prisma.symptomDefinition.count({ where: { userId: user.id, isActive: true }, diff --git a/src/app/api/symptoms/events/[id]/route.ts b/src/app/api/symptoms/events/[id]/route.ts index f5a031c37..be70e4338 100644 --- a/src/app/api/symptoms/events/[id]/route.ts +++ b/src/app/api/symptoms/events/[id]/route.ts @@ -13,13 +13,8 @@ import { NextRequest } from "next/server"; 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 { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -54,16 +49,11 @@ export const PATCH = apiHandler( if (!gate.enabled) return gate.response; const { id } = await params; - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, symptomEventUpdateSchema, { maxBytes: 16 * 1024, + errorCode: "symptoms.invalid", }); - if (jsonError) return jsonError; - const parsed = symptomEventUpdateSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "symptoms.invalid", - }); - } + if (!parsed.ok) return parsed.error; const entry = parsed.data; const existing = await prisma.symptomEvent.findFirst({ diff --git a/src/app/api/symptoms/events/route.ts b/src/app/api/symptoms/events/route.ts index 40fa8b0ec..46f2a411b 100644 --- a/src/app/api/symptoms/events/route.ts +++ b/src/app/api/symptoms/events/route.ts @@ -23,8 +23,8 @@ import { apiError, getClientIp, returnAllZodIssues, - safeJson, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { withIdempotency } from "@/lib/idempotency"; @@ -107,16 +107,11 @@ async function postEvent(request: NextRequest): Promise { const gate = await requireIllnessEnabled(user.id); if (!gate.enabled) return gate.response; - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, symptomEventCreateSchema, { maxBytes: 16 * 1024, + errorCode: "symptoms.invalid", }); - if (jsonError) return jsonError; - const parsed = symptomEventCreateSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "symptoms.invalid", - }); - } + if (!parsed.ok) return parsed.error; const entry = parsed.data; // A hidden definition stays loggable from an older client's cached list; diff --git a/src/app/api/tokens/documents/route.ts b/src/app/api/tokens/documents/route.ts index c0ca0d9fd..52b869767 100644 --- a/src/app/api/tokens/documents/route.ts +++ b/src/app/api/tokens/documents/route.ts @@ -28,13 +28,8 @@ import { assertRecentCookieProof, requireCookieAuth, } 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 { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -79,15 +74,10 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Too many token mints, try again later", 429); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, createDocumentTokenSchema, { maxBytes: 16 * 1024, }); - if (jsonError) return jsonError; - - const parsed = createDocumentTokenSchema.safeParse(body); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; // Counted live and per scope, after validation and before the create; the // count-then-create race is bounded by the mint bucket above. See the diff --git a/src/app/api/tokens/measurements/route.ts b/src/app/api/tokens/measurements/route.ts index 2b10b3d37..600b968db 100644 --- a/src/app/api/tokens/measurements/route.ts +++ b/src/app/api/tokens/measurements/route.ts @@ -35,13 +35,8 @@ import { assertRecentCookieProof, requireCookieAuth, } 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 { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -118,15 +113,10 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Too many token mints, try again later", 429); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, createMeasurementTokenSchema, { maxBytes: 16 * 1024, }); - if (jsonError) return jsonError; - - const parsed = createMeasurementTokenSchema.safeParse(body); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; // Counted LIVE, not minted-ever, so revoking frees a slot and an expired // token stops occupying one — the ceiling bounds what currently exists diff --git a/src/app/api/tokens/workouts/route.ts b/src/app/api/tokens/workouts/route.ts index fca9acce0..b852b07eb 100644 --- a/src/app/api/tokens/workouts/route.ts +++ b/src/app/api/tokens/workouts/route.ts @@ -27,13 +27,8 @@ import { assertRecentCookieProof, requireCookieAuth, } 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 { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -78,15 +73,10 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Too many token mints, try again later", 429); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, createWorkoutTokenSchema, { maxBytes: 16 * 1024, }); - if (jsonError) return jsonError; - - const parsed = createWorkoutTokenSchema.safeParse(body); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; // Counted live and per scope, after validation and before the create; the // count-then-create race is bounded by the mint bucket above. See the diff --git a/src/app/api/user/thresholds/route.ts b/src/app/api/user/thresholds/route.ts index bd8ec93aa..f792101f6 100644 --- a/src/app/api/user/thresholds/route.ts +++ b/src/app/api/user/thresholds/route.ts @@ -15,13 +15,8 @@ * `RESET_ALL_CONFIRMATION` below. */ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { - apiSuccess, - apiError, - returnAllZodIssues, - safeJson, - getClientIp, -} 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 { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; @@ -84,16 +79,10 @@ export const PUT = apiHandler(async (request: NextRequest) => { return apiError("Too many requests, please slow down", 429); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, thresholdsUpdateSchema, { maxBytes: 64 * 1024, }); - if (jsonError) return jsonError; - - const parsed = thresholdsUpdateSchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 — multi-issue 422. - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const existing = await prisma.user.findUnique({ where: { id: user.id }, diff --git a/src/app/api/vaccinations/[id]/booster/route.ts b/src/app/api/vaccinations/[id]/booster/route.ts index 19a87d846..9bed56f44 100644 --- a/src/app/api/vaccinations/[id]/booster/route.ts +++ b/src/app/api/vaccinations/[id]/booster/route.ts @@ -30,13 +30,8 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; 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 { vaccinationBoosterSchema } from "@/lib/validations/vaccinations"; import { mintOrReanchorBooster } from "@/lib/vaccinations/booster-mint"; import { resolveOwnerTimezone } from "@/lib/vaccinations/service"; @@ -64,17 +59,11 @@ export const POST = apiHandler( ); } - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, vaccinationBoosterSchema, { maxBytes: 8 * 1024, + errorCode: "vaccination.booster-invalid", }); - if (jsonError) return jsonError; - - const parsed = vaccinationBoosterSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "vaccination.booster-invalid", - }); - } + if (!parsed.ok) return parsed.error; const timezone = await resolveOwnerTimezone(user.id); const result = await prisma.$transaction((tx) => diff --git a/src/app/api/vaccinations/[id]/links/route.ts b/src/app/api/vaccinations/[id]/links/route.ts index b6cd14fe9..c14fbc181 100644 --- a/src/app/api/vaccinations/[id]/links/route.ts +++ b/src/app/api/vaccinations/[id]/links/route.ts @@ -19,12 +19,8 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { - apiSuccess, - apiError, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { vaccinationLinkSchema } from "@/lib/validations/vaccinations"; import { linkTargets, unlinkTargets } from "@/lib/links"; import { loadVaccinationDocuments } from "@/lib/vaccinations/service"; @@ -41,17 +37,11 @@ async function handle( const { id } = await params; - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, vaccinationLinkSchema, { maxBytes: 16 * 1024, + errorCode: "vaccination.link.invalid", }); - if (jsonError) return jsonError; - - const parsed = vaccinationLinkSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "vaccination.link.invalid", - }); - } + if (!parsed.ok) return parsed.error; const apply = verb === "link" ? linkTargets : unlinkTargets; const result = await apply(prisma, { diff --git a/src/app/api/vaccinations/[id]/route.ts b/src/app/api/vaccinations/[id]/route.ts index 78111f637..42df0ed7e 100644 --- a/src/app/api/vaccinations/[id]/route.ts +++ b/src/app/api/vaccinations/[id]/route.ts @@ -20,13 +20,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, - apiError, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { vaccinationUpdateSchema } from "@/lib/validations/vaccinations"; import { toVaccinationDTO } from "@/lib/vaccinations/dto"; @@ -98,17 +93,11 @@ export const PATCH = apiHandler( return apiError("Vaccination not found", 404); } - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, vaccinationUpdateSchema, { maxBytes: 32 * 1024, + errorCode: "vaccination.invalid", }); - if (jsonError) return jsonError; - - const parsed = vaccinationUpdateSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "vaccination.invalid", - }); - } + if (!parsed.ok) return parsed.error; const entry = parsed.data; diff --git a/src/app/api/vaccinations/custom/[id]/route.ts b/src/app/api/vaccinations/custom/[id]/route.ts index e929aa1d9..9b2e08939 100644 --- a/src/app/api/vaccinations/custom/[id]/route.ts +++ b/src/app/api/vaccinations/custom/[id]/route.ts @@ -23,13 +23,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 { - apiError, - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { customVaccineUpdateSchema } from "@/lib/validations/vaccinations"; import { toCustomVaccineDTO } from "@/lib/vaccinations/dto"; import { @@ -57,17 +52,11 @@ export const PATCH = apiHandler( }); } - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, customVaccineUpdateSchema, { maxBytes: 8 * 1024, + errorCode: "vaccination.custom.invalid", }); - if (jsonError) return jsonError; - - const parsed = customVaccineUpdateSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "vaccination.custom.invalid", - }); - } + if (!parsed.ok) return parsed.error; const entry = parsed.data; // The lookup below and the unique index answer the same question; two diff --git a/src/app/api/vaccinations/custom/route.ts b/src/app/api/vaccinations/custom/route.ts index d114d3a3a..36311d852 100644 --- a/src/app/api/vaccinations/custom/route.ts +++ b/src/app/api/vaccinations/custom/route.ts @@ -24,13 +24,8 @@ import { isP2002 } from "@/lib/prisma-errors"; 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 { customVaccineCreateSchema } from "@/lib/validations/vaccinations"; import { toCustomVaccineDTO } from "@/lib/vaccinations/dto"; import { @@ -58,17 +53,11 @@ export const GET = apiHandler(async () => { export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireRecordAuth("write", "profile"); - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, customVaccineCreateSchema, { maxBytes: 8 * 1024, + errorCode: "vaccination.custom.invalid", }); - if (jsonError) return jsonError; - - const parsed = customVaccineCreateSchema.safeParse(rawBody); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422, { - errorCode: "vaccination.custom.invalid", - }); - } + if (!parsed.ok) return parsed.error; const entry = parsed.data; // The lookup below and the unique index answer the same question; two diff --git a/src/app/api/vaccinations/route.ts b/src/app/api/vaccinations/route.ts index afb938b15..0b55314a2 100644 --- a/src/app/api/vaccinations/route.ts +++ b/src/app/api/vaccinations/route.ts @@ -27,8 +27,8 @@ import { apiError, getClientIp, returnAllZodIssues, - safeJson, } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { withIdempotency } from "@/lib/idempotency"; import { apiListSuccess } from "@/lib/api/list-cap"; import { listPage } from "@/lib/http/list-query"; @@ -151,21 +151,13 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postVaccination)); async function postVaccination(request: NextRequest): Promise { const { user } = await requireRecordAuth("write", "profile"); - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, vaccinationCreateSchema, { maxBytes: 32 * 1024, + action: "vaccination.record.validation-failed", + countIssues: true, + errorCode: "vaccination.invalid", }); - if (jsonError) return jsonError; - - const parsed = vaccinationCreateSchema.safeParse(rawBody); - if (!parsed.success) { - annotate({ - action: { name: "vaccination.record.validation-failed" }, - meta: { issue_count: parsed.error.issues.length }, - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "vaccination.invalid", - }); - } + if (!parsed.ok) return parsed.error; const entry = parsed.data; const occurredAt = new Date(entry.occurredAt);