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);