mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 }) => ({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user