fix(api): name the refused fields on every shape rejection

Fifty-nine 422s raised off a Zod `safeParse` answered with one opaque
sentence and nothing a client could map onto a field, while the other
166 sent `details.issues` — the multi-issue envelope that exists so a
caller with three bad fields fixes all three in one round-trip. Twelve
of the fifty-nine put a dotted machine token in `error` where the
sentence belongs, so `error` was sometimes text to show a person and
sometimes a token that must not be shown, with nothing in the payload
to tell them apart.

Every one of them now answers through `apiValidationError`, which keeps
the existing `error` string and the existing `meta` byte for byte and
adds the issue list beside them. The dotted tokens keep their place in
`error` for the clients that already match on them and are published in
`meta.errorCode` as well, which is the field a machine code belongs in
and the one a new client should read.

A guard reads the route tree per refusal site rather than per file —
per file is what let this grow, since a route that used the helper once
looked compliant to a `grep -l` sweep — and asserts it still finds the
sites it judges, so a matcher that stops matching fails instead of
reporting a clean zero.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 07:19:55 +02:00
1 parent c64708cc37
commit 9ce0bd5027
63 files changed
+912 -140

No files matched your search

@@ -0,0 +1,143 @@
/**
* Every 422 raised by a Zod `safeParse` names the fields it refused.
*
* `returnAllZodIssues` / `apiValidationError` emit `details.issues` — one entry
* per rejected field — so a client with three bad fields fixes all three in one
* round-trip instead of three. Most routes use them. Fifty-nine refusal sites
* did not: they returned the correct 422 with a single opaque sentence, and a
* client could not map the failure onto a field. Twelve of those put a dotted
* machine token in `error` instead of a sentence, so `error` was sometimes text
* to show a person and sometimes a token that must not be shown, with nothing
* in the payload to tell them apart.
*
* The matcher is per-site, not per-file, because per-file is what let this
* grow: a route that used the helper once and a bare `apiError(..., 422)`
* somewhere else in the same file looked compliant to a `grep -l` sweep. So
* 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.
*
* Its limits, stated so nobody reads more into a green run than is there:
* a refusal built somewhere other than the `if (!x.success)` block that decided
* it — a helper called with the error, a status assembled from a variable — is
* invisible here, and so is a route that hands the `ZodError` to shared code
* outside `src/app/api`. It catches the shape this defect actually took, and
* the count assertion below is what keeps it from passing on a matcher that
* silently stopped matching anything.
*/
import { readFileSync, readdirSync } from "node:fs";
import { join, relative, sep } from "node:path";
import { describe, expect, it } from "vitest";
const API_ROOT = join(process.cwd(), "src", "app", "api");
/**
* Routes that answer a shape refusal without the issue list, each with the
* reason it must differ. Empty today: every 422 raised off a `safeParse` under
* `src/app/api` carries `details.issues`. An entry here is a decision, not a
* backlog item — write why the list cannot ship, not when it will.
*/
const ALLOWLIST: Record<string, string> = {};
/** Every `route.ts` under `src/app/api`, tests excluded. */
function routeFiles(dir: string): string[] {
const found: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "__tests__") continue;
found.push(...routeFiles(full));
continue;
}
if (entry.name === "route.ts") found.push(full);
}
return found;
}
/**
* The `{ … }` (or single statement) that follows an `if (…)`, brace-balanced.
*
* A refusal is often one statement with no braces — `if (!parsed.success)
* return apiError("Invalid data", 422);` — so both forms are read.
*/
function refusalBlock(src: string, afterCondition: number): string {
let i = afterCondition;
while (i < src.length && /\s/.test(src[i])) i++;
if (src[i] === "{") {
let depth = 0;
for (let j = i; j < src.length; j++) {
if (src[j] === "{") depth++;
else if (src[j] === "}") {
depth--;
if (depth === 0) return src.slice(i, j + 1);
}
}
return src.slice(i);
}
const end = src.indexOf(";", i);
return src.slice(i, end === -1 ? src.length : end + 1);
}
interface RefusalSite {
route: string;
line: number;
carriesIssues: boolean;
}
/** 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");
// 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>();
for (const m of src.matchAll(
/(?:const|let)\s+([A-Za-z0-9_$]+)\s*=\s*(?:await\s+)?[^;]*?safeParse\s*\(/g,
)) {
parsed.add(m[1]);
}
if (parsed.size === 0) continue;
for (const m of src.matchAll(
/if\s*\(\s*!\s*([A-Za-z0-9_$]+)\.success\s*\)/g,
)) {
if (!parsed.has(m[1])) continue;
const block = refusalBlock(src, (m.index ?? 0) + m[0].length);
if (!/\b422\b/.test(block)) continue;
sites.push({
route: relative(process.cwd(), file).split(sep).join("/"),
line: src.slice(0, m.index).split("\n").length,
carriesIssues: /returnAllZodIssues|apiValidationError/.test(block),
});
}
}
return sites;
}
describe("Zod shape refusals carry the issue list", () => {
const sites = scanRefusalSites();
it("finds the refusal sites it is meant to judge", () => {
// A matcher that stops matching would otherwise report a clean zero it did
// not earn. The tree carried 59 of these when the guard was written; the
// floor is deliberately loose so ordinary route churn does not trip it.
expect(sites.length).toBeGreaterThan(40);
});
it("answers every 422 with details.issues", () => {
const missing = sites
.filter((site) => !site.carriesIssues && !(site.route in ALLOWLIST))
.map((site) => `${site.route}:${site.line}`);
expect(missing).toEqual([]);
});
it("keeps no stale allowlist entries", () => {
const stale = Object.keys(ALLOWLIST).filter(
(route) => !sites.some((site) => site.route === route),
);
expect(stale).toEqual([]);
});
});
@@ -2,10 +2,12 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
@@ -58,7 +60,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = generateSchema.safeParse(body ?? {});
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const existing = await prisma.appSettings.findUnique({
+12 -2
View File
@@ -6,7 +6,13 @@ import {
type AITestOverride,
} from "@/lib/ai/provider";
import { singleUserTurn } from "@/lib/ai/types";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import {
@@ -69,7 +75,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (error) return error;
const parsed = aiTestOverrideSchema.safeParse(data);
if (!parsed.success) {
return apiError("Invalid override payload", 422);
return apiValidationError(
"Invalid override payload",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
override = parsed.data;
}
+11 -2
View File
@@ -37,7 +37,12 @@
*/
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -80,7 +85,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return apiError("identifier required", 422);
return apiValidationError(
"identifier required",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const identifier = parsed.data.identifier;
+11 -2
View File
@@ -3,7 +3,12 @@ import { loginPasswordSchema } from "@/lib/validations/auth";
import { verifyPassword } from "@/lib/auth/password";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
import { apiError, safeJson } from "@/lib/api-response";
import {
apiError,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { NextRequest, NextResponse } from "next/server";
@@ -55,7 +60,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = loginPasswordSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid credentials", 422);
return apiValidationError(
"Invalid credentials",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const { email, password } = parsed.data;
+14 -2
View File
@@ -16,7 +16,11 @@
* row on every turn — there's no caching layer to invalidate.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import {
apiSuccess,
apiValidationError,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import {
@@ -74,7 +78,15 @@ export const PUT = apiHandler(async (req: Request) => {
action: { name: "auth.me.coach-prefs.put.invalid" },
meta: { issues: parsed.error.issues.length },
});
throw new HttpError(422, "coach-prefs.body.invalid_shape");
// The dotted token keeps its place in `error` — clients already match on it
// there — while `meta.errorCode` publishes it in the field a machine code
// belongs in, and `details.issues` names the fields that were refused.
return apiValidationError(
"coach-prefs.body.invalid_shape",
sanitiseZodIssues(parsed.error.issues),
422,
{ errorCode: "coach-prefs.body.invalid_shape" },
);
}
// Persist the canonical defaulted form so the column shape stays
+7 -1
View File
@@ -20,8 +20,10 @@ import {
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -63,7 +65,11 @@ export const POST = apiHandler(async (req: Request) => {
}
const parsed = mfaDisableSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const factor = await verifyMfaFactor(
@@ -25,8 +25,10 @@ import {
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -78,7 +80,11 @@ export const POST = apiHandler(async (req: Request) => {
}
const parsed = totpConfirmSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid code", 422);
return apiValidationError(
"Invalid code",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
// Decrypt is fail-closed — a missing/rotated key throws rather than
@@ -13,8 +13,10 @@ import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -39,7 +41,11 @@ export const PATCH = apiHandler(
const parsed = mfaWebauthnRenameSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const existing = await prisma.webauthnMfaCredential.findUnique({
@@ -11,8 +11,10 @@ import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -34,7 +36,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = mfaWebauthnRegisterVerifySchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const { challengeId, credential, name } = parsed.data;
+15 -2
View File
@@ -17,7 +17,12 @@
* outside that route ever read.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import {
apiSuccess,
apiValidationError,
getClientIp,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma, toJson } from "@/lib/db";
@@ -57,7 +62,15 @@ export const PUT = apiHandler(async (req: Request) => {
action: { name: "auth.me.report-selection.put.invalid" },
meta: { issues: parsed.error.issues.length },
});
throw new HttpError(422, "report-selection.body.invalid_shape");
// The dotted token keeps its place in `error` — clients already match on it
// there — while `meta.errorCode` publishes it in the field a machine code
// belongs in, and `details.issues` names the fields that were refused.
return apiValidationError(
"report-selection.body.invalid_shape",
sanitiseZodIssues(parsed.error.issues),
422,
{ errorCode: "report-selection.body.invalid_shape" },
);
}
const minted = selectionFromRequest(parsed.data);
+15 -2
View File
@@ -19,7 +19,12 @@
* payloads were built from.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import {
apiSuccess,
apiValidationError,
getClientIp,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -57,7 +62,15 @@ export const PUT = apiHandler(async (req: Request) => {
action: { name: "auth.me.source-priority.put.invalid" },
meta: { issues: parsed.error.issues.length },
});
throw new HttpError(422, "source-priority.body.invalid_shape");
// The dotted token keeps its place in `error` — clients already match on it
// there — while `meta.errorCode` publishes it in the field a machine code
// belongs in, and `details.issues` names the fields that were refused.
return apiValidationError(
"source-priority.body.invalid_shape",
sanitiseZodIssues(parsed.error.issues),
422,
{ errorCode: "source-priority.body.invalid_shape" },
);
}
// v1.4.25 W10 reconcile (security M-3): capture the previous shape
+11 -2
View File
@@ -17,7 +17,12 @@
* limited per IP on top of the per-ticket cap.
*/
import { NextRequest } from "next/server";
import { apiError, safeJson } from "@/lib/api-response";
import {
apiError,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -61,7 +66,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = mfaVerifySchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const { mfaTicket, method, code, rememberDevice } = parsed.data;
@@ -13,7 +13,13 @@
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -45,7 +51,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = mfaWebauthnLoginOptionsSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const challenge = await loadActiveChallenge(parsed.data.mfaTicket);
+11 -2
View File
@@ -16,7 +16,12 @@
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { apiError, safeJson } from "@/lib/api-response";
import {
apiError,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -59,7 +64,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = mfaWebauthnLoginVerifySchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const { mfaTicket, challengeId, credential, rememberDevice } = parsed.data;
+11 -2
View File
@@ -20,7 +20,12 @@
* `requireFreshMfa` step-up.
*/
import { NextRequest } from "next/server";
import { apiError, safeJson } from "@/lib/api-response";
import {
apiError,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -83,7 +88,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = nativeHandoffTokenSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const { code, codeVerifier } = parsed.data;
+11 -2
View File
@@ -23,7 +23,12 @@
* `requireFreshMfa` step-up, so an SSO login can never satisfy step-up.
*/
import { NextRequest } from "next/server";
import { apiError, safeJson } from "@/lib/api-response";
import {
apiError,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -89,7 +94,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = oidcNativeTokenSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const { code, codeVerifier } = parsed.data;
+12 -2
View File
@@ -18,7 +18,13 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireBearerAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -50,7 +56,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = stepUpOptionsSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
if (parsed.data.method === "webauthn") {
+7 -1
View File
@@ -33,8 +33,10 @@ import { apiHandler, requireBearerAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -112,7 +114,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = stepUpMintSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const method: StepUpMethod = parsed.data.method;
+14 -4
View File
@@ -18,7 +18,12 @@ import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiError, apiSuccess } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
sanitiseZodIssues,
} from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { cycleCalendarQuerySchema } from "@/lib/validations/cycle";
import {
@@ -55,9 +60,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
to: url.searchParams.get("to") ?? undefined,
});
if (!parsed.success) {
return apiError("Invalid calendar query", 422, {
errorCode: "cycle.calendar.invalid",
});
return apiValidationError(
"Invalid calendar query",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "cycle.calendar.invalid",
},
);
}
const tz = user.timezone ?? DEFAULT_TIMEZONE;
+13 -4
View File
@@ -12,7 +12,11 @@ import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiError, apiSuccess } from "@/lib/api-response";
import {
apiSuccess,
apiValidationError,
sanitiseZodIssues,
} from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { cycleHistoryQuerySchema } from "@/lib/validations/cycle";
import { toMenstrualCycleDTO } from "@/lib/cycle/dto";
@@ -39,9 +43,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
limit: url.searchParams.get("limit") ?? undefined,
});
if (!parsed.success) {
return apiError("Invalid history query", 422, {
errorCode: "cycle.cycles.invalid",
});
return apiValidationError(
"Invalid history query",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "cycle.cycles.invalid",
},
);
}
const limit = parsed.data.limit ?? DEFAULT_LIMIT;
@@ -24,7 +24,13 @@
import { type NextRequest } from "next/server";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -93,9 +99,14 @@ export const POST = apiHandler(
if (jsonError) return jsonError;
const parsed = documentChatRequestSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid document chat request", 422, {
errorCode: "documents.chat.invalid",
});
return apiValidationError(
"Invalid document chat request",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "documents.chat.invalid",
},
);
}
const { conversationId, message, locale: bodyLocale } = parsed.data;
@@ -231,9 +242,14 @@ export const GET = apiHandler(
limit: url.searchParams.get("limit") ?? undefined,
});
if (!parsed.success) {
return apiError("Invalid chat history query", 422, {
errorCode: "documents.chat.invalid",
});
return apiValidationError(
"Invalid chat history query",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "documents.chat.invalid",
},
);
}
// With a conversationId → that one thread's messages (must hold this document).
@@ -30,8 +30,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
@@ -236,9 +238,14 @@ async function handleTextExtract(
const parsed = inboundTextExtractSchema.safeParse(body);
if (!parsed.success) {
await refundDocumentAiSlot(userId);
return apiError("Invalid document text payload", 422, {
errorCode: "documents.inbound.extractFailed",
});
return apiValidationError(
"Invalid document text payload",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "documents.inbound.extractFailed",
},
);
}
const dateKey = buildDateKey();
@@ -26,8 +26,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
@@ -154,9 +156,14 @@ async function handleTextIndex(
const parsed = inboundTextExtractSchema.safeParse(body);
if (!parsed.success) {
await refundDocumentAiSlot(userId);
return apiError("Invalid document text payload", 422, {
errorCode: "documents.inbound.extractFailed",
});
return apiValidationError(
"Invalid document text payload",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "documents.inbound.extractFailed",
},
);
}
const { tokenCount } = await upsertContentIndex({
+10 -3
View File
@@ -13,8 +13,10 @@ import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -143,9 +145,14 @@ export const PATCH = apiHandler(
const parsed = documentUpdateSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid document update", 422, {
errorCode: "documents.inbound.invalidUpdate",
});
return apiValidationError(
"Invalid document update",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "documents.inbound.invalidUpdate",
},
);
}
const existing = await prisma.inboundDocument.findFirst({
@@ -18,8 +18,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
@@ -148,9 +150,14 @@ async function handleTextSuggest(
const parsed = inboundTextExtractSchema.safeParse(body);
if (!parsed.success) {
await refundDocumentAiSlot(userId);
return apiError("Invalid document text payload", 422, {
errorCode: "documents.inbound.extractFailed",
});
return apiValidationError(
"Invalid document text payload",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "documents.inbound.extractFailed",
},
);
}
const dateKey = buildDateKey();
@@ -25,8 +25,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
@@ -297,9 +299,14 @@ async function handleTextSummary(
const parsed = inboundTextExtractSchema.safeParse(body);
if (!parsed.success) {
await refundDocumentAiSlot(userId);
return apiError("Invalid document text payload", 422, {
errorCode: "documents.inbound.extractFailed",
});
return apiValidationError(
"Invalid document text payload",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "documents.inbound.extractFailed",
},
);
}
// `mode=text` over posted OCR text is a pure echo — the text IS the
+23 -7
View File
@@ -33,7 +33,13 @@ import {
requireRecordAuth,
type AuthContext,
} from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
sanitiseZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { hashQueryTokens } from "@/lib/documents/content-index";
@@ -236,9 +242,14 @@ async function processUpload(
encounterIds: rawEncounterIds.length > 0 ? rawEncounterIds : undefined,
});
if (!parsed.success) {
return apiError("Invalid document metadata", 422, {
errorCode: "documents.inbound.invalidMetadata",
});
return apiValidationError(
"Invalid document metadata",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "documents.inbound.invalidMetadata",
},
);
}
let buffer: Buffer;
@@ -518,9 +529,14 @@ export const GET = apiHandler(async (request: Request) => {
kind: kinds.length > 0 ? kinds : undefined,
});
if (!parsed.success) {
return apiError("Invalid list query", 422, {
errorCode: "documents.inbound.invalidQuery",
});
return apiValidationError(
"Invalid list query",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "documents.inbound.invalidQuery",
},
);
}
const {
q,
@@ -19,6 +19,28 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() }));
vi.mock("@/lib/api-response", () => ({
// The parse-refusal pair the routes now answer with: the same envelope
// plus the per-field issue list.
sanitiseZodIssues: (
issues: { path: (string | number)[]; code: string; message: string }[],
) =>
issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
message: issue.message,
})),
apiValidationError: (
error: string,
issues: unknown[],
status: number,
options?: { errorCode?: string },
) => ({
data: null,
error,
status,
details: { issues },
meta: options?.errorCode ? { errorCode: options.errorCode } : undefined,
}),
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
apiError: (error: string, status: number) => ({ data: null, error, status }),
safeJson: async (req: NextRequest) => {
+11 -2
View File
@@ -2,7 +2,12 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { isP2025 } from "@/lib/prisma-errors";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { markDisconnected } from "@/lib/integrations/status";
@@ -47,7 +52,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const result = z.safeParse(fitbitCredentialsSchema, body);
if (!result.success) {
return apiError("Client ID and Client Secret are required", 422);
return apiValidationError(
"Client ID and Client Secret are required",
sanitiseZodIssues(result.error.issues),
422,
);
}
await prisma.user.update({
+11 -2
View File
@@ -2,7 +2,12 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { isP2025 } from "@/lib/prisma-errors";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { markDisconnected } from "@/lib/integrations/status";
@@ -47,7 +52,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const result = z.safeParse(googleHealthCredentialsSchema, body);
if (!result.success) {
return apiError("Client ID and Client Secret are required", 422);
return apiValidationError(
"Client ID and Client Secret are required",
sanitiseZodIssues(result.error.issues),
422,
);
}
// Field-by-field assignment — never spread the parsed object, so no future
+5 -2
View File
@@ -3,10 +3,12 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
@@ -175,8 +177,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (jsonError) return jsonError;
const parsed = importSchema.safeParse(body);
if (!parsed.success) {
return apiError(
return apiValidationError(
parsed.error.issues[0]?.message ?? "Invalid import payload",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
@@ -10,7 +10,13 @@
import { type NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -65,9 +71,14 @@ export const POST = apiHandler(
if (jsonError) return jsonError;
const parsed = coachAttachmentCreateSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid attachment request", 422, {
errorCode: "coach.fenced.invalid",
});
return apiValidationError(
"Invalid attachment request",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "coach.fenced.invalid",
},
);
}
const { documentId } = parsed.data;
+14 -4
View File
@@ -15,7 +15,12 @@
import { type NextRequest } from "next/server";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiError, safeJson } from "@/lib/api-response";
import {
apiError,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -57,9 +62,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (jsonError) return jsonError;
const parsed = fencedChatRequestSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid fenced chat request", 422, {
errorCode: "coach.fenced.invalid",
});
return apiValidationError(
"Invalid fenced chat request",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "coach.fenced.invalid",
},
);
}
const {
conversationId,
@@ -34,8 +34,10 @@ import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -91,7 +93,15 @@ async function handlePost(request: NextRequest, ctx: RouteContext) {
action: { name: "insights.coach.message.feedback" },
meta: { outcome: "validation_failed" },
});
throw new HttpError(422, "feedback.body.invalid");
// The dotted token keeps its place in `error` — clients already match on it
// there — while `meta.errorCode` publishes it 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" },
);
}
const body = parsed.data;
+15 -5
View File
@@ -27,10 +27,15 @@
* 7. Persist user message + assistant message (encrypted) and bump
* the day's CoachUsage token ledger.
*/
import { NextResponse, type NextRequest } from "next/server";
import { type NextRequest } from "next/server";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate, getEvent } from "@/lib/logging/context";
import { redactOptional, redactSecrets } from "@/lib/logging/redact";
import { auditLog } from "@/lib/auth/audit";
@@ -209,9 +214,14 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
action: { name: "insights.coach.invalid" },
meta: { issues: parsed.error.issues.length },
});
return NextResponse.json(
{ data: null, error: "coach.request.invalid" },
{ status: 422 },
// The dotted token keeps its place in `error` — clients already match on it
// there — while `meta.errorCode` publishes it in the field a machine code
// belongs in, and `details.issues` names the fields that were refused.
return apiValidationError(
"coach.request.invalid",
sanitiseZodIssues(parsed.error.issues),
422,
{ errorCode: "coach.request.invalid" },
);
}
const {
+7 -1
View File
@@ -4,8 +4,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -66,7 +68,11 @@ async function handlePost(request: NextRequest) {
action: { name: "insights.recommendation.feedback" },
meta: { outcome: "validation_failed" },
});
return apiError("Invalid feedback payload", 422);
return apiValidationError(
"Invalid feedback payload",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const body = parsed.data;
+11 -2
View File
@@ -2,7 +2,12 @@ import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, HttpError, requireAuth } from "@/lib/api-handler";
import { apiSuccess, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { resolveProviderChain } from "@/lib/ai/provider";
import { getLastWorkingProvider } from "@/lib/ai/provider-runner";
@@ -111,7 +116,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const parsed = chainBodySchema.safeParse(body);
if (!parsed.success) {
throw new HttpError(422, "Invalid provider chain payload");
return apiValidationError(
"Invalid provider chain payload",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
// Defence-in-depth: reject duplicate provider types so a stale-tab
+15 -4
View File
@@ -28,7 +28,13 @@
import { Buffer } from "node:buffer";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import {
@@ -154,9 +160,14 @@ async function handleTextExtract(
const parsed = ocrTextExtractSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid OCR text payload", 422, {
errorCode: "labs.ocr.extractFailed",
});
return apiValidationError(
"Invalid OCR text payload",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "labs.ocr.extractFailed",
},
);
}
// Budget — text mode is a plain text→JSON structuring pass, far cheaper than
+7 -1
View File
@@ -45,8 +45,10 @@ import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import {
@@ -274,7 +276,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
const parsed = batchPayloadSchema.safeParse(rawBody);
if (!parsed.success) {
return apiError(parsed.error.issues[0]?.message ?? "Invalid batch", 422);
return apiValidationError(
parsed.error.issues[0]?.message ?? "Invalid batch",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
// A scoped credential may attribute MANUAL and nothing else. Refused loudly
@@ -38,8 +38,10 @@ import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
@@ -83,7 +85,11 @@ async function deleteByExternalIds(request: NextRequest): Promise<Response> {
const parsed = payloadSchema.safeParse(rawBody);
if (!parsed.success) {
return apiError(parsed.error.issues[0]?.message ?? "Invalid batch", 422);
return apiValidationError(
parsed.error.issues[0]?.message ?? "Invalid batch",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const { externalIds } = parsed.data;
+13 -2
View File
@@ -1,6 +1,12 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { getGlitchtipSettings } from "@/lib/monitoring-settings";
import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip";
@@ -42,7 +48,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
const parsed = clientErrorSchema.safeParse(body);
if (!parsed.success) return apiError("Invalid error data", 422);
if (!parsed.success)
return apiValidationError(
"Invalid error data",
sanitiseZodIssues(parsed.error.issues),
422,
);
const delivery = await sendGlitchtipEvent({
dsn: settings.glitchtipDsn,
@@ -42,6 +42,28 @@ vi.mock("@/lib/rate-limit", () => ({
}));
vi.mock("@/lib/api-response", () => ({
// The parse-refusal pair the routes now answer with: the same envelope
// plus the per-field issue list.
sanitiseZodIssues: (
issues: { path: (string | number)[]; code: string; message: string }[],
) =>
issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
message: issue.message,
})),
apiValidationError: (
error: string,
issues: unknown[],
status: number,
options?: { errorCode?: string },
) => ({
data: null,
error,
status,
details: { issues },
meta: options?.errorCode ? { errorCode: options.errorCode } : undefined,
}),
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
apiError: (
error: string,
+12 -2
View File
@@ -3,7 +3,13 @@ import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
@@ -53,7 +59,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const result = z.safeParse(nightscoutConnectSchema, body);
if (!result.success) {
return apiError("A valid Nightscout URL is required", 422);
return apiValidationError(
"A valid Nightscout URL is required",
sanitiseZodIssues(result.error.issues),
422,
);
}
const { url, token } = result.data;
+12 -2
View File
@@ -2,7 +2,12 @@ import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
sanitiseZodIssues,
} from "@/lib/api-response";
import { notificationPreferenceSchema } from "@/lib/validations/notifications";
import { EVENT_TYPES, CHANNEL_TYPE_LABELS } from "@/lib/notifications/types";
import type { ChannelType } from "@/lib/notifications/types";
@@ -106,7 +111,12 @@ export const PUT = apiHandler(async (request: NextRequest) => {
}
const parsed = notificationPreferenceSchema.safeParse(body);
if (!parsed.success) return apiError("Invalid data", 422);
if (!parsed.success)
return apiValidationError(
"Invalid data",
sanitiseZodIssues(parsed.error.issues),
422,
);
const { channelId, eventType, enabled } = parsed.data;
+12 -2
View File
@@ -2,7 +2,12 @@ import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
sanitiseZodIssues,
} from "@/lib/api-response";
import { CHANNEL_TYPE_LABELS, EVENT_TYPES } from "@/lib/notifications/types";
import type { ChannelType, EventType } from "@/lib/notifications/types";
import { reEnableChannel } from "@/lib/notifications/channel-state";
@@ -156,7 +161,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
const parsed = reEnableChannelSchema.safeParse(body);
if (!parsed.success) return apiError("Invalid data", 422);
if (!parsed.success)
return apiValidationError(
"Invalid data",
sanitiseZodIssues(parsed.error.issues),
422,
);
const channel = await prisma.notificationChannel.findFirst({
where: { id: parsed.data.channelId, userId: user.id },
+14 -4
View File
@@ -1,7 +1,12 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { setOnboardingPendingCookie } from "@/lib/auth/session";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -22,9 +27,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (jsonError) return jsonError;
const result = z.safeParse(onboardingCompleteSchema, body);
if (!result.success) {
return apiError("Invalid input", 422, {
errorCode: "onboarding.complete.invalid",
});
return apiValidationError(
"Invalid input",
sanitiseZodIssues(result.error.issues),
422,
{
errorCode: "onboarding.complete.invalid",
},
);
}
const data: Record<string, unknown> = {
+14 -4
View File
@@ -3,7 +3,12 @@ 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, apiError, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { DISCLAIMER_VERSION } from "@/lib/onboarding/disclaimer";
/**
@@ -38,9 +43,14 @@ export const POST = apiHandler(async (request) => {
const parsed = z.safeParse(bodySchema, rawBody);
if (!parsed.success) {
return apiError("Invalid input", 422, {
errorCode: "onboarding.disclaimer.invalid",
});
return apiValidationError(
"Invalid input",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "onboarding.disclaimer.invalid",
},
);
}
// The server pins the canonical version it stamps — the body version is
+10 -3
View File
@@ -4,8 +4,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { setOnboardingPendingCookie } from "@/lib/auth/session";
@@ -80,9 +82,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "onboarding.step" },
meta: { outcome: "validation_failed" },
});
return apiError("Invalid step payload", 422, {
errorCode: "onboarding.step.invalid",
});
return apiValidationError(
"Invalid step payload",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "onboarding.step.invalid",
},
);
}
const { step, goals } = parsed.data;
+14 -4
View File
@@ -4,7 +4,12 @@ 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, apiError, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { tourProgressSchema } from "@/lib/onboarding/tour-progress";
import { Prisma } from "@/generated/prisma/client";
@@ -56,9 +61,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = z.safeParse(tourBodySchema, body);
if (!parsed.success) {
return apiError("Invalid input", 422, {
errorCode: "onboarding.tour.invalid",
});
return apiValidationError(
"Invalid input",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "onboarding.tour.invalid",
},
);
}
// Build the update field-by-field (no mass assignment). `completed`
@@ -23,6 +23,28 @@ vi.mock("@/lib/oura/credentials", () => ({
}));
vi.mock("@/lib/api-response", () => ({
// The parse-refusal pair the routes now answer with: the same envelope
// plus the per-field issue list.
sanitiseZodIssues: (
issues: { path: (string | number)[]; code: string; message: string }[],
) =>
issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
message: issue.message,
})),
apiValidationError: (
error: string,
issues: unknown[],
status: number,
options?: { errorCode?: string },
) => ({
data: null,
error,
status,
details: { issues },
meta: options?.errorCode ? { errorCode: options.errorCode } : undefined,
}),
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
apiError: (error: string, status: number) => ({ data: null, error, status }),
safeJson: vi.fn(),
+11 -2
View File
@@ -1,6 +1,11 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { markDisconnected } from "@/lib/integrations/status";
@@ -51,7 +56,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const result = z.safeParse(ouraCredentialsSchema, body);
if (!result.success) {
return apiError("Client ID and Client Secret are required", 422);
return apiValidationError(
"Client ID and Client Secret are required",
sanitiseZodIssues(result.error.issues),
422,
);
}
await storeOuraClientCredentials(
@@ -26,6 +26,28 @@ vi.mock("@/lib/polar/credentials", () => ({
clearPolarClientCredentials: clearMock,
}));
vi.mock("@/lib/api-response", () => ({
// The parse-refusal pair the routes now answer with: the same envelope
// plus the per-field issue list.
sanitiseZodIssues: (
issues: { path: (string | number)[]; code: string; message: string }[],
) =>
issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
message: issue.message,
})),
apiValidationError: (
error: string,
issues: unknown[],
status: number,
options?: { errorCode?: string },
) => ({
data: null,
error,
status,
details: { issues },
meta: options?.errorCode ? { errorCode: options.errorCode } : undefined,
}),
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
apiError: (error: string, status: number) => ({ data: null, error, status }),
safeJson: vi.fn(async (req: { _body: unknown }) => ({
+11 -2
View File
@@ -1,7 +1,12 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { markDisconnected } from "@/lib/integrations/status";
import {
@@ -51,7 +56,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const result = z.safeParse(polarCredentialsSchema, body);
if (!result.success) {
return apiError("Client ID and Client Secret are required", 422);
return apiValidationError(
"Client ID and Client Secret are required",
sanitiseZodIssues(result.error.issues),
422,
);
}
await storePolarClientCredentials(
+13 -2
View File
@@ -1,5 +1,11 @@
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import {
emailSettingsSchema,
notificationChannelEnabledSchema,
@@ -102,7 +108,12 @@ export const PUT = apiHandler(async (request: NextRequest) => {
}
const parsed = emailSettingsSchema.safeParse(body);
if (!parsed.success) return apiError("Invalid data", 422);
if (!parsed.success)
return apiValidationError(
"Invalid data",
sanitiseZodIssues(parsed.error.issues),
422,
);
const { recipient, enabled } = parsed.data;
+13 -2
View File
@@ -1,5 +1,11 @@
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import {
notificationChannelEnabledSchema,
ntfySettingsSchema,
@@ -119,7 +125,12 @@ export const PUT = apiHandler(async (request: NextRequest) => {
}
const parsed = ntfySettingsSchema.safeParse(body);
if (!parsed.success) return apiError("Invalid data", 422);
if (!parsed.success)
return apiValidationError(
"Invalid data",
sanitiseZodIssues(parsed.error.issues),
422,
);
const { serverUrl, topic, authToken, enabled } = parsed.data;
+12 -2
View File
@@ -1,5 +1,11 @@
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { decrypt, encrypt } from "@/lib/crypto";
import { deleteTelegramWebhook, setTelegramWebhook } from "@/lib/telegram";
import { telegramSettingsSchema } from "@/lib/validations/telegram";
@@ -171,7 +177,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const result = z.safeParse(telegramSettingsSchema, body);
if (!result.success) {
return apiError("Invalid input", 422);
return apiValidationError(
"Invalid input",
sanitiseZodIssues(result.error.issues),
422,
);
}
const { botToken, chatId, enabled } = result.data;
+13 -2
View File
@@ -1,5 +1,11 @@
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import {
notificationChannelEnabledSchema,
webhookSettingsSchema,
@@ -108,7 +114,12 @@ export const PUT = apiHandler(async (request: NextRequest) => {
}
const parsed = webhookSettingsSchema.safeParse(body);
if (!parsed.success) return apiError("Invalid data", 422);
if (!parsed.success)
return apiValidationError(
"Invalid data",
sanitiseZodIssues(parsed.error.issues),
422,
);
const { url, headerName, headerValue, enabled } = parsed.data;
+11 -2
View File
@@ -1,6 +1,11 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { markDisconnected } from "@/lib/integrations/status";
@@ -52,7 +57,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const result = z.safeParse(stravaCredentialsSchema, body);
if (!result.success) {
return apiError("Client ID and Client Secret are required", 422);
return apiValidationError(
"Client ID and Client Secret are required",
sanitiseZodIssues(result.error.issues),
422,
);
}
await storeStravaClientCredentials(
+11 -2
View File
@@ -50,7 +50,12 @@ import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { readNote } from "@/lib/crypto/note-cipher";
@@ -211,7 +216,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
limit: url.searchParams.get("limit") ?? undefined,
});
if (!parsed.success) {
return apiError("Invalid sync query", 422);
return apiValidationError(
"Invalid sync query",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const limit = parsed.data.limit ?? DEFAULT_LIMIT;
@@ -19,6 +19,28 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() }));
vi.mock("@/lib/api-response", () => ({
// The parse-refusal pair the routes now answer with: the same envelope
// plus the per-field issue list.
sanitiseZodIssues: (
issues: { path: (string | number)[]; code: string; message: string }[],
) =>
issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
message: issue.message,
})),
apiValidationError: (
error: string,
issues: unknown[],
status: number,
options?: { errorCode?: string },
) => ({
data: null,
error,
status,
details: { issues },
meta: options?.errorCode ? { errorCode: options.errorCode } : undefined,
}),
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
apiError: (error: string, status: number) => ({ data: null, error, status }),
safeJson: async (req: NextRequest) => {
+11 -2
View File
@@ -2,7 +2,12 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { isP2025 } from "@/lib/prisma-errors";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { markDisconnected } from "@/lib/integrations/status";
@@ -45,7 +50,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const result = z.safeParse(whoopCredentialsSchema, body);
if (!result.success) {
return apiError("Client ID and Client Secret are required", 422);
return apiValidationError(
"Client ID and Client Secret are required",
sanitiseZodIssues(result.error.issues),
422,
);
}
await prisma.user.update({
+11 -2
View File
@@ -1,6 +1,11 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { isP2025 } from "@/lib/prisma-errors";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
@@ -46,7 +51,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
if (jsonError) return jsonError;
const result = z.safeParse(withingsCredentialsSchema, body);
if (!result.success) {
return apiError("Client ID and Client Secret are required", 422);
return apiValidationError(
"Client ID and Client Secret are required",
sanitiseZodIssues(result.error.issues),
422,
);
}
await prisma.user.update({