Merge branch 'feat/v146-server-parse' into feat/v146-server

This commit is contained in:
Marc-André Bombeck committed 2026-10-23 11:30:47 +02:00
commit 7ea247e193
47 files changed
+760 -1113

No files matched your search

+2 -47
View File
@@ -1,21 +1,15 @@
{
"$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,
"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/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,
@@ -26,85 +20,46 @@
"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,
@@ -16,7 +16,10 @@
* every `if (!<result>.success)` block whose identifier came from a `safeParse`
* in that file is read on its own, and only the blocks that answer 422 are
* judged — a 400, a 401 or a 403 raised off a parse result is a different
* refusal and is left alone.
* refusal and is left alone. A route that reads its body through
* `parseJsonBody` has no such block: each call is a site of its own, carrying
* the issue list by construction, and its `message` option is read as the
* refused string by the token sweep below.
*
* The second half of the same audit item is the dotted token in `error`, and
* it is checked by its own sweep at the bottom of this file rather than by the
@@ -94,11 +97,58 @@ interface RefusalSite {
carriesIssues: boolean;
}
/**
* Every `parseJsonBody(…)` call in a file, as its paren-balanced source text
* with the line it starts on. The helper reads, validates and refuses in one
* call (`src/lib/api/body.ts`), so a route that uses it has no
* `if (!x.success)` block for the matcher above to find; its refusal is the
* helper's envelope, which always carries `details.issues`.
*/
function parseJsonBodyCalls(src: string): { text: string; line: number }[] {
const calls: { text: string; line: number }[] = [];
for (const m of src.matchAll(/\bparseJsonBody\s*\(/g)) {
const start = m.index ?? 0;
let depth = 0;
let end = src.length;
for (let j = start + m[0].length - 1; j < src.length; j++) {
if (src[j] === "(") depth++;
else if (src[j] === ")") {
depth--;
if (depth === 0) {
end = j + 1;
break;
}
}
}
calls.push({
text: src.slice(start, end),
line: src.slice(0, start).split("\n").length,
});
}
return calls;
}
/** The status a `parseJsonBody` call refuses with: its `status` option, else 422. */
function parseJsonBodyStatus(call: string): number {
const m = /\bstatus:\s*(\d{3})\b/.exec(call);
return m ? Number(m[1]) : 422;
}
/** Every 422 refusal decided by a `safeParse` result, across the route tree. */
function scanRefusalSites(): RefusalSite[] {
const sites: RefusalSite[] = [];
for (const file of routeFiles(API_ROOT)) {
const src = readFileSync(file, "utf8");
// A `parseJsonBody` refusal is a 422 with the issue list unless the call
// names another status, which is then a different refusal, as below.
for (const call of parseJsonBodyCalls(src)) {
if (parseJsonBodyStatus(call.text) !== 422) continue;
sites.push({
route: relative(process.cwd(), file).split(sep).join("/"),
line: call.line,
carriesIssues: true,
});
}
// Identifiers bound to a parse result. Both call styles are in the tree:
// `schema.safeParse(body)` and the standalone `z.safeParse(schema, body)`.
const parsed = new Set<string>();
@@ -228,12 +278,20 @@ function tokenRefusals(): string[] {
/apiValidationError\(\s*"([^"]+)"/g,
/new HttpError\(\s*422\s*,\s*"([^"]+)"/g,
];
const refused: string[] = [];
for (const shape of shapes) {
for (const m of src.matchAll(shape)) {
if (!DOTTED_TOKEN.test(m[1])) continue;
const key = `${route} ${m[1]}`;
if (!found.includes(key)) found.push(key);
}
for (const m of src.matchAll(shape)) refused.push(m[1]);
}
// `parseJsonBody`'s `message` option is the 422's `error` string.
for (const call of parseJsonBodyCalls(src)) {
if (parseJsonBodyStatus(call.text) !== 422) continue;
const m = /\bmessage:\s*"([^"]+)"/.exec(call.text);
if (m) refused.push(m[1]);
}
for (const text of refused) {
if (!DOTTED_TOKEN.test(text)) continue;
const key = `${route} ${text}`;
if (!found.includes(key)) found.push(key);
}
}
return found;
@@ -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" },
+4 -11
View File
@@ -1,12 +1,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 { annotate } from "@/lib/logging/context";
import {
@@ -31,13 +27,10 @@ export const GET = apiHandler(async () => {
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: rawBody, error } = await safeJson(request, {
const parsed = await parseJsonBody(request, emergencyProfileUpdateSchema, {
maxBytes: 16 * 1024,
});
if (error) return error;
const parsed = emergencyProfileUpdateSchema.safeParse(rawBody);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
if (!parsed.ok) return parsed.error;
const payload = await writeEmergencyProfile(user.id, parsed.data);
await auditLog("anamnesis.emergency.update", {
+4 -12
View File
@@ -1,13 +1,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";
@@ -39,13 +34,10 @@ export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const { data: rawBody, error } = await safeJson(request, {
const parsed = await parseJsonBody(request, factCorrectionSchema, {
maxBytes: 8 * 1024,
});
if (error) return error;
const parsed = factCorrectionSchema.safeParse(rawBody);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
if (!parsed.ok) return parsed.error;
const current = await prisma.healthProfileFactRevision.findFirst({
where: {
+4 -12
View File
@@ -1,13 +1,8 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth, 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";
@@ -41,13 +36,10 @@ export const GET = apiHandler(async () => {
async function postFact(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const { data: rawBody, error } = await safeJson(request, {
const parsed = await parseJsonBody(request, factWriteSchema, {
maxBytes: 8 * 1024,
});
if (error) return error;
const parsed = factWriteSchema.safeParse(rawBody);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
if (!parsed.ok) return parsed.error;
const now = new Date();
const validFrom = parsed.data.validFrom
+22 -28
View File
@@ -5,10 +5,9 @@ import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import {
destroyedDetails,
@@ -122,34 +121,29 @@ export const PUT = apiHandler(
});
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, updateBiomarkerSchema, {
maxBytes: 16 * 1024,
action: "labs.biomarker.update.validation-failed",
countIssues: true,
meta: { biomarkerId: id },
errorCode: "labs.biomarker.update.invalid",
onInvalid: (error) => {
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without
// the stamp a manager's malformed payload would read as the owner's own.
void auditLog("labs.biomarker.update.validation-failed", {
userId: user.id,
details: { issues: auditIssues, biomarkerId: id },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
},
});
if (jsonError) return jsonError;
const parsed = updateBiomarkerSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "labs.biomarker.update.validation-failed" },
meta: { issue_count: parsed.error.issues.length, biomarkerId: id },
});
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a manager's malformed payload would read as the owner's own.
void auditLog("labs.biomarker.update.validation-failed", {
userId: user.id,
details: { issues: auditIssues, biomarkerId: id },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "labs.biomarker.update.invalid",
});
}
if (!parsed.ok) return parsed.error;
const d = parsed.data;
+18 -25
View File
@@ -5,10 +5,9 @@ import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { withIdempotency } from "@/lib/idempotency";
@@ -77,31 +76,25 @@ async function postBiomarker(request: NextRequest) {
// tracks gets the ordinary 409 rather than a second copy.
const { user } = await requireRecordAuth("write", "labs");
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, createBiomarkerSchema, {
maxBytes: 16 * 1024,
action: "labs.biomarker.create.validation-failed",
countIssues: true,
errorCode: "labs.biomarker.create.invalid",
onInvalid: (error) => {
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
// v1.36.x — through `auditLog()`, the only writer that stamps the actor.
void auditLog("labs.biomarker.create.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
},
});
if (jsonError) return jsonError;
const parsed = createBiomarkerSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "labs.biomarker.create.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
// v1.36.x — through `auditLog()`, the only writer that stamps the actor.
void auditLog("labs.biomarker.create.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "labs.biomarker.create.invalid",
});
}
if (!parsed.ok) return parsed.error;
const { name, unit, lowerBound, upperBound, context, panel } = parsed.data;
@@ -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) {
@@ -5,10 +5,9 @@ import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store";
@@ -49,33 +48,29 @@ export const PATCH = apiHandler(
return apiError("Custom metric entry not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, updateCustomMetricEntrySchema, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateCustomMetricEntrySchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "custom-metric.entry.update.validation-failed" },
meta: { issue_count: parsed.error.issues.length, entryId },
});
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "custom-metric.entry.update.validation-failed",
details: JSON.stringify({ issues: auditIssues, entryId }),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
action: "custom-metric.entry.update.validation-failed",
countIssues: true,
meta: { entryId },
onInvalid: (error) => {
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
return returnAllZodIssues(parsed.error, 422);
}
prisma.auditLog
.create({
data: {
userId: user.id,
action: "custom-metric.entry.update.validation-failed",
details: JSON.stringify({ issues: auditIssues, entryId }),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
});
},
});
if (!parsed.ok) return parsed.error;
const d = parsed.data;
@@ -6,9 +6,9 @@ import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store";
@@ -117,29 +117,25 @@ async function postCustomMetricEntry(
return apiError("Custom metric not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, createCustomMetricEntrySchema, {
maxBytes: 16 * 1024,
action: "custom-metric.entry.create.validation-failed",
countIssues: true,
meta: { customMetricId: id },
onInvalid: (error) => {
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
// v1.36.x — through `auditLog()`, the only writer that stamps the actor.
void auditLog("custom-metric.entry.create.validation-failed", {
userId: user.id,
details: { issues: auditIssues, customMetricId: id },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
},
});
if (jsonError) return jsonError;
const parsed = createCustomMetricEntrySchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "custom-metric.entry.create.validation-failed" },
meta: { issue_count: parsed.error.issues.length, customMetricId: id },
});
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
// v1.36.x — through `auditLog()`, the only writer that stamps the actor.
void auditLog("custom-metric.entry.create.validation-failed", {
userId: user.id,
details: { issues: auditIssues, customMetricId: id },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const { value, measuredAt, note } = parsed.data;
+25 -30
View File
@@ -5,10 +5,9 @@ import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
import {
@@ -81,36 +80,32 @@ export const PATCH = apiHandler(
return apiError("Custom metric not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, updateCustomMetricSchema, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateCustomMetricSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "custom-metric.metric.update.validation-failed" },
meta: { issue_count: parsed.error.issues.length, customMetricId: id },
});
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "custom-metric.metric.update.validation-failed",
details: JSON.stringify({
issues: auditIssues,
customMetricId: id,
}),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
action: "custom-metric.metric.update.validation-failed",
countIssues: true,
meta: { customMetricId: id },
onInvalid: (error) => {
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
return returnAllZodIssues(parsed.error, 422);
}
prisma.auditLog
.create({
data: {
userId: user.id,
action: "custom-metric.metric.update.validation-failed",
details: JSON.stringify({
issues: auditIssues,
customMetricId: id,
}),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
});
},
});
if (!parsed.ok) return parsed.error;
const d = parsed.data;
+21 -27
View File
@@ -5,10 +5,9 @@ import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
import {
@@ -63,33 +62,28 @@ export const POST = apiHandler(
async function postCustomMetric(request: NextRequest) {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, createCustomMetricSchema, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = createCustomMetricSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "custom-metric.metric.create.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "custom-metric.metric.create.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
action: "custom-metric.metric.create.validation-failed",
countIssues: true,
onInvalid: (error) => {
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
return returnAllZodIssues(parsed.error, 422);
}
prisma.auditLog
.create({
data: {
userId: user.id,
action: "custom-metric.metric.create.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
});
},
});
if (!parsed.ok) return parsed.error;
const {
name,
+16 -20
View File
@@ -24,8 +24,8 @@ import {
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { withIdempotency } from "@/lib/idempotency";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import {
@@ -99,27 +99,23 @@ async function postDayLog(request: NextRequest): Promise<Response> {
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const { data: rawBody, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, cycleDayLogCreateSchema, {
maxBytes: 64 * 1024,
errorCode: "cycle.day-log.invalid",
onInvalid: (error, rawBody) => {
const shape = unstableExternalIdShape(rawBody);
annotate({
action: { name: "cycle.day-log.validation-failed" },
meta: {
issue_count: error.issues.length,
...(shape
? unstableExternalIdMeta("cycle.day_log.create", [shape])
: {}),
},
});
},
});
if (jsonError) return jsonError;
const parsed = cycleDayLogCreateSchema.safeParse(rawBody);
if (!parsed.success) {
const shape = unstableExternalIdShape(rawBody);
annotate({
action: { name: "cycle.day-log.validation-failed" },
meta: {
issue_count: parsed.error.issues.length,
...(shape
? unstableExternalIdMeta("cycle.day_log.create", [shape])
: {}),
},
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "cycle.day-log.invalid",
});
}
if (!parsed.ok) return parsed.error;
const entry = parsed.data;
const tz = user.timezone ?? DEFAULT_TIMEZONE;
+15 -23
View File
@@ -18,12 +18,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,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { withIdempotency } from "@/lib/idempotency";
import { cyclePeriodSchema } from "@/lib/validations/cycle";
@@ -63,25 +59,21 @@ async function postPeriod(request: NextRequest): Promise<Response> {
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const { data: rawBody, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, cyclePeriodSchema, {
maxBytes: 64 * 1024,
errorCode: "cycle.period.invalid",
onInvalid: (error, rawBody) => {
const shape = unstableExternalIdShape(rawBody);
annotate({
action: { name: "cycle.period.validation-failed" },
meta: {
issue_count: error.issues.length,
...(shape ? unstableExternalIdMeta("cycle.period", [shape]) : {}),
},
});
},
});
if (jsonError) return jsonError;
const parsed = cyclePeriodSchema.safeParse(rawBody);
if (!parsed.success) {
const shape = unstableExternalIdShape(rawBody);
annotate({
action: { name: "cycle.period.validation-failed" },
meta: {
issue_count: parsed.error.issues.length,
...(shape ? unstableExternalIdMeta("cycle.period", [shape]) : {}),
},
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "cycle.period.invalid",
});
}
if (!parsed.ok) return parsed.error;
const { action, date, externalId, loggedAt } = parsed.data;
const tz = user.timezone ?? DEFAULT_TIMEZONE;
@@ -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,
@@ -14,13 +14,8 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
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 { requireIllnessEnabled } from "@/lib/illness/gate";
import { illnessEpisodeResolveSchema } from "@/lib/validations/illness";
import { toIllnessEpisodeDTO } from "@/lib/illness/dto";
@@ -60,17 +55,12 @@ export const PATCH = apiHandler(
});
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, illnessEpisodeResolveSchema, {
maxBytes: 4 * 1024,
input: (rawBody) => rawBody ?? {},
errorCode: "illness.episode.invalid",
});
if (jsonError) return jsonError;
const parsed = illnessEpisodeResolveSchema.safeParse(rawBody ?? {});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "illness.episode.invalid",
});
}
if (!parsed.ok) return parsed.error;
const resolvedAt = parsed.data.resolvedAt
? new Date(parsed.data.resolvedAt)
+35 -38
View File
@@ -6,10 +6,9 @@ import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { hashToken } from "@/lib/auth/hmac";
import {
checkAuthSurfaceRateLimit,
@@ -152,45 +151,43 @@ export const POST = apiHandler(async (request: NextRequest) => {
data: { lastUsedAt: new Date() },
});
const { data: body, error: jsonError } = await safeJson(request, {
// v1.4.43 — external native ingest is a bulk-source; surface
// every Zod issue so a Telegram-bridge or HomeAssistant
// integration sees the full validation shape at once. Audit
// breadcrumb keyed `ingest.medication.validation-failed`.
const parsed = await parseJsonBody(request, externalIntakeSchema, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = externalIntakeSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 — external native ingest is a bulk-source; surface
// every Zod issue so a Telegram-bridge or HomeAssistant
// integration sees the full validation shape at once. Audit
// breadcrumb keyed `ingest.medication.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
const shape = unstableExternalIdShape(body, "idempotencyKey");
annotate({
action: { name: "ingest.medication.validation-failed" },
meta: {
issue_count: issues.length,
...(shape ? unstableExternalIdMeta("medication.ingest", [shape]) : {}),
},
});
// v1.4.49 — strip `message` from the audit-ledger row; external
// ingest carries free-text `medicationName` + opaque
// `idempotencyKey`.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: apiToken.userId,
action: "ingest.medication.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
onInvalid: (error, body) => {
const shape = unstableExternalIdShape(body, "idempotencyKey");
annotate({
action: { name: "ingest.medication.validation-failed" },
meta: {
issue_count: error.issues.length,
...(shape
? unstableExternalIdMeta("medication.ingest", [shape])
: {}),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
// v1.4.49 — strip `message` from the audit-ledger row; external
// ingest carries free-text `medicationName` + opaque
// `idempotencyKey`.
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: apiToken.userId,
action: "ingest.medication.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
},
});
if (!parsed.ok) return parsed.error;
const { medicationName, takenAt, idempotencyKey } = parsed.data;
@@ -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.
+22 -28
View File
@@ -5,10 +5,9 @@ import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { overwriteDetails } from "@/lib/sharing/audit-details";
import { prisma } from "@/lib/db";
@@ -111,34 +110,29 @@ export const PUT = apiHandler(
});
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, updateLabResultSchema, {
maxBytes: 16 * 1024,
action: "labs.update.validation-failed",
countIssues: true,
meta: { labResultId: id },
errorCode: "labs.update.invalid",
onInvalid: (error) => {
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without
// the stamp a manager's malformed payload would read as the owner's own.
void auditLog("labs.update.validation-failed", {
userId: user.id,
details: { issues: auditIssues, labResultId: id },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
},
});
if (jsonError) return jsonError;
const parsed = updateLabResultSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "labs.update.validation-failed" },
meta: { issue_count: parsed.error.issues.length, labResultId: id },
});
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a manager's malformed payload would read as the owner's own.
void auditLog("labs.update.validation-failed", {
userId: user.id,
details: { issues: auditIssues, labResultId: id },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "labs.update.invalid",
});
}
if (!parsed.ok) return parsed.error;
const d = parsed.data;
+21 -31
View File
@@ -19,13 +19,8 @@ import { NextRequest } from "next/server";
import { fireAndForget } from "@/lib/logging/fire-and-forget";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiSuccess, getClientIp, sanitiseZodIssues } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
@@ -159,32 +154,27 @@ async function commitOcrRows(request: NextRequest) {
// OCR_MAX_ROWS rows at every field's longest bound run past 256 KB; 512
// keeps a full report in one body with room to spare.
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, ocrCommitSchema, {
maxBytes: 512 * 1024,
action: "labs.ocr.commit.validation-failed",
countIssues: true,
onInvalid: (error) => {
// Free-text analyte / unit could land in a Zod issue message — strip values.
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "labs.ocr.commit.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {});
},
});
if (jsonError) return jsonError;
const parsed = ocrCommitSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "labs.ocr.commit.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
// Free-text analyte / unit could land in a Zod issue message — strip values.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "labs.ocr.commit.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {});
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const inserted: ReturnType<typeof serialiseLabResult>[] = [];
const skipped: OcrSkippedRowDto[] = [];
+22 -28
View File
@@ -7,9 +7,9 @@ import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
@@ -157,35 +157,29 @@ async function postLabResult(request: NextRequest) {
// that leaves the owner with a usable catalogue afterwards.
const { user } = await requireRecordAuth("write", "labs");
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, createLabResultSchema, {
maxBytes: 16 * 1024,
action: "labs.create.validation-failed",
countIssues: true,
errorCode: "labs.create.invalid",
onInvalid: (error) => {
// Free-text `analyte` / `unit` / `note` could land verbatim in a Zod
// issue message — strip values from the audit-ledger breadcrumb.
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
// v1.36.x — through `auditLog()`, the only writer that stamps
// `actorUserId`. A bare `prisma.auditLog.create` files a delegate's
// malformed payload as if the owner had typed it.
void auditLog("labs.create.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
},
});
if (jsonError) return jsonError;
const parsed = createLabResultSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "labs.create.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
// Free-text `analyte` / `unit` / `note` could land verbatim in a Zod
// issue message — strip values from the audit-ledger breadcrumb.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
// v1.36.x — through `auditLog()`, the only writer that stamps
// `actorUserId`. A bare `prisma.auditLog.create` files a delegate's
// malformed payload as if the owner had typed it.
void auditLog("labs.create.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "labs.create.invalid",
});
}
if (!parsed.ok) return parsed.error;
const {
biomarkerId,
+27 -29
View File
@@ -21,10 +21,9 @@ import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { updateMeasurementReminderSchema } from "@/lib/validations/measurement-reminders";
import {
@@ -92,33 +91,32 @@ export const PATCH = apiHandler(
return apiError("Measurement reminder not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateMeasurementReminderSchema.safeParse(body);
if (!parsed.success) {
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "measurement-reminders.update.validation-failed" },
meta: { issue_count: issues.length, reminderId: id },
});
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a manager's malformed payload would read as the owner's own.
void auditLog("measurement-reminders.update.validation-failed", {
userId: user.id,
details: { issues: auditIssues, reminderId: id },
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const parsed = await parseJsonBody(
request,
updateMeasurementReminderSchema,
{
maxBytes: 16 * 1024,
action: "measurement-reminders.update.validation-failed",
countIssues: true,
meta: { reminderId: id },
onInvalid: (error) => {
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without
// the stamp a manager's malformed payload would read as the owner's own.
void auditLog("measurement-reminders.update.validation-failed", {
userId: user.id,
details: { issues: auditIssues, reminderId: id },
}).catch(() => {
/* swallow — 422 response is the contract */
});
},
},
);
if (!parsed.ok) return parsed.error;
const data = parsed.data;
@@ -18,13 +18,8 @@ import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
returnAllZodIssues,
} 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 { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
import { snoozeMeasurementReminderSchema } from "@/lib/validations/measurement-reminders";
@@ -43,12 +38,11 @@ export const POST = apiHandler(
const { user } = await requireRecordAuth("manage", "measurements");
const { id } = await params;
const body = await safeJson(request);
if (body.error) return body.error;
const parsed = snoozeMeasurementReminderSchema.safeParse(body.data);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
const parsed = await parseJsonBody(
request,
snoozeMeasurementReminderSchema,
);
if (!parsed.ok) return parsed.error;
// An appointment reminder is not addressable here — see the sibling
// routes; snoozing one would move a date the visit record still owns.
+21 -32
View File
@@ -9,13 +9,8 @@ import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiSuccess, getClientIp, sanitiseZodIssues } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { withIdempotency } from "@/lib/idempotency";
import { createMeasurementReminderSchema } from "@/lib/validations/measurement-reminders";
@@ -57,33 +52,27 @@ async function postReminder(request: NextRequest): Promise<Response> {
// row is listed, editable and removable by the owner.
const { user } = await requireRecordAuth("manage", "measurements");
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, createMeasurementReminderSchema, {
maxBytes: 16 * 1024,
action: "measurement-reminders.create.validation-failed",
countIssues: true,
onInvalid: (error) => {
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a manager's malformed payload would read as the owner's own.
void auditLog("measurement-reminders.create.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
},
});
if (jsonError) return jsonError;
const parsed = createMeasurementReminderSchema.safeParse(body);
if (!parsed.success) {
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "measurement-reminders.create.validation-failed" },
meta: { issue_count: issues.length },
});
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a manager's malformed payload would read as the owner's own.
void auditLog("measurement-reminders.create.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const data = parsed.data;
+28 -33
View File
@@ -8,9 +8,9 @@ import {
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import {
updateMeasurementSchema,
USER_CORRECTABLE_MEASUREMENT_SOURCES,
@@ -75,40 +75,35 @@ export const PUT = apiHandler(
return apiError("Measurement not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request, {
// v1.4.43 — measurement edit hot path; multi-issue 422 +
// audit breadcrumb keyed `measurements.update.validation-failed`.
const parsed = await parseJsonBody(request, updateMeasurementSchema, {
maxBytes: 64 * 1024,
action: "measurements.update.validation-failed",
countIssues: true,
meta: { measurement_id: id },
onInvalid: (error) => {
// v1.4.49 — strip `message` from the audit-ledger row; the
// update schema carries free-text `notes`.
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without
// the stamp a manager's malformed payload would read as the owner's own.
void auditLog("measurements.update.validation-failed", {
userId: user.id,
details: {
issues: auditIssues,
measurementId: id,
},
}).catch(() => {
/* swallow — 422 response is the contract */
});
},
});
if (jsonError) return jsonError;
const parsed = updateMeasurementSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 — measurement edit hot path; multi-issue 422 +
// audit breadcrumb keyed `measurements.update.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "measurements.update.validation-failed" },
meta: { issue_count: issues.length, measurement_id: id },
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// update schema carries free-text `notes`.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a manager's malformed payload would read as the owner's own.
void auditLog("measurements.update.validation-failed", {
userId: user.id,
details: {
issues: auditIssues,
measurementId: id,
},
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const data = parsed.data;
@@ -6,10 +6,9 @@ import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import {
type MedicationImportPayload,
@@ -121,55 +120,48 @@ export const POST = apiHandler(
return apiError("Too many import submissions, try again later", 429);
}
const { data: body, error: jsonError } = await safeJson(request, {
// v1.4.43 — CSV import; preserve the `Invalid format:` prefix
// semantics for the existing client-side branch by setting the
// `errorCode: "medication.intake.import.invalid_format"` meta.
// The client UI can now branch on `meta.errorCode` while every
// Zod issue is also surfaced under `details.issues`. Bulk-import
// path → audit breadcrumb keyed
// `medications.intake.import.validation-failed`.
const parsed = await parseJsonBody(request, validatedImportSchema, {
maxBytes: 1024 * 1024,
// Support both direct arrays and object-with-array payloads
input: (body) =>
Array.isArray(body)
? body
: typeof body === "object" && body !== null
? Object.values(body).find((value) => Array.isArray(value))
: null,
action: "medications.intake.import.validation-failed",
countIssues: true,
meta: { medication_id: id },
errorCode: "medication.intake.import.invalid_format",
onInvalid: (error) => {
// v1.4.49 — strip `message` from the audit-ledger row; CSV
// imports carry caller-provided strings that Zod may echo.
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without
// the stamp a manager's malformed payload would read as the owner's own.
void auditLog("medications.intake.import.validation-failed", {
userId: user.id,
details: {
issues: auditIssues,
medicationId: id,
},
}).catch(() => {
/* swallow — 422 response is the contract */
});
},
});
if (jsonError) return jsonError;
// Support both direct arrays and object-with-array payloads
const payload = Array.isArray(body)
? body
: typeof body === "object" && body !== null
? Object.values(body).find((value) => Array.isArray(value))
: null;
const parsed = validatedImportSchema.safeParse(payload);
if (!parsed.success) {
// v1.4.43 — CSV import; preserve the `Invalid format:` prefix
// semantics for the existing client-side branch by setting the
// `errorCode: "medication.intake.import.invalid_format"` meta.
// The client UI can now branch on `meta.errorCode` while every
// Zod issue is also surfaced under `details.issues`. Bulk-import
// path → audit breadcrumb keyed
// `medications.intake.import.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "medications.intake.import.validation-failed" },
meta: { issue_count: issues.length, medication_id: id },
});
// v1.4.49 — strip `message` from the audit-ledger row; CSV
// imports carry caller-provided strings that Zod may echo.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a manager's malformed payload would read as the owner's own.
void auditLog("medications.intake.import.validation-failed", {
userId: user.id,
details: {
issues: auditIssues,
medicationId: id,
},
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "medication.intake.import.invalid_format",
});
}
if (!parsed.ok) return parsed.error;
const entries = parsed.data;
const normalized: MedicationImportPayload = {
@@ -32,13 +32,8 @@ import {
destroyedDetails,
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 { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { scheduleRevisionUpdateSchema } from "@/lib/validations/schedule-revision";
import {
@@ -68,15 +63,11 @@ export const PATCH = apiHandler(
return apiError("Medication not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, scheduleRevisionUpdateSchema, {
maxBytes: 16 * 1024,
input: (body) => body ?? {},
});
if (jsonError) return jsonError;
const parsed = scheduleRevisionUpdateSchema.safeParse(body ?? {});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const validFrom = new Date(parsed.data.validFrom);
const validUntil = new Date(parsed.data.validUntil);
@@ -27,13 +27,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 { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { scheduleRevisionCreateSchema } from "@/lib/validations/schedule-revision";
import {
@@ -151,15 +146,11 @@ export const POST = apiHandler(
return apiError("Medication not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, scheduleRevisionCreateSchema, {
maxBytes: 16 * 1024,
input: (body) => body ?? {},
});
if (jsonError) return jsonError;
const parsed = scheduleRevisionCreateSchema.safeParse(body ?? {});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const validFrom = new Date(parsed.data.validFrom);
const validUntil = new Date(parsed.data.validUntil);
+10 -16
View File
@@ -55,12 +55,8 @@ import type { 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 { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { assertAiEgress } from "@/lib/ai/capabilities/egress";
@@ -164,18 +160,16 @@ async function handleExtract(request: NextRequest): Promise<Response> {
const userId = auth.user.id;
// Body parse + Zod validation.
const { data: body, error: jsonError } = await safeJson(request, {
const validated = await parseJsonBody(request, requestSchema, {
maxBytes: 16 * 1024,
onInvalid: (error) => {
annotate({
action: { name: "medications.extract.invalid" },
meta: { issues: error.issues.length },
});
},
});
if (jsonError) return jsonError;
const validated = requestSchema.safeParse(body);
if (!validated.success) {
annotate({
action: { name: "medications.extract.invalid" },
meta: { issues: validated.error.issues.length },
});
return returnAllZodIssues(validated.error);
}
if (!validated.ok) return validated.error;
const { text, locale, today: todayOverride } = validated.data;
+16 -21
View File
@@ -24,9 +24,9 @@ import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import { auditLog } from "@/lib/auth/audit";
@@ -239,28 +239,23 @@ async function postAssessment(request: NextRequest): Promise<Response> {
}
// Bounded body read BEFORE validation (the item answers are small; cap so an
// unbounded body can never reach `request.json()`). Destructure the
// `{ data, error }` envelope — passing the wrapper to `safeParse` would never
// match the schema, which is what left this route dead-on-arrival.
const { data: body, error: jsonError } = await safeJson(request, {
// unbounded body can never reach `request.json()`).
const parsed = await parseJsonBody(request, createAssessmentSchema, {
maxBytes: 8 * 1024,
onInvalid: (error, body) => {
const shape = unstableExternalIdShape(body);
annotate({
action: { name: "mental-health.create.validation-failed" },
meta: {
issue_count: error.issues.length,
...(shape
? unstableExternalIdMeta("mental_health.assessment.create", [shape])
: {}),
},
});
},
});
if (jsonError) return jsonError;
const parsed = createAssessmentSchema.safeParse(body);
if (!parsed.success) {
const shape = unstableExternalIdShape(body);
annotate({
action: { name: "mental-health.create.validation-failed" },
meta: {
issue_count: sanitiseZodIssues(parsed.error.issues).length,
...(shape
? unstableExternalIdMeta("mental_health.assessment.create", [shape])
: {}),
},
});
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const {
instrument,
+26 -33
View File
@@ -5,10 +5,9 @@ import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { updateMoodEntrySchema, getScoreForMood } from "@/lib/validations/mood";
import { NextRequest } from "next/server";
import { Prisma } from "@/generated/prisma/client";
@@ -88,39 +87,33 @@ export const PUT = apiHandler(
});
}
const { data: body, error: jsonError } = await safeJson(request, {
// v1.4.43 — mood edit hot path; multi-issue 422 + audit
// breadcrumb keyed `mood-entries.update.validation-failed`.
const parsed = await parseJsonBody(request, updateMoodEntrySchema, {
maxBytes: 64 * 1024,
action: "mood-entries.update.validation-failed",
countIssues: true,
meta: { moodEntryId: id },
errorCode: "mood.update.invalid",
onInvalid: (error) => {
// v1.4.49 — strip `message` from the audit-ledger row; mood
// update carries free-text `note` + `tags`.
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without
// the stamp a manager's malformed payload would read as the owner's own.
void auditLog("mood-entries.update.validation-failed", {
userId: user.id,
details: { issues: auditIssues, moodEntryId: id },
}).catch(() => {
/* swallow — 422 response is the contract */
});
},
});
if (jsonError) return jsonError;
const parsed = updateMoodEntrySchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 — mood edit hot path; multi-issue 422 + audit
// breadcrumb keyed `mood-entries.update.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "mood-entries.update.validation-failed" },
meta: { issue_count: issues.length, moodEntryId: id },
});
// v1.4.49 — strip `message` from the audit-ledger row; mood
// update carries free-text `note` + `tags`.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a manager's malformed payload would read as the owner's own.
void auditLog("mood-entries.update.validation-failed", {
userId: user.id,
details: { issues: auditIssues, moodEntryId: id },
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "mood.update.invalid",
});
}
if (!parsed.ok) return parsed.error;
const data = parsed.data;
+32 -37
View File
@@ -5,9 +5,9 @@ import {
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import {
createMoodEntrySchema,
listMoodEntriesSchema,
@@ -108,44 +108,39 @@ async function postMoodEntry(request: NextRequest) {
// the `externalId` refusal below.
const { user, actor } = await requireRecordAuth("manage", "mind");
const { data: body, error: jsonError } = await safeJson(request, {
// v1.4.43 — iOS mood log hot path; multi-issue 422 + audit
// breadcrumb keyed `mood-entries.create.validation-failed`.
const parsed = await parseJsonBody(request, createMoodEntrySchema, {
maxBytes: 64 * 1024,
errorCode: "mood.create.invalid",
onInvalid: (error, body) => {
const shape = unstableExternalIdShape(body);
annotate({
action: { name: "mood-entries.create.validation-failed" },
meta: {
issue_count: error.issues.length,
...(shape ? unstableExternalIdMeta("mood.create", [shape]) : {}),
},
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// mood-create schema's free-text `note` + `tags` could land in a
// Zod issue message verbatim.
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a manager's malformed payload would read as the owner's own.
void auditLog("mood-entries.create.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
},
});
if (jsonError) return jsonError;
const parsed = createMoodEntrySchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 — iOS mood log hot path; multi-issue 422 + audit
// breadcrumb keyed `mood-entries.create.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
const shape = unstableExternalIdShape(body);
annotate({
action: { name: "mood-entries.create.validation-failed" },
meta: {
issue_count: issues.length,
...(shape ? unstableExternalIdMeta("mood.create", [shape]) : {}),
},
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// mood-create schema's free-text `note` + `tags` could land in a
// Zod issue message verbatim.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a manager's malformed payload would read as the owner's own.
void auditLog("mood-entries.create.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "mood.create.invalid",
});
}
if (!parsed.ok) return parsed.error;
const {
mood,
+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