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

@@ -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({