From ca04ef01b3ddff26cf2838ceeec0b9faaad8f898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 11:29:23 +0200 Subject: [PATCH] refactor(api): read the remaining settings and admin route bodies through parseJsonBody Admin, coach, consent, chart-overlay, device registration, insights feedback and provider chain, ECG ingest, onboarding, search and the AI provider settings move to parseJsonBody with the same message, error code, status and wide-event action they answered with. A refusal whose annotate meta is computed from the issues, or puts its keys in another order than the helper would, keeps that annotate verbatim in onInvalid. The routes that still call safeJson do something between the read and the schema: an optimistic-concurrency token split off the body, a count cap answered first, a refunded quota slot, a conditional read, a non-Zod check, or the body handed to a service. --- src/__tests__/baselines/route-safe-json.json | 22 +----- .../encryption/key-backup/confirm/route.ts | 17 ++--- src/app/api/admin/invites/route.ts | 18 ++--- .../settings/web-push-vapid/generate/route.ts | 25 ++----- src/app/api/coach/about-me/questions/route.ts | 13 ++-- src/app/api/consent/ai/route.ts | 20 ++---- .../dashboard/chart-overlay-prefs/route.ts | 71 ++++++++----------- src/app/api/devices/route.ts | 71 ++++++++----------- .../chat/messages/[id]/feedback/route.ts | 41 ++++------- src/app/api/insights/ecg/route.ts | 43 +++++------ src/app/api/insights/feedback/route.ts | 36 ++++------ src/app/api/insights/provider-chain/route.ts | 22 ++---- src/app/api/onboarding/answers/route.ts | 27 ++----- src/app/api/onboarding/complete/route.ts | 29 ++------ src/app/api/onboarding/disclaimer/route.ts | 26 ++----- src/app/api/onboarding/restart/route.ts | 28 +++----- src/app/api/onboarding/tour/route.ts | 26 ++----- src/app/api/search/route.ts | 38 ++++------ src/app/api/user/ai-provider/route.ts | 41 +++++------ 19 files changed, 203 insertions(+), 411 deletions(-) diff --git a/src/__tests__/baselines/route-safe-json.json b/src/__tests__/baselines/route-safe-json.json index 3931f309f..ec52c3e59 100644 --- a/src/__tests__/baselines/route-safe-json.json +++ b/src/__tests__/baselines/route-safe-json.json @@ -1,16 +1,13 @@ { "$comment": "safeJson calls per route file under src/app; new body-reading routes use parseJsonBody. Maintained by src/__tests__/route-body-parse-ratchet.test.ts.", - "count": 79, - "total": 79, + "count": 61, + "total": 61, "entries": { "app/api/admin/ai-settings/route.ts": 1, "app/api/admin/drain-per-sample-cumulative/route.ts": 1, - "app/api/admin/encryption/key-backup/confirm/route.ts": 1, "app/api/admin/encryption/key-backup/verify/route.ts": 1, - "app/api/admin/invites/route.ts": 1, "app/api/admin/notifications/reminder-check/route.ts": 1, "app/api/admin/rollups/recompute/route.ts": 1, - "app/api/admin/settings/web-push-vapid/generate/route.ts": 1, "app/api/admin/users/[id]/reset-password/route.ts": 1, "app/api/ai/test/route.ts": 1, "app/api/auth/login/route.ts": 1, @@ -23,17 +20,13 @@ "app/api/auth/passkey/login-options/route.ts": 1, "app/api/auth/profile/route.ts": 1, "app/api/auth/refresh/route.ts": 1, - "app/api/coach/about-me/questions/route.ts": 1, "app/api/coach/about-me/route.ts": 1, - "app/api/consent/ai/route.ts": 1, "app/api/cycle/day-logs/bulk/route.ts": 1, - "app/api/dashboard/chart-overlay-prefs/route.ts": 1, "app/api/dashboard/widgets/route.ts": 1, "app/api/dawarich/connect/route.ts": 1, "app/api/dawarich/disconnect/route.ts": 1, "app/api/dawarich/settings/route.ts": 1, "app/api/dawarich/sync/route.ts": 1, - "app/api/devices/route.ts": 1, "app/api/documents/inbound/[id]/extract/route.ts": 1, "app/api/documents/inbound/[id]/index/route.ts": 1, "app/api/documents/inbound/[id]/suggest/route.ts": 1, @@ -41,12 +34,8 @@ "app/api/fitbit/credentials/route.ts": 1, "app/api/google-health/credentials/route.ts": 1, "app/api/import/route.ts": 1, - "app/api/insights/chat/messages/[id]/feedback/route.ts": 1, - "app/api/insights/ecg/route.ts": 1, - "app/api/insights/feedback/route.ts": 1, "app/api/insights/generate/route.ts": 1, "app/api/insights/layout/route.ts": 1, - "app/api/insights/provider-chain/route.ts": 1, "app/api/integrations/healthkit/route.ts": 1, "app/api/internal/deploy-webhook/route.ts": 1, "app/api/labs/ocr/extract/route.ts": 1, @@ -63,21 +52,14 @@ "app/api/mood/tags/layout/route.ts": 1, "app/api/nightscout/connect/route.ts": 1, "app/api/nutrients/batch/route.ts": 1, - "app/api/onboarding/answers/route.ts": 1, - "app/api/onboarding/complete/route.ts": 1, - "app/api/onboarding/disclaimer/route.ts": 1, - "app/api/onboarding/restart/route.ts": 1, - "app/api/onboarding/tour/route.ts": 1, "app/api/oura/credentials/route.ts": 1, "app/api/polar/credentials/route.ts": 1, "app/api/record-settings/[family]/route.ts": 1, - "app/api/search/route.ts": 1, "app/api/settings/email/route.ts": 1, "app/api/settings/ntfy/route.ts": 1, "app/api/settings/telegram/route.ts": 1, "app/api/settings/webhook/route.ts": 1, "app/api/strava/credentials/route.ts": 1, - "app/api/user/ai-provider/route.ts": 1, "app/api/user/profile/route.ts": 1, "app/api/whoop/credentials/route.ts": 1, "app/api/withings/credentials/route.ts": 1, diff --git a/src/app/api/admin/encryption/key-backup/confirm/route.ts b/src/app/api/admin/encryption/key-backup/confirm/route.ts index b82977fd4..1a9f57724 100644 --- a/src/app/api/admin/encryption/key-backup/confirm/route.ts +++ b/src/app/api/admin/encryption/key-backup/confirm/route.ts @@ -11,13 +11,8 @@ import type { NextRequest } from "next/server"; import { z } from "zod/v4"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { annotate } from "@/lib/logging/context"; import { @@ -38,10 +33,10 @@ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAdmin(); annotate({ action: { name: "admin.encryption.keyBackup.confirm" } }); - const body = await safeJson(request, { maxBytes: 1024 }); - if (body.error) return body.error; - const parsed = confirmSchema.safeParse(body.data); - if (!parsed.success) return returnAllZodIssues(parsed.error); + const parsed = await parseJsonBody(request, confirmSchema, { + maxBytes: 1024, + }); + if (!parsed.ok) return parsed.error; const result = await confirmKeyBackup({ keyId: parsed.data.keyId, diff --git a/src/app/api/admin/invites/route.ts b/src/app/api/admin/invites/route.ts index 63552fea1..7f22909b4 100644 --- a/src/app/api/admin/invites/route.ts +++ b/src/app/api/admin/invites/route.ts @@ -13,12 +13,8 @@ */ import { prisma } from "@/lib/db"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; -import { - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { hashToken } from "@/lib/auth/hmac"; @@ -62,15 +58,11 @@ export const GET = apiHandler(async () => { export const POST = apiHandler(async (req: Request) => { const { user } = await requireAdmin(); - const { data: body, error: jsonError } = await safeJson(req, { + const parsed = await parseJsonBody(req, inviteCreateSchema, { maxBytes: 16 * 1024, + input: (body) => body ?? {}, }); - if (jsonError) return jsonError; - - const parsed = inviteCreateSchema.safeParse(body ?? {}); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const { expiresInDays, maxUses } = parsed.data; const rawToken = generateInviteToken(); diff --git a/src/app/api/admin/settings/web-push-vapid/generate/route.ts b/src/app/api/admin/settings/web-push-vapid/generate/route.ts index 27ce847d4..9f7e7093b 100644 --- a/src/app/api/admin/settings/web-push-vapid/generate/route.ts +++ b/src/app/api/admin/settings/web-push-vapid/generate/route.ts @@ -1,14 +1,8 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; -import { - apiError, - apiSuccess, - apiValidationError, - getClientIp, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; import { z } from "zod/v4"; @@ -53,19 +47,12 @@ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAdmin(); annotate({ action: { name: "admin.webPushVapid.generate" } }); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, generateSchema, { maxBytes: 4 * 1024, + input: (body) => body ?? {}, + message: "Invalid request", }); - if (jsonError) return jsonError; - - const parsed = generateSchema.safeParse(body ?? {}); - if (!parsed.success) { - return apiValidationError( - "Invalid request", - sanitiseZodIssues(parsed.error.issues), - 422, - ); - } + if (!parsed.ok) return parsed.error; const existing = await prisma.appSettings.findUnique({ where: { id: "singleton" }, diff --git a/src/app/api/coach/about-me/questions/route.ts b/src/app/api/coach/about-me/questions/route.ts index b9639e24b..85c1fa9a2 100644 --- a/src/app/api/coach/about-me/questions/route.ts +++ b/src/app/api/coach/about-me/questions/route.ts @@ -13,7 +13,8 @@ import { z } from "zod/v4"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { apiSuccess } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { getPendingQuestionsForUser, @@ -48,15 +49,11 @@ export const DELETE = apiHandler(async (req: Request) => { const gate = await requireModuleEnabled(user.id, "coach"); if (!gate.enabled) return gate.response; - const { data: body, error: jsonError } = await safeJson(req, { + const parsed = await parseJsonBody(req, dismissSchema, { maxBytes: 4 * 1024, + input: (body) => body ?? {}, }); - if (jsonError) return jsonError; - - const parsed = dismissSchema.safeParse(body ?? {}); - if (!parsed.success) { - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; const current = await getPendingQuestionsForUser(user.id); const remaining = diff --git a/src/app/api/consent/ai/route.ts b/src/app/api/consent/ai/route.ts index 5f460e415..86421e8ca 100644 --- a/src/app/api/consent/ai/route.ts +++ b/src/app/api/consent/ai/route.ts @@ -18,12 +18,8 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { fireAndForget } from "@/lib/logging/fire-and-forget"; -import { - apiError, - apiSuccess, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -50,16 +46,12 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } - const { data: body, error } = await safeJson(request, { + // v1.4.43 — multi-issue 400 (consent routes use 400 not 422). + const parsed = await parseJsonBody(request, consentPostBody, { maxBytes: 128 * 1024, + status: 400, }); - if (error) return error; - - const parsed = consentPostBody.safeParse(body); - if (!parsed.success) { - // v1.4.43 — multi-issue 400 (consent routes use 400 not 422). - return returnAllZodIssues(parsed.error, 400); - } + if (!parsed.ok) return parsed.error; const { kind, artefact, signedAt, confirm } = parsed.data; if (confirm !== true) { diff --git a/src/app/api/dashboard/chart-overlay-prefs/route.ts b/src/app/api/dashboard/chart-overlay-prefs/route.ts index 0f3e5464b..ab5c51a89 100644 --- a/src/app/api/dashboard/chart-overlay-prefs/route.ts +++ b/src/app/api/dashboard/chart-overlay-prefs/route.ts @@ -16,12 +16,8 @@ * same request. */ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { - apiSuccess, - returnAllZodIssues, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiSuccess, sanitiseZodIssues } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { shouldEmitAuditRow } from "@/lib/audit-dedup"; import { annotate } from "@/lib/logging/context"; import { prisma, toJson } from "@/lib/db"; @@ -60,42 +56,37 @@ const prefsSchema = z export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: body, error: jsonError } = await safeJson(request, { + // v1.4.43 — sibling of `/api/dashboard/widgets`; the per-chart + // overlay popover hits this on every toggle so the multi-issue + // envelope matches widgets exactly. Audit breadcrumb keyed + // `dashboard.chart-overlay.validation-failed`, deduped via the + // shared `shouldEmitAuditRow` 60 s `(userId, action)` window so a + // misbehaving iOS client looping the popover cannot flood the + // audit ledger. + const parsed = await parseJsonBody(request, prefsSchema, { maxBytes: 64 * 1024, + action: "dashboard.chart-overlay.validation-failed", + countIssues: true, + onInvalid: (error) => { + if ( + shouldEmitAuditRow(user.id, "dashboard.chart-overlay.validation-failed") + ) { + const issues = sanitiseZodIssues(error.issues); + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "dashboard.chart-overlay.validation-failed", + details: JSON.stringify({ issues }), + }, + }) + .catch(() => { + /* swallow — 422 response is the contract */ + }); + } + }, }); - if (jsonError) return jsonError; - - const parsed = prefsSchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 — sibling of `/api/dashboard/widgets`; the per-chart - // overlay popover hits this on every toggle so the multi-issue - // envelope matches widgets exactly. Audit breadcrumb keyed - // `dashboard.chart-overlay.validation-failed`, deduped via the - // shared `shouldEmitAuditRow` 60 s `(userId, action)` window so a - // misbehaving iOS client looping the popover cannot flood the - // audit ledger. - const issues = sanitiseZodIssues(parsed.error.issues); - annotate({ - action: { name: "dashboard.chart-overlay.validation-failed" }, - meta: { issue_count: issues.length }, - }); - if ( - shouldEmitAuditRow(user.id, "dashboard.chart-overlay.validation-failed") - ) { - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "dashboard.chart-overlay.validation-failed", - details: JSON.stringify({ issues }), - }, - }) - .catch(() => { - /* swallow — 422 response is the contract */ - }); - } - return returnAllZodIssues(parsed.error, 422); - } + if (!parsed.ok) return parsed.error; // Read-modify-write under a row lock, so two concurrent toggles (two // tabs, two charts flipped in quick succession) can neither drop one diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts index 17cfb2d16..5ad6c53a1 100644 --- a/src/app/api/devices/route.ts +++ b/src/app/api/devices/route.ts @@ -24,13 +24,8 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - returnAllZodIssues, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiError, apiSuccess, sanitiseZodIssues } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; @@ -113,42 +108,36 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Too many device registrations, please slow down", 429); } - const { data: body, error } = await safeJson(request, { + // v1.4.43 — iOS device-registration multi-issue 422 + audit + // breadcrumb. APNs-token + bundleId + environment + model + locale + // can all fail simultaneously on a misconfigured client build. + const parsed = await parseJsonBody(request, deviceSchema, { maxBytes: 64 * 1024, - }); - if (error) return error; - - const parsed = deviceSchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 — iOS device-registration multi-issue 422 + audit - // breadcrumb. APNs-token + bundleId + environment + model + locale - // can all fail simultaneously on a misconfigured client build. - const issues = sanitiseZodIssues(parsed.error.issues); - annotate({ - action: { name: "devices.register.validation-failed" }, - meta: { issue_count: issues.length }, - }); - // v1.4.49 — strip `message` from the audit-ledger row. Device - // registration carries `token` + `apnsToken`; the default Zod - // message for `invalid_format` echoes the offending value, which - // would persist an APNs token (or a forged near-miss) into the - // audit ledger. - const auditIssues = sanitiseZodIssues(parsed.error.issues, { - stripValuesFromMessage: true, - }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "devices.register.validation-failed", - details: JSON.stringify({ issues: auditIssues }), - }, - }) - .catch(() => { - /* swallow — 422 response is the contract */ + action: "devices.register.validation-failed", + countIssues: true, + onInvalid: (error) => { + // v1.4.49 — strip `message` from the audit-ledger row. Device + // registration carries `token` + `apnsToken`; the default Zod + // message for `invalid_format` echoes the offending value, which + // would persist an APNs token (or a forged near-miss) into the + // audit ledger. + const auditIssues = sanitiseZodIssues(error.issues, { + stripValuesFromMessage: true, }); - return returnAllZodIssues(parsed.error, 422); - } + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "devices.register.validation-failed", + details: JSON.stringify({ issues: auditIssues }), + }, + }) + .catch(() => { + /* swallow — 422 response is the contract */ + }); + }, + }); + if (!parsed.ok) return parsed.error; const { token, diff --git a/src/app/api/insights/chat/messages/[id]/feedback/route.ts b/src/app/api/insights/chat/messages/[id]/feedback/route.ts index 344eb38ff..05c2410cc 100644 --- a/src/app/api/insights/chat/messages/[id]/feedback/route.ts +++ b/src/app/api/insights/chat/messages/[id]/feedback/route.ts @@ -31,14 +31,8 @@ import type { NextRequest } from "next/server"; import { z } from "zod/v4"; import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - apiValidationError, - getClientIp, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { Prisma } from "@/generated/prisma/client"; @@ -79,28 +73,19 @@ async function handlePost(request: NextRequest, ctx: RouteContext) { return apiError("Too many feedback submissions, try again later", 429); } - const { data: rawBody, error } = await safeJson(request, { + // The dotted token keeps its place in `error`: this refusal keeps its 422, + // so moving the string would be a wire change with nothing forcing it — + // the `invalid_json` siblings moved only because their status moved to 400 + // anyway. `meta.errorCode` publishes the same token in the field a machine + // code belongs in, and `details.issues` names the fields that were refused. + const parsed = await parseJsonBody(request, coachMessageFeedbackSchema, { maxBytes: 64 * 1024, + action: "insights.coach.message.feedback", + meta: { outcome: "validation_failed" }, + message: "feedback.body.invalid", + errorCode: "feedback.body.invalid", }); - if (error) return error; - const parsed = coachMessageFeedbackSchema.safeParse(rawBody); - if (!parsed.success) { - annotate({ - action: { name: "insights.coach.message.feedback" }, - meta: { outcome: "validation_failed" }, - }); - // The dotted token keeps its place in `error`: this refusal keeps its 422, - // so moving the string would be a wire change with nothing forcing it — - // the `invalid_json` siblings moved only because their status moved to 400 - // anyway. `meta.errorCode` publishes the same token in the field a machine - // code belongs in, and `details.issues` names the fields that were refused. - return apiValidationError( - "feedback.body.invalid", - sanitiseZodIssues(parsed.error.issues), - 422, - { errorCode: "feedback.body.invalid" }, - ); - } + if (!parsed.ok) return parsed.error; const body = parsed.data; // Verify ownership only — we no longer touch encryptedContent. The diff --git a/src/app/api/insights/ecg/route.ts b/src/app/api/insights/ecg/route.ts index 4a9cfd219..65d8f897d 100644 --- a/src/app/api/insights/ecg/route.ts +++ b/src/app/api/insights/ecg/route.ts @@ -35,12 +35,8 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; -import { - apiError, - apiSuccess, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -223,27 +219,24 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Too many ECG submissions, try again later", 429); } - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, ecgIngestSchema, { maxBytes: MAX_BODY_BYTES, + onInvalid: (error) => { + // Which field was refused and why, never the value: without it a refused + // recording is a bare "invalid" in the log and the cause has to be guessed. + annotate({ + action: { name: "insights.ecg.ingest" }, + meta: { + outcome: "invalid", + invalidFields: error.issues + .map((issue) => `${issue.path.join(".") || "(body)"}:${issue.code}`) + .slice(0, 8) + .join(","), + }, + }); + }, }); - if (jsonError) return jsonError; - - const parsed = ecgIngestSchema.safeParse(rawBody); - if (!parsed.success) { - // Which field was refused and why, never the value: without it a refused - // recording is a bare "invalid" in the log and the cause has to be guessed. - annotate({ - action: { name: "insights.ecg.ingest" }, - meta: { - outcome: "invalid", - invalidFields: parsed.error.issues - .map((issue) => `${issue.path.join(".") || "(body)"}:${issue.code}`) - .slice(0, 8) - .join(","), - }, - }); - return returnAllZodIssues(parsed.error); - } + if (!parsed.ok) return parsed.error; const body = parsed.data; const result = await persistEcgRecording( diff --git a/src/app/api/insights/feedback/route.ts b/src/app/api/insights/feedback/route.ts index c98cab6bb..f1178254d 100644 --- a/src/app/api/insights/feedback/route.ts +++ b/src/app/api/insights/feedback/route.ts @@ -1,14 +1,8 @@ import type { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - apiValidationError, - getClientIp, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { Prisma } from "@/generated/prisma/client"; @@ -57,23 +51,17 @@ async function handlePost(request: NextRequest) { return apiError("Too many feedback submissions, try again later", 429); } - const { data: rawBody, error } = await safeJson(request, { - maxBytes: 64 * 1024, - }); - if (error) return error; - - const parsed = recommendationFeedbackRequestSchema.safeParse(rawBody); - if (!parsed.success) { - annotate({ - action: { name: "insights.recommendation.feedback" }, + const parsed = await parseJsonBody( + request, + recommendationFeedbackRequestSchema, + { + maxBytes: 64 * 1024, + action: "insights.recommendation.feedback", meta: { outcome: "validation_failed" }, - }); - return apiValidationError( - "Invalid feedback payload", - sanitiseZodIssues(parsed.error.issues), - 422, - ); - } + message: "Invalid feedback payload", + }, + ); + if (!parsed.ok) return parsed.error; const body = parsed.data; const attribution = await resolveFeedbackAttribution(user.id); diff --git a/src/app/api/insights/provider-chain/route.ts b/src/app/api/insights/provider-chain/route.ts index e2063b78e..4685916f1 100644 --- a/src/app/api/insights/provider-chain/route.ts +++ b/src/app/api/insights/provider-chain/route.ts @@ -1,11 +1,7 @@ import type { NextRequest } from "next/server"; import { apiHandler, HttpError, requireAuth } from "@/lib/api-handler"; -import { - apiSuccess, - apiValidationError, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiSuccess } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { annotate } from "@/lib/logging/context"; import { probeProviderChain, resolveProviderChain } from "@/lib/ai/provider"; import { @@ -90,19 +86,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "insights.provider_chain.update" } }); - const { data: body, error } = await safeJson(request, { + const parsed = await parseJsonBody(request, providerChainPutSchema, { maxBytes: 64 * 1024, + message: "Invalid provider chain payload", }); - if (error) return error; - - const parsed = providerChainPutSchema.safeParse(body); - if (!parsed.success) { - return apiValidationError( - "Invalid provider chain payload", - sanitiseZodIssues(parsed.error.issues), - 422, - ); - } + if (!parsed.ok) return parsed.error; // Defence-in-depth: reject duplicate provider types so a stale-tab // resubmit cannot create a chain that walks the same entry twice. diff --git a/src/app/api/onboarding/answers/route.ts b/src/app/api/onboarding/answers/route.ts index 9a318f4eb..f4c1fa4cd 100644 --- a/src/app/api/onboarding/answers/route.ts +++ b/src/app/api/onboarding/answers/route.ts @@ -24,13 +24,8 @@ import type { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { prisma, toJson } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; @@ -68,21 +63,13 @@ export const PATCH = apiHandler(async (request: NextRequest) => { }); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, onboardingAnswerSchema, { maxBytes: 16 * 1024, + action: "onboarding.needs.save", + meta: { outcome: "validation_failed" }, + errorCode: "onboarding.answers.invalid", }); - if (jsonError) return jsonError; - - const parsed = onboardingAnswerSchema.safeParse(body); - if (!parsed.success) { - annotate({ - action: { name: "onboarding.needs.save" }, - meta: { outcome: "validation_failed" }, - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "onboarding.answers.invalid", - }); - } + if (!parsed.ok) return parsed.error; const answer = parsed.data; const existing = await prisma.onboardingRecord.findUnique({ diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts index 635827dd1..f51f93d22 100644 --- a/src/app/api/onboarding/complete/route.ts +++ b/src/app/api/onboarding/complete/route.ts @@ -2,14 +2,8 @@ import { prisma, toJson } from "@/lib/db"; import { Prisma } from "@/generated/prisma/client"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { - apiError, - apiSuccess, - apiValidationError, - getClientIp, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { setOnboardingPendingCookie } from "@/lib/auth/session"; import { normalisePrefs } from "@/lib/modules/gate"; @@ -34,7 +28,6 @@ import { import { readManagedProfileForGuardian } from "@/lib/managed-profiles/lifecycle"; import { writeRecordModulePreferences } from "@/lib/record-settings/modules"; import { NextRequest } from "next/server"; -import { z } from "zod/v4"; import { onboardingCompleteSchema } from "@/lib/validations/onboarding"; /** @@ -75,22 +68,12 @@ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "onboarding.complete" } }); - const { data: body, error: jsonError } = await safeJson(request, { + const result = await parseJsonBody(request, onboardingCompleteSchema, { maxBytes: 64 * 1024, + message: "Invalid input", + errorCode: "onboarding.complete.invalid", }); - - if (jsonError) return jsonError; - const result = z.safeParse(onboardingCompleteSchema, body); - if (!result.success) { - return apiValidationError( - "Invalid input", - sanitiseZodIssues(result.error.issues), - 422, - { - errorCode: "onboarding.complete.invalid", - }, - ); - } + if (!result.ok) return result.error; const data: Record = { onboardingCompletedAt: new Date(), diff --git a/src/app/api/onboarding/disclaimer/route.ts b/src/app/api/onboarding/disclaimer/route.ts index ed89cc61c..7c10a018e 100644 --- a/src/app/api/onboarding/disclaimer/route.ts +++ b/src/app/api/onboarding/disclaimer/route.ts @@ -3,12 +3,8 @@ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { - apiSuccess, - apiValidationError, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiSuccess } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { DISCLAIMER_VERSION } from "@/lib/onboarding/disclaimer"; /** @@ -36,22 +32,12 @@ const bodySchema = z export const POST = apiHandler(async (request) => { const { user } = await requireAuth(); - const { data: rawBody, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, bodySchema, { maxBytes: 4 * 1024, + message: "Invalid input", + errorCode: "onboarding.disclaimer.invalid", }); - if (jsonError) return jsonError; - - const parsed = z.safeParse(bodySchema, rawBody); - if (!parsed.success) { - return apiValidationError( - "Invalid input", - sanitiseZodIssues(parsed.error.issues), - 422, - { - errorCode: "onboarding.disclaimer.invalid", - }, - ); - } + if (!parsed.ok) return parsed.error; // The server pins the canonical version it stamps — the body version is // only a freshness signal, never the source of truth. diff --git a/src/app/api/onboarding/restart/route.ts b/src/app/api/onboarding/restart/route.ts index 6c17bb117..f9929e18a 100644 --- a/src/app/api/onboarding/restart/route.ts +++ b/src/app/api/onboarding/restart/route.ts @@ -31,13 +31,8 @@ import type { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { prisma, toJson } from "@/lib/db"; import { Prisma } from "@/generated/prisma/client"; @@ -74,21 +69,14 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, onboardingRestartSchema, { maxBytes: 4 * 1024, + input: (body) => body ?? {}, + action: "onboarding.needs.restart", + meta: { outcome: "validation_failed" }, + errorCode: "onboarding.restart.invalid", }); - if (jsonError) return jsonError; - - const parsed = onboardingRestartSchema.safeParse(body ?? {}); - if (!parsed.success) { - annotate({ - action: { name: "onboarding.needs.restart" }, - meta: { outcome: "validation_failed" }, - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "onboarding.restart.invalid", - }); - } + if (!parsed.ok) return parsed.error; const steps = defaultOnboardingSteps(); const written = await prisma.onboardingRecord.upsert({ diff --git a/src/app/api/onboarding/tour/route.ts b/src/app/api/onboarding/tour/route.ts index dc04d6454..c22ded0c2 100644 --- a/src/app/api/onboarding/tour/route.ts +++ b/src/app/api/onboarding/tour/route.ts @@ -4,12 +4,8 @@ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { - apiSuccess, - apiValidationError, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; +import { apiSuccess } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { tourProgressSchema } from "@/lib/onboarding/tour-progress"; import { Prisma } from "@/generated/prisma/client"; @@ -54,22 +50,12 @@ const tourBodySchema = z export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: body, error: jsonError } = await safeJson(request, { + const parsed = await parseJsonBody(request, tourBodySchema, { maxBytes: 64 * 1024, + message: "Invalid input", + errorCode: "onboarding.tour.invalid", }); - if (jsonError) return jsonError; - - const parsed = z.safeParse(tourBodySchema, body); - if (!parsed.success) { - return apiValidationError( - "Invalid input", - sanitiseZodIssues(parsed.error.issues), - 422, - { - errorCode: "onboarding.tour.invalid", - }, - ); - } + if (!parsed.ok) return parsed.error; // Build the update field-by-field (no mass assignment). `completed` // drives the coarse boolean; a replay (`completed:false`) wipes the diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 0ffcd61df..dcdba0d56 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -28,12 +28,8 @@ import { NextRequest } from "next/server"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { prisma } from "@/lib/db"; import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; import { annotate } from "@/lib/logging/context"; @@ -71,24 +67,20 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Too many searches. Please retry shortly.", 429); } - const { data: body, error: jsonError } = await safeJson<{ - q?: unknown; - limit?: unknown; - }>(request, { maxBytes: SEARCH_BODY_MAX_BYTES }); - if (jsonError) return jsonError; - const parsed = searchQuerySchema.safeParse({ - q: body?.q ?? undefined, - limit: body?.limit ?? undefined, + const parsed = await parseJsonBody(request, searchQuerySchema, { + maxBytes: SEARCH_BODY_MAX_BYTES, + input: (raw) => { + const body = raw as { q?: unknown; limit?: unknown }; + return { + q: body?.q ?? undefined, + limit: body?.limit ?? undefined, + }; + }, + action: "search.query.invalid", + countIssues: true, + errorCode: "search.invalid", }); - if (!parsed.success) { - annotate({ - action: { name: "search.query.invalid" }, - meta: { issue_count: parsed.error.issues.length }, - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "search.invalid", - }); - } + if (!parsed.ok) return parsed.error; const slot = beginSearch(actor.id, user.id); try { diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts index 842e8e41b..3b72429cf 100644 --- a/src/app/api/user/ai-provider/route.ts +++ b/src/app/api/user/ai-provider/route.ts @@ -1,13 +1,8 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { prisma } from "@/lib/db"; -import { - apiSuccess, - apiError, - getClientIp, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; import { auditLog } from "@/lib/auth/audit"; import { isPublicUrl } from "@/lib/validations/notifications"; import { isLocalAiHostAllowed } from "@/lib/ai/local-host-allowlist"; @@ -170,30 +165,26 @@ export const PATCH = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "user.ai-provider.update" } }); - const { data: rawBody, error } = await safeJson(request, { - maxBytes: 64 * 1024, - }); - if (error) return error; - // Every accepted field states its type in the schema, so a wrongly-typed // value is a named issue rather than a key quietly dropped on the floor. // The multi-issue envelope is safe to return even though this body carries // plaintext credentials: Zod's type and range messages name the received // TYPE, never the received value. See the note in the validations module // before adding a `.refine()` here. - const parsed = aiProviderPatchSchema.safeParse(rawBody); - if (!parsed.success) { - annotate({ - action: { name: "user.ai-provider.update" }, - meta: { - outcome: "validation_failed", - issue_count: parsed.error.issues.length, - }, - }); - return returnAllZodIssues(parsed.error, 422, { - errorCode: "ai_provider.invalid", - }); - } + const parsed = await parseJsonBody(request, aiProviderPatchSchema, { + maxBytes: 64 * 1024, + errorCode: "ai_provider.invalid", + onInvalid: (error) => { + annotate({ + action: { name: "user.ai-provider.update" }, + meta: { + outcome: "validation_failed", + issue_count: error.issues.length, + }, + }); + }, + }); + if (!parsed.ok) return parsed.error; const body = parsed.data; // v1.39.3 — whose configuration this is. Settings saved on an admin account