refactor(api): read the remaining settings and admin route bodies through parseJsonBody

Admin, coach, consent, chart-overlay, device registration, insights
feedback and provider chain, ECG ingest, onboarding, search and the AI
provider settings move to parseJsonBody with the same message, error
code, status and wide-event action they answered with. A refusal whose
annotate meta is computed from the issues, or puts its keys in another
order than the helper would, keeps that annotate verbatim in onInvalid.

The routes that still call safeJson do something between the read and the
schema: an optimistic-concurrency token split off the body, a count cap
answered first, a refunded quota slot, a conditional read, a non-Zod
check, or the body handed to a service.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 11:29:23 +02:00
1 parent af14d0aa7e
commit ca04ef01b3
19 files changed
+203 -411

No files matched your search

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