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