From 9ce0bd5027b47abbcb40e35f4150dfcf1253627e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 07:19:55 +0200 Subject: [PATCH 01/56] fix(api): name the refused fields on every shape rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../zod-refusal-issue-list-guard.test.ts | 143 ++++++++++++++++++ .../settings/web-push-vapid/generate/route.ts | 10 +- src/app/api/ai/test/route.ts | 14 +- src/app/api/auth/check-user/route.ts | 13 +- src/app/api/auth/login/route.ts | 13 +- src/app/api/auth/me/coach-prefs/route.ts | 16 +- src/app/api/auth/me/mfa/disable/route.ts | 8 +- src/app/api/auth/me/mfa/totp/confirm/route.ts | 8 +- .../api/auth/me/mfa/webauthn/[id]/route.ts | 8 +- .../me/mfa/webauthn/register/verify/route.ts | 8 +- src/app/api/auth/me/report-selection/route.ts | 17 ++- src/app/api/auth/me/source-priority/route.ts | 17 ++- src/app/api/auth/mfa/verify/route.ts | 13 +- .../auth/mfa/webauthn/verify/options/route.ts | 14 +- src/app/api/auth/mfa/webauthn/verify/route.ts | 13 +- src/app/api/auth/native/token/route.ts | 13 +- src/app/api/auth/oidc/native/token/route.ts | 13 +- src/app/api/auth/step-up/options/route.ts | 14 +- src/app/api/auth/step-up/route.ts | 8 +- src/app/api/cycle/calendar/route.ts | 18 ++- src/app/api/cycle/cycles/route.ts | 17 ++- .../api/documents/inbound/[id]/chat/route.ts | 30 +++- .../documents/inbound/[id]/extract/route.ts | 13 +- .../api/documents/inbound/[id]/index/route.ts | 13 +- src/app/api/documents/inbound/[id]/route.ts | 13 +- .../documents/inbound/[id]/suggest/route.ts | 13 +- .../documents/inbound/[id]/summary/route.ts | 13 +- src/app/api/documents/inbound/route.ts | 30 +++- .../credentials/__tests__/route.test.ts | 22 +++ src/app/api/fitbit/credentials/route.ts | 13 +- .../api/google-health/credentials/route.ts | 13 +- src/app/api/import/route.ts | 7 +- .../insights/chat/[id]/attachments/route.ts | 19 ++- src/app/api/insights/chat/fenced/route.ts | 18 ++- .../chat/messages/[id]/feedback/route.ts | 12 +- src/app/api/insights/chat/route.ts | 20 ++- src/app/api/insights/feedback/route.ts | 8 +- src/app/api/insights/provider-chain/route.ts | 13 +- src/app/api/labs/ocr/extract/route.ts | 19 ++- src/app/api/measurements/batch/route.ts | 8 +- .../api/measurements/by-external-ids/route.ts | 8 +- src/app/api/monitoring/glitchtip/route.ts | 15 +- .../connect/__tests__/route.test.ts | 22 +++ src/app/api/nightscout/connect/route.ts | 14 +- .../api/notifications/preferences/route.ts | 14 +- src/app/api/notifications/status/route.ts | 14 +- src/app/api/onboarding/complete/route.ts | 18 ++- src/app/api/onboarding/disclaimer/route.ts | 18 ++- src/app/api/onboarding/step/route.ts | 13 +- src/app/api/onboarding/tour/route.ts | 18 ++- .../oura/credentials/__tests__/route.test.ts | 22 +++ src/app/api/oura/credentials/route.ts | 13 +- .../polar/credentials/__tests__/route.test.ts | 22 +++ src/app/api/polar/credentials/route.ts | 13 +- src/app/api/settings/email/route.ts | 15 +- src/app/api/settings/ntfy/route.ts | 15 +- src/app/api/settings/telegram/route.ts | 14 +- src/app/api/settings/webhook/route.ts | 15 +- src/app/api/strava/credentials/route.ts | 13 +- src/app/api/sync/changes/route.ts | 13 +- .../whoop/credentials/__tests__/route.test.ts | 22 +++ src/app/api/whoop/credentials/route.ts | 13 +- src/app/api/withings/credentials/route.ts | 13 +- 63 files changed, 912 insertions(+), 140 deletions(-) create mode 100644 src/__tests__/zod-refusal-issue-list-guard.test.ts diff --git a/src/__tests__/zod-refusal-issue-list-guard.test.ts b/src/__tests__/zod-refusal-issue-list-guard.test.ts new file mode 100644 index 000000000..00031d521 --- /dev/null +++ b/src/__tests__/zod-refusal-issue-list-guard.test.ts @@ -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 (!.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 = {}; + +/** 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(); + 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([]); + }); +}); diff --git a/src/app/api/admin/settings/web-push-vapid/generate/route.ts b/src/app/api/admin/settings/web-push-vapid/generate/route.ts index db647d982..27ce847d4 100644 --- a/src/app/api/admin/settings/web-push-vapid/generate/route.ts +++ b/src/app/api/admin/settings/web-push-vapid/generate/route.ts @@ -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({ diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index 6c3abfaae..56440ce29 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -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; } diff --git a/src/app/api/auth/check-user/route.ts b/src/app/api/auth/check-user/route.ts index cae5d1db0..1aaf77170 100644 --- a/src/app/api/auth/check-user/route.ts +++ b/src/app/api/auth/check-user/route.ts @@ -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; diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 6d48a15b5..eb3272f8f 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -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; diff --git a/src/app/api/auth/me/coach-prefs/route.ts b/src/app/api/auth/me/coach-prefs/route.ts index d76c3fdb2..2c0c3d26d 100644 --- a/src/app/api/auth/me/coach-prefs/route.ts +++ b/src/app/api/auth/me/coach-prefs/route.ts @@ -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 diff --git a/src/app/api/auth/me/mfa/disable/route.ts b/src/app/api/auth/me/mfa/disable/route.ts index 8e9718220..d1ec12e0f 100644 --- a/src/app/api/auth/me/mfa/disable/route.ts +++ b/src/app/api/auth/me/mfa/disable/route.ts @@ -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( diff --git a/src/app/api/auth/me/mfa/totp/confirm/route.ts b/src/app/api/auth/me/mfa/totp/confirm/route.ts index 1251829d0..2aac38635 100644 --- a/src/app/api/auth/me/mfa/totp/confirm/route.ts +++ b/src/app/api/auth/me/mfa/totp/confirm/route.ts @@ -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 diff --git a/src/app/api/auth/me/mfa/webauthn/[id]/route.ts b/src/app/api/auth/me/mfa/webauthn/[id]/route.ts index 61af3c671..cff4259d6 100644 --- a/src/app/api/auth/me/mfa/webauthn/[id]/route.ts +++ b/src/app/api/auth/me/mfa/webauthn/[id]/route.ts @@ -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({ diff --git a/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts index 371e91174..e96388a1f 100644 --- a/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts +++ b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts @@ -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; diff --git a/src/app/api/auth/me/report-selection/route.ts b/src/app/api/auth/me/report-selection/route.ts index dd9c4b6a9..621c3ac78 100644 --- a/src/app/api/auth/me/report-selection/route.ts +++ b/src/app/api/auth/me/report-selection/route.ts @@ -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); diff --git a/src/app/api/auth/me/source-priority/route.ts b/src/app/api/auth/me/source-priority/route.ts index 2deba909d..264de6c7e 100644 --- a/src/app/api/auth/me/source-priority/route.ts +++ b/src/app/api/auth/me/source-priority/route.ts @@ -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 diff --git a/src/app/api/auth/mfa/verify/route.ts b/src/app/api/auth/mfa/verify/route.ts index 67a4028d9..cdd7b03af 100644 --- a/src/app/api/auth/mfa/verify/route.ts +++ b/src/app/api/auth/mfa/verify/route.ts @@ -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; diff --git a/src/app/api/auth/mfa/webauthn/verify/options/route.ts b/src/app/api/auth/mfa/webauthn/verify/options/route.ts index aeff3e827..36992dd24 100644 --- a/src/app/api/auth/mfa/webauthn/verify/options/route.ts +++ b/src/app/api/auth/mfa/webauthn/verify/options/route.ts @@ -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); diff --git a/src/app/api/auth/mfa/webauthn/verify/route.ts b/src/app/api/auth/mfa/webauthn/verify/route.ts index 3ad7c0b57..00df78104 100644 --- a/src/app/api/auth/mfa/webauthn/verify/route.ts +++ b/src/app/api/auth/mfa/webauthn/verify/route.ts @@ -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; diff --git a/src/app/api/auth/native/token/route.ts b/src/app/api/auth/native/token/route.ts index 5055a2024..0d74291b3 100644 --- a/src/app/api/auth/native/token/route.ts +++ b/src/app/api/auth/native/token/route.ts @@ -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; diff --git a/src/app/api/auth/oidc/native/token/route.ts b/src/app/api/auth/oidc/native/token/route.ts index d0bd4674e..bc28bed5e 100644 --- a/src/app/api/auth/oidc/native/token/route.ts +++ b/src/app/api/auth/oidc/native/token/route.ts @@ -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; diff --git a/src/app/api/auth/step-up/options/route.ts b/src/app/api/auth/step-up/options/route.ts index 30392af0c..6ec2f67ac 100644 --- a/src/app/api/auth/step-up/options/route.ts +++ b/src/app/api/auth/step-up/options/route.ts @@ -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") { diff --git a/src/app/api/auth/step-up/route.ts b/src/app/api/auth/step-up/route.ts index 7744cc320..2514d777a 100644 --- a/src/app/api/auth/step-up/route.ts +++ b/src/app/api/auth/step-up/route.ts @@ -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; diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts index 2c69eb869..9906a35c5 100644 --- a/src/app/api/cycle/calendar/route.ts +++ b/src/app/api/cycle/calendar/route.ts @@ -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; diff --git a/src/app/api/cycle/cycles/route.ts b/src/app/api/cycle/cycles/route.ts index 6e1c65fb3..0e4a90692 100644 --- a/src/app/api/cycle/cycles/route.ts +++ b/src/app/api/cycle/cycles/route.ts @@ -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; diff --git a/src/app/api/documents/inbound/[id]/chat/route.ts b/src/app/api/documents/inbound/[id]/chat/route.ts index 4a001abad..b0559de97 100644 --- a/src/app/api/documents/inbound/[id]/chat/route.ts +++ b/src/app/api/documents/inbound/[id]/chat/route.ts @@ -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). diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts index 93de5f7e9..757c012a7 100644 --- a/src/app/api/documents/inbound/[id]/extract/route.ts +++ b/src/app/api/documents/inbound/[id]/extract/route.ts @@ -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(); diff --git a/src/app/api/documents/inbound/[id]/index/route.ts b/src/app/api/documents/inbound/[id]/index/route.ts index d64166fe1..9a3cd6756 100644 --- a/src/app/api/documents/inbound/[id]/index/route.ts +++ b/src/app/api/documents/inbound/[id]/index/route.ts @@ -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({ diff --git a/src/app/api/documents/inbound/[id]/route.ts b/src/app/api/documents/inbound/[id]/route.ts index 22fef5f43..ee87db643 100644 --- a/src/app/api/documents/inbound/[id]/route.ts +++ b/src/app/api/documents/inbound/[id]/route.ts @@ -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({ diff --git a/src/app/api/documents/inbound/[id]/suggest/route.ts b/src/app/api/documents/inbound/[id]/suggest/route.ts index 94f435641..b7feb1f51 100644 --- a/src/app/api/documents/inbound/[id]/suggest/route.ts +++ b/src/app/api/documents/inbound/[id]/suggest/route.ts @@ -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(); diff --git a/src/app/api/documents/inbound/[id]/summary/route.ts b/src/app/api/documents/inbound/[id]/summary/route.ts index 55fbea7ec..186e842d7 100644 --- a/src/app/api/documents/inbound/[id]/summary/route.ts +++ b/src/app/api/documents/inbound/[id]/summary/route.ts @@ -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 diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts index f4ebcbe0a..15e79f51b 100644 --- a/src/app/api/documents/inbound/route.ts +++ b/src/app/api/documents/inbound/route.ts @@ -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, diff --git a/src/app/api/fitbit/credentials/__tests__/route.test.ts b/src/app/api/fitbit/credentials/__tests__/route.test.ts index bb694f3ed..e8a5f6444 100644 --- a/src/app/api/fitbit/credentials/__tests__/route.test.ts +++ b/src/app/api/fitbit/credentials/__tests__/route.test.ts @@ -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) => { diff --git a/src/app/api/fitbit/credentials/route.ts b/src/app/api/fitbit/credentials/route.ts index bef303a15..d313e401d 100644 --- a/src/app/api/fitbit/credentials/route.ts +++ b/src/app/api/fitbit/credentials/route.ts @@ -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({ diff --git a/src/app/api/google-health/credentials/route.ts b/src/app/api/google-health/credentials/route.ts index d7edaa328..f8f2a271d 100644 --- a/src/app/api/google-health/credentials/route.ts +++ b/src/app/api/google-health/credentials/route.ts @@ -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 diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index e81f51a54..02bad9389 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -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, ); } diff --git a/src/app/api/insights/chat/[id]/attachments/route.ts b/src/app/api/insights/chat/[id]/attachments/route.ts index 2b1dcd488..a6c66fb75 100644 --- a/src/app/api/insights/chat/[id]/attachments/route.ts +++ b/src/app/api/insights/chat/[id]/attachments/route.ts @@ -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; diff --git a/src/app/api/insights/chat/fenced/route.ts b/src/app/api/insights/chat/fenced/route.ts index 338c938c1..1c7da325a 100644 --- a/src/app/api/insights/chat/fenced/route.ts +++ b/src/app/api/insights/chat/fenced/route.ts @@ -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, diff --git a/src/app/api/insights/chat/messages/[id]/feedback/route.ts b/src/app/api/insights/chat/messages/[id]/feedback/route.ts index 973f9a264..668a06599 100644 --- a/src/app/api/insights/chat/messages/[id]/feedback/route.ts +++ b/src/app/api/insights/chat/messages/[id]/feedback/route.ts @@ -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; diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index a5c0e3191..e14cdff7e 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -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 { 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 { diff --git a/src/app/api/insights/feedback/route.ts b/src/app/api/insights/feedback/route.ts index a8b30b5e7..6d1e22b36 100644 --- a/src/app/api/insights/feedback/route.ts +++ b/src/app/api/insights/feedback/route.ts @@ -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; diff --git a/src/app/api/insights/provider-chain/route.ts b/src/app/api/insights/provider-chain/route.ts index 82216c462..b5afc4379 100644 --- a/src/app/api/insights/provider-chain/route.ts +++ b/src/app/api/insights/provider-chain/route.ts @@ -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 diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts index d6219657e..125af81dd 100644 --- a/src/app/api/labs/ocr/extract/route.ts +++ b/src/app/api/labs/ocr/extract/route.ts @@ -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 diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 52672d2ae..776e870b7 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -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 { 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 diff --git a/src/app/api/measurements/by-external-ids/route.ts b/src/app/api/measurements/by-external-ids/route.ts index 8ede3dcb1..387db7ed4 100644 --- a/src/app/api/measurements/by-external-ids/route.ts +++ b/src/app/api/measurements/by-external-ids/route.ts @@ -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 { 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; diff --git a/src/app/api/monitoring/glitchtip/route.ts b/src/app/api/monitoring/glitchtip/route.ts index d84d38d87..0d444cc59 100644 --- a/src/app/api/monitoring/glitchtip/route.ts +++ b/src/app/api/monitoring/glitchtip/route.ts @@ -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, diff --git a/src/app/api/nightscout/connect/__tests__/route.test.ts b/src/app/api/nightscout/connect/__tests__/route.test.ts index f1cde5a7f..bb7b545a3 100644 --- a/src/app/api/nightscout/connect/__tests__/route.test.ts +++ b/src/app/api/nightscout/connect/__tests__/route.test.ts @@ -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, diff --git a/src/app/api/nightscout/connect/route.ts b/src/app/api/nightscout/connect/route.ts index 57a221e2d..77bc5f383 100644 --- a/src/app/api/nightscout/connect/route.ts +++ b/src/app/api/nightscout/connect/route.ts @@ -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; diff --git a/src/app/api/notifications/preferences/route.ts b/src/app/api/notifications/preferences/route.ts index 9b98b2ca4..0fbe2fa4d 100644 --- a/src/app/api/notifications/preferences/route.ts +++ b/src/app/api/notifications/preferences/route.ts @@ -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; diff --git a/src/app/api/notifications/status/route.ts b/src/app/api/notifications/status/route.ts index 54fb387cc..5adf11a2a 100644 --- a/src/app/api/notifications/status/route.ts +++ b/src/app/api/notifications/status/route.ts @@ -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 }, diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts index 4bfe35382..a41c6fb52 100644 --- a/src/app/api/onboarding/complete/route.ts +++ b/src/app/api/onboarding/complete/route.ts @@ -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 = { diff --git a/src/app/api/onboarding/disclaimer/route.ts b/src/app/api/onboarding/disclaimer/route.ts index e9cf2dd5a..acb33fff5 100644 --- a/src/app/api/onboarding/disclaimer/route.ts +++ b/src/app/api/onboarding/disclaimer/route.ts @@ -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 diff --git a/src/app/api/onboarding/step/route.ts b/src/app/api/onboarding/step/route.ts index 177e42831..c21dc2cc8 100644 --- a/src/app/api/onboarding/step/route.ts +++ b/src/app/api/onboarding/step/route.ts @@ -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; diff --git a/src/app/api/onboarding/tour/route.ts b/src/app/api/onboarding/tour/route.ts index 08e9df6a2..b68d24e7b 100644 --- a/src/app/api/onboarding/tour/route.ts +++ b/src/app/api/onboarding/tour/route.ts @@ -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` diff --git a/src/app/api/oura/credentials/__tests__/route.test.ts b/src/app/api/oura/credentials/__tests__/route.test.ts index 800be41a7..d59052f89 100644 --- a/src/app/api/oura/credentials/__tests__/route.test.ts +++ b/src/app/api/oura/credentials/__tests__/route.test.ts @@ -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(), diff --git a/src/app/api/oura/credentials/route.ts b/src/app/api/oura/credentials/route.ts index 177718127..b5bb0a6e9 100644 --- a/src/app/api/oura/credentials/route.ts +++ b/src/app/api/oura/credentials/route.ts @@ -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( diff --git a/src/app/api/polar/credentials/__tests__/route.test.ts b/src/app/api/polar/credentials/__tests__/route.test.ts index 3b744bc78..2952491f7 100644 --- a/src/app/api/polar/credentials/__tests__/route.test.ts +++ b/src/app/api/polar/credentials/__tests__/route.test.ts @@ -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 }) => ({ diff --git a/src/app/api/polar/credentials/route.ts b/src/app/api/polar/credentials/route.ts index 7dbab091b..6d23712f0 100644 --- a/src/app/api/polar/credentials/route.ts +++ b/src/app/api/polar/credentials/route.ts @@ -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( diff --git a/src/app/api/settings/email/route.ts b/src/app/api/settings/email/route.ts index bd2dc592d..c8a2afb2c 100644 --- a/src/app/api/settings/email/route.ts +++ b/src/app/api/settings/email/route.ts @@ -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; diff --git a/src/app/api/settings/ntfy/route.ts b/src/app/api/settings/ntfy/route.ts index ea54e4c0f..eb0f4b05a 100644 --- a/src/app/api/settings/ntfy/route.ts +++ b/src/app/api/settings/ntfy/route.ts @@ -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; diff --git a/src/app/api/settings/telegram/route.ts b/src/app/api/settings/telegram/route.ts index c837ce010..545392e9a 100644 --- a/src/app/api/settings/telegram/route.ts +++ b/src/app/api/settings/telegram/route.ts @@ -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; diff --git a/src/app/api/settings/webhook/route.ts b/src/app/api/settings/webhook/route.ts index 8129a2745..0b8c5082f 100644 --- a/src/app/api/settings/webhook/route.ts +++ b/src/app/api/settings/webhook/route.ts @@ -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; diff --git a/src/app/api/strava/credentials/route.ts b/src/app/api/strava/credentials/route.ts index ac53d9c47..7605775ef 100644 --- a/src/app/api/strava/credentials/route.ts +++ b/src/app/api/strava/credentials/route.ts @@ -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( diff --git a/src/app/api/sync/changes/route.ts b/src/app/api/sync/changes/route.ts index 3688a6863..694929cc0 100644 --- a/src/app/api/sync/changes/route.ts +++ b/src/app/api/sync/changes/route.ts @@ -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; diff --git a/src/app/api/whoop/credentials/__tests__/route.test.ts b/src/app/api/whoop/credentials/__tests__/route.test.ts index 6ca1e67ea..091c9835d 100644 --- a/src/app/api/whoop/credentials/__tests__/route.test.ts +++ b/src/app/api/whoop/credentials/__tests__/route.test.ts @@ -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) => { diff --git a/src/app/api/whoop/credentials/route.ts b/src/app/api/whoop/credentials/route.ts index ffc7e9183..26448bbc9 100644 --- a/src/app/api/whoop/credentials/route.ts +++ b/src/app/api/whoop/credentials/route.ts @@ -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({ diff --git a/src/app/api/withings/credentials/route.ts b/src/app/api/withings/credentials/route.ts index 3829ed3d6..56e1f83e8 100644 --- a/src/app/api/withings/credentials/route.ts +++ b/src/app/api/withings/credentials/route.ts @@ -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({ From 3e44d6851893672dae7a1dde6e00ff0a436b0709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 07:26:32 +0200 Subject: [PATCH 02/56] fix(api): answer an unparseable body with 400 everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `safeJson` refuses a body that will not parse with 400, and so does `apiHandler`'s own SyntaxError branch — about two hundred and twenty routes inherit that. Seven `/api/auth/me/*` writes hand-rolled the parse in a try/catch and threw 422 for the identical condition, so a client's "my serializer produced garbage" branch had to accept two statuses on a subset of routes it had no way to predict. All seven answer 400 now, with the sentence `safeJson` already sends and the dotted token each one carried moved to `meta.errorCode`. The 400 is published on those seven operations through a shared response; it is deliberately not folded into the standard set, which is spread onto reads as well and a GET has no body to refuse. `/api/auth/me/health-score-config` keeps its 422: the operation's own description names that status and its exact message, so moving it is a contract decision rather than a cleanup. The guard carries it as an allowlist entry with that reason rather than quietly skipping it. Three route tests pinned the old status; they assert the new one and say why. The guard reads the hand-rolled parses out of the tree and asserts it still finds them, so it cannot pass on a matcher that stopped matching. --- docs/api/openapi.yaml | 348 +++++++++--------- .../malformed-json-status-guard.test.ts | 158 ++++++++ src/app/api/auth/me/coach-prefs/route.ts | 7 +- src/app/api/auth/me/cycle-prefs/route.ts | 2 +- .../me/disable-coach/__tests__/route.test.ts | 12 +- src/app/api/auth/me/disable-coach/route.ts | 6 +- .../auth/me/modules/__tests__/route.test.ts | 12 +- src/app/api/auth/me/modules/route.ts | 6 +- .../__tests__/route.test.ts | 12 +- .../api/auth/me/notification-prefs/route.ts | 6 +- src/app/api/auth/me/report-selection/route.ts | 5 +- src/app/api/auth/me/source-priority/route.ts | 7 +- src/lib/openapi/routes/cycle.ts | 2 + src/lib/openapi/routes/profile.ts | 19 +- src/lib/openapi/routes/shared.ts | 23 ++ 15 files changed, 434 insertions(+), 191 deletions(-) create mode 100644 src/__tests__/malformed-json-status-guard.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3293ed93f..264c54a16 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -672,6 +672,14 @@ paths: application/json: schema: $ref: "#/components/schemas/CyclePrefsPatchEnvelope" + "400": &a10 + description: The request body is not parseable JSON. Nothing was read and nothing was written. `meta.errorCode` names + the surface that refused it (`.body.invalid_json`). This is a client-side serialisation fault, not + a validation failure — a body that parses but fails the schema is the 422 beside this. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "422": *a4 "429": *a5 @@ -4196,7 +4204,7 @@ paths: schema: $ref: "#/components/schemas/GetWorkoutResponse" "401": *a1 - "403": &a12 + "403": &a13 description: 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.' @@ -5323,7 +5331,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a10 + "422": &a11 description: Request validation failed. When the body carried a `baseUpdatedAt` that could not be parsed as an ISO-8601 timestamp — including an explicit `null` — `meta.errorCode` = `invalid_base_updated_at` and nothing was written. Omit the key entirely for the unconditional write; do not send `null` for it. @@ -6483,6 +6491,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutCoachPrefsResponse" + "400": *a10 "401": *a1 "409": description: Coach preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read @@ -6492,7 +6501,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a10 + "422": *a11 "429": *a5 /api/auth/me/disable-coach: get: @@ -6530,6 +6539,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchDisableCoachResponse" + "400": *a10 "401": *a1 "422": *a4 "429": *a5 @@ -6684,6 +6694,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchModulesResponse" + "400": *a10 "401": *a1 "409": description: Module preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. @@ -6693,7 +6704,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a10 + "422": *a11 "429": *a5 /api/auth/me/source-priority: get: @@ -7524,6 +7535,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutSourcePriorityResponse" + "400": *a10 "401": *a1 "422": *a4 "429": *a5 @@ -7710,6 +7722,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchNotificationPrefsResponse" + "400": *a10 "401": *a1 "409": description: Notification preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. @@ -7719,7 +7732,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a10 + "422": *a11 "429": *a5 /api/auth/me/report-selection: get: @@ -7760,6 +7773,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutReportSelectionResponseEnvelope" + "400": *a10 "401": *a1 "422": *a4 "429": *a5 @@ -8734,7 +8748,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a10 + "422": *a11 "429": *a5 delete: tags: @@ -8938,7 +8952,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a10 + "422": *a11 "429": *a5 /api/anamnesis/facts: get: @@ -9066,7 +9080,7 @@ paths: schema: $ref: "#/components/schemas/CorrectAnamnesisFactResponse" "401": *a1 - "404": &a11 + "404": &a12 description: No current revision with this id exists for the authenticated account. content: application/json: @@ -9102,7 +9116,7 @@ paths: schema: $ref: "#/components/schemas/RemoveAnamnesisFactResponse" "401": *a1 - "404": *a11 + "404": *a12 "409": description: Anamnesis fact changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -10226,7 +10240,7 @@ paths: schema: $ref: "#/components/schemas/CoachSeededQuestionEnvelope" "401": *a1 - "403": *a12 + "403": *a13 "422": *a4 "429": *a5 /api/insights/narrative: @@ -10996,7 +11010,7 @@ paths: schema: $ref: "#/components/schemas/EcgIngestCreatedEnvelope" "401": *a1 - "403": *a12 + "403": *a13 "422": *a4 "429": *a5 /api/insights/ecg/{id}: @@ -11223,7 +11237,7 @@ paths: schema: $ref: "#/components/schemas/MoodDailySeriesEnvelope" "401": *a1 - "403": *a12 + "403": *a13 "422": *a4 "429": *a5 /api/mood/insights: @@ -11245,7 +11259,7 @@ paths: schema: $ref: "#/components/schemas/MoodAggregatesEnvelope" "401": *a1 - "403": *a12 + "403": *a13 "422": *a4 "429": *a5 /api/mood/prognosis: @@ -11378,7 +11392,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" "401": *a1 - "404": &a13 + "404": &a14 description: Not found / not owned by the caller. content: application/json: @@ -11413,7 +11427,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" "401": *a1 - "404": *a13 + "404": *a14 "422": *a4 "429": *a5 /api/mood/tags/{key}/hidden: @@ -11449,7 +11463,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "404": *a13 + "404": *a14 "422": *a4 "429": *a5 /api/mood/tags/groups: @@ -11502,7 +11516,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" "401": *a1 - "404": *a13 + "404": *a14 "422": *a4 "429": *a5 delete: @@ -11533,7 +11547,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" "401": *a1 - "404": *a13 + "404": *a14 "422": *a4 "429": *a5 /api/mood/tags/layout: @@ -11581,7 +11595,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a10 + "422": *a11 "429": *a5 /api/mood-entries: get: @@ -11698,7 +11712,7 @@ paths: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 "403": *a8 - "409": &a14 + "409": &a15 description: A mood entry with the same `(date, moodLoggedAt)` instant already exists. `meta.errorCode` = `mood.duplicate_timestamp`. Only a request without `externalId` can answer this — with one, the write upserts in place. @@ -11730,7 +11744,7 @@ paths: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 "403": *a8 - "404": *a13 + "404": *a14 "422": *a4 "429": *a5 put: @@ -11765,8 +11779,8 @@ paths: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 "403": *a8 - "404": *a13 - "409": *a14 + "404": *a14 + "409": *a15 "422": *a4 "429": *a5 delete: @@ -11792,7 +11806,7 @@ paths: $ref: "#/components/schemas/MoodEntryDeleteEnvelope" "401": *a1 "403": *a8 - "404": *a13 + "404": *a14 "422": *a4 "429": *a5 /api/mood-entries/restore: @@ -11942,7 +11956,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutNtfySettingsResponse" - "400": &a15 + "400": &a16 description: Body is not parseable JSON. content: application/json: @@ -11955,13 +11969,13 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": &a16 + "413": &a17 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a17 + "415": &a18 description: Content-Type is not `application/json`. content: application/json: @@ -12019,7 +12033,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutTelegramSettingsResponse" - "400": *a15 + "400": *a16 "401": *a1 "403": description: The operator switched Telegram off for this instance and the body asked to enable it. No webhook was @@ -12034,8 +12048,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": *a16 - "415": *a17 + "413": *a17 + "415": *a18 "422": description: Body matched neither accepted shape; or `enabled` is true without both a bot token and a chat id; or the deployment's `APP_URL` is not a public HTTPS host on port 80/88/443/8443; or Telegram refused the webhook @@ -12089,10 +12103,10 @@ paths: application/json: schema: $ref: "#/components/schemas/PutWebhookSettingsResponse" - "400": *a15 + "400": *a16 "401": *a1 - "413": *a16 - "415": *a17 + "413": *a17 + "415": *a18 "422": description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored. content: @@ -12556,7 +12570,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentLatestForKindEnvelope" - $ref: "#/components/schemas/ConsentLatestByKindEnvelope" - "400": &a18 + "400": &a19 description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`. Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422." content: @@ -12605,7 +12619,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentRevokeForKindEnvelope" - $ref: "#/components/schemas/ConsentRevokeAllEnvelope" - "400": *a18 + "400": *a19 "401": *a1 "422": *a4 "429": *a5 @@ -12891,7 +12905,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 "403": *a8 - "404": &a19 + "404": &a20 description: Measurement reminder not found / not owned. content: application/json: @@ -12926,7 +12940,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 "403": *a8 - "404": *a19 + "404": *a20 "422": *a4 "429": *a5 delete: @@ -12949,7 +12963,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 "403": *a8 - "404": *a19 + "404": *a20 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/satisfy: @@ -12974,7 +12988,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 "403": *a8 - "404": *a19 + "404": *a20 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/complete: @@ -13002,7 +13016,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" "401": *a1 "403": *a8 - "404": *a19 + "404": *a20 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/skip: @@ -13032,7 +13046,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderSkipEnvelope" "401": *a1 "403": *a8 - "404": *a19 + "404": *a20 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/snooze: @@ -13066,7 +13080,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderSnoozeEnvelope" "401": *a1 "403": *a8 - "404": *a19 + "404": *a20 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/history: @@ -13107,7 +13121,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderHistoryEnvelope" "401": *a1 "403": *a8 - "404": *a19 + "404": *a20 "422": *a4 "429": *a5 /api/labs: @@ -13231,7 +13245,7 @@ paths: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 "403": *a8 - "404": &a20 + "404": &a21 description: Lab result not found (or owned by another user). content: application/json: @@ -13266,7 +13280,7 @@ paths: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 "403": *a8 - "404": *a20 + "404": *a21 "422": *a4 "429": *a5 delete: @@ -13289,7 +13303,7 @@ paths: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 "403": *a8 - "404": *a20 + "404": *a21 "422": *a4 "429": *a5 /api/labs/restore: @@ -13445,7 +13459,7 @@ paths: $ref: "#/components/schemas/CreateBiomarkerResponse" "401": *a1 "403": *a8 - "409": &a22 + "409": &a23 description: A biomarker with this name already exists for the caller. content: application/json: @@ -13474,7 +13488,7 @@ paths: $ref: "#/components/schemas/GetBiomarkerResponse" "401": *a1 "403": *a8 - "404": &a21 + "404": &a22 description: Biomarker not found (or owned by another user). content: application/json: @@ -13509,8 +13523,8 @@ paths: $ref: "#/components/schemas/UpdateBiomarkerResponse" "401": *a1 "403": *a8 - "404": *a21 - "409": *a22 + "404": *a22 + "409": *a23 "422": *a4 "429": *a5 delete: @@ -13533,7 +13547,7 @@ paths: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 - "404": *a21 + "404": *a22 "422": *a4 "429": *a5 /api/encounters: @@ -13672,7 +13686,7 @@ paths: $ref: "#/components/schemas/GetEncounterEnvelope" "401": *a1 "403": *a8 - "404": &a23 + "404": &a24 description: Visit not found (or owned by another user). content: application/json: @@ -13706,7 +13720,7 @@ paths: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 "403": *a8 - "404": *a23 + "404": *a24 "422": *a4 "429": *a5 delete: @@ -13731,7 +13745,7 @@ paths: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 "403": *a8 - "404": *a23 + "404": *a24 "422": *a4 "429": *a5 /api/encounters/{id}/restore: @@ -13757,7 +13771,7 @@ paths: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 "403": *a8 - "404": *a23 + "404": *a24 "422": *a4 "429": *a5 /api/encounters/{id}/links: @@ -13787,7 +13801,7 @@ paths: schema: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 - "404": *a23 + "404": *a24 "422": *a4 "429": *a5 delete: @@ -13816,7 +13830,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 - "404": *a23 + "404": *a24 "422": *a4 "429": *a5 /api/practitioners: @@ -13895,7 +13909,7 @@ paths: $ref: "#/components/schemas/GetPractitionerEnvelope" "401": *a1 "403": *a8 - "404": &a24 + "404": &a25 description: Practitioner not found (or owned by another user). content: application/json: @@ -13929,7 +13943,7 @@ paths: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 "403": *a8 - "404": *a24 + "404": *a25 "422": *a4 "429": *a5 delete: @@ -13953,7 +13967,7 @@ paths: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 "403": *a8 - "404": *a24 + "404": *a25 "422": *a4 "429": *a5 /api/practitioners/{id}/restore: @@ -13979,7 +13993,7 @@ paths: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 "403": *a8 - "404": *a24 + "404": *a25 "422": *a4 "429": *a5 /api/vaccinations: @@ -14074,7 +14088,7 @@ paths: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 "403": *a8 - "404": &a25 + "404": &a26 description: Vaccination not found (or owned by another user). content: application/json: @@ -14109,7 +14123,7 @@ paths: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 "403": *a8 - "404": *a25 + "404": *a26 "422": *a4 "429": *a5 delete: @@ -14134,7 +14148,7 @@ paths: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 "403": *a8 - "404": *a25 + "404": *a26 "422": *a4 "429": *a5 /api/vaccinations/{id}/restore: @@ -14159,7 +14173,7 @@ paths: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 "403": *a8 - "404": *a25 + "404": *a26 "422": *a4 "429": *a5 /api/vaccinations/{id}/booster: @@ -14200,7 +14214,7 @@ paths: $ref: "#/components/schemas/MintVaccinationBoosterEnvelope" "401": *a1 "403": *a8 - "404": *a25 + "404": *a26 "422": *a4 "429": *a5 /api/vaccinations/suggest: @@ -14258,7 +14272,7 @@ paths: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 - "404": *a25 + "404": *a26 "422": *a4 "429": *a5 delete: @@ -14287,7 +14301,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 - "404": *a25 + "404": *a26 "422": *a4 "429": *a5 /api/illness/episodes: @@ -14371,7 +14385,7 @@ paths: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 "403": *a8 - "404": &a26 + "404": &a27 description: Illness episode not found (or owned by another user). content: application/json: @@ -14406,7 +14420,7 @@ paths: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 "403": *a8 - "404": *a26 + "404": *a27 "422": *a4 "429": *a5 delete: @@ -14430,7 +14444,7 @@ paths: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 "403": *a8 - "404": *a26 + "404": *a27 "422": *a4 "429": *a5 /api/illness/episodes/{id}/restore: @@ -14458,7 +14472,7 @@ paths: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 "403": *a8 - "404": *a26 + "404": *a27 "409": *a7 "422": *a4 "429": *a5 @@ -14491,7 +14505,7 @@ paths: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 "403": *a8 - "404": *a26 + "404": *a27 "422": *a4 "429": *a5 /api/illness/episodes/{id}/day-logs: @@ -14542,7 +14556,7 @@ paths: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 "403": *a8 - "404": *a26 + "404": *a27 "422": *a4 "429": *a5 post: @@ -14579,7 +14593,7 @@ paths: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 "403": *a8 - "404": *a26 + "404": *a27 "422": *a4 "429": *a5 /api/illness/episodes/{id}/correlation: @@ -14606,7 +14620,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a26 + "404": *a27 "422": *a4 "429": *a5 /api/illness/insights: @@ -14815,7 +14829,7 @@ paths: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 "403": *a8 - "404": &a27 + "404": &a28 description: Allergy not found (or owned by another user). content: application/json: @@ -14849,7 +14863,7 @@ paths: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 "403": *a8 - "404": *a27 + "404": *a28 "422": *a4 "429": *a5 delete: @@ -14873,7 +14887,7 @@ paths: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 "403": *a8 - "404": *a27 + "404": *a28 "422": *a4 "429": *a5 /api/family-history: @@ -14947,7 +14961,7 @@ paths: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 "403": *a8 - "404": &a28 + "404": &a29 description: Family history entry not found (or owned by another user). content: application/json: @@ -14981,7 +14995,7 @@ paths: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 "403": *a8 - "404": *a28 + "404": *a29 "422": *a4 "429": *a5 delete: @@ -15005,7 +15019,7 @@ paths: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 "403": *a8 - "404": *a28 + "404": *a29 "422": *a4 "429": *a5 /api/documents/inbound: @@ -15900,7 +15914,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a30 + "409": &a31 description: A custom metric with this name already exists for the caller. content: application/json: @@ -15929,7 +15943,7 @@ paths: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 "403": *a8 - "404": &a29 + "404": &a30 description: Custom metric not found (or owned by another user). content: application/json: @@ -15963,8 +15977,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a29 - "409": *a30 + "404": *a30 + "409": *a31 "422": *a4 "429": *a5 delete: @@ -15987,7 +16001,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -16031,7 +16045,7 @@ paths: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 "403": *a8 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 post: @@ -16060,7 +16074,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a29 + "404": *a30 "409": *a7 "422": *a4 "429": *a5 @@ -16096,7 +16110,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a31 + "404": &a32 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -16129,7 +16143,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a31 + "404": *a32 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -16467,7 +16481,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a32 + "403": &a33 description: >- The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is @@ -16522,7 +16536,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a32 + "403": *a33 "422": *a4 "429": *a5 /api/nutrients/water: @@ -16551,7 +16565,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a32 + "403": *a33 "409": *a7 "422": *a4 "429": *a5 @@ -17053,26 +17067,26 @@ paths: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" - "400": &a33 + "400": &a34 description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "413": &a34 + "413": &a35 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a35 + "415": &a36 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a36 + "422": &a37 description: "`clientId` and `clientSecret` are both required and each must be 1–200 characters. The envelope carries a single fixed message, not per-field issues." content: @@ -17258,11 +17272,11 @@ paths: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" - "400": *a33 + "400": *a34 "401": *a1 - "413": *a34 - "415": *a35 - "422": *a36 + "413": *a35 + "415": *a36 + "422": *a37 "429": *a5 delete: tags: @@ -17405,11 +17419,11 @@ paths: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" - "400": *a33 + "400": *a34 "401": *a1 - "413": *a34 - "415": *a35 - "422": *a36 + "413": *a35 + "415": *a36 + "422": *a37 "429": *a5 delete: tags: @@ -17562,11 +17576,11 @@ paths: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" - "400": *a33 + "400": *a34 "401": *a1 - "413": *a34 - "415": *a35 - "422": *a36 + "413": *a35 + "415": *a36 + "422": *a37 "429": *a5 delete: tags: @@ -17642,21 +17656,21 @@ paths: schema: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 - "422": &a37 + "422": &a38 description: No usable connection for this provider — nothing stored, or the stored grant could no longer be resolved into an access token. `meta.errorCode` = `not_configured`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a38 + "429": &a39 description: More than 5 requests in 60 s from this user. `meta.errorCode` = `rate_limited_self` — this is the local limiter, distinct from a `rate_limited` 502 which is the provider's. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": &a39 + "502": &a40 description: "The probe did not complete. `meta.errorCode` names the class: `credentials_rejected` (the provider answered 401/403 — the stored grant is gone), `rate_limited` (the PROVIDER answered 429, which is not this endpoint's own limiter), `upstream_error` (5xx), `timeout` (no answer within 5 s), `connection_failed` @@ -17701,11 +17715,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" - "400": *a33 + "400": *a34 "401": *a1 - "413": *a34 - "415": *a35 - "422": *a36 + "413": *a35 + "415": *a36 + "422": *a37 "429": *a5 delete: tags: @@ -17781,9 +17795,9 @@ paths: schema: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 - "422": *a37 - "429": *a38 - "502": *a39 + "422": *a38 + "429": *a39 + "502": *a40 /api/strava/credentials: get: tags: @@ -17819,11 +17833,11 @@ paths: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" - "400": *a33 + "400": *a34 "401": *a1 - "413": *a34 - "415": *a35 - "422": *a36 + "413": *a35 + "415": *a36 + "422": *a37 "429": *a5 delete: tags: @@ -17901,9 +17915,9 @@ paths: schema: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 - "422": *a37 - "429": *a38 - "502": *a39 + "422": *a38 + "429": *a39 + "502": *a40 /api/integrations/status: get: tags: @@ -17939,7 +17953,7 @@ paths: $ref: "#/components/schemas/FitbitResumeEnvelope" "401": *a1 "422": *a4 - "429": *a38 + "429": *a39 /api/integrations/fitbit/test: post: tags: @@ -17957,9 +17971,9 @@ paths: schema: $ref: "#/components/schemas/FitbitIntegrationTestEnvelope" "401": *a1 - "422": *a37 - "429": *a38 - "502": *a39 + "422": *a38 + "429": *a39 + "502": *a40 /api/integrations/google-health/resume: post: tags: @@ -17976,7 +17990,7 @@ paths: $ref: "#/components/schemas/GoogleHealthResumeEnvelope" "401": *a1 "422": *a4 - "429": *a38 + "429": *a39 /api/integrations/google-health/test: post: tags: @@ -18009,9 +18023,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a37 - "429": *a38 - "502": *a39 + "422": *a38 + "429": *a39 + "502": *a40 /api/integrations/whoop/resume: post: tags: @@ -18028,7 +18042,7 @@ paths: $ref: "#/components/schemas/WhoopResumeEnvelope" "401": *a1 "422": *a4 - "429": *a38 + "429": *a39 /api/integrations/whoop/test: post: tags: @@ -18045,9 +18059,9 @@ paths: schema: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 - "422": *a37 - "429": *a38 - "502": *a39 + "422": *a38 + "429": *a39 + "502": *a40 /api/integrations/withings/resume: post: tags: @@ -18065,7 +18079,7 @@ paths: $ref: "#/components/schemas/WithingsResumeEnvelope" "401": *a1 "422": *a4 - "429": *a38 + "429": *a39 /api/integrations/withings/test: post: tags: @@ -18083,8 +18097,8 @@ paths: schema: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 - "422": *a37 - "429": *a38 + "422": *a38 + "429": *a39 "502": description: "The probe did not complete. The shared codes apply (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed`) and this path adds one of its own: @@ -18148,7 +18162,7 @@ paths: schema: $ref: "#/components/schemas/DismissPriorityItemEnvelope" "401": *a1 - "403": *a12 + "403": *a13 "422": *a4 "429": *a5 /api/dashboard/chart-overlay-prefs: @@ -18258,7 +18272,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a10 + "422": *a11 "429": *a5 delete: tags: @@ -18321,7 +18335,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a40 + "403": &a41 description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is byte-identical for an account that does not exist and one that granted @@ -18346,7 +18360,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a40 + "403": *a41 "422": *a4 "429": *a5 post: @@ -18441,7 +18455,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a40 + "403": *a41 "404": description: No such invitation for this account. content: @@ -18485,7 +18499,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a40 + "403": *a41 "404": description: No such grant on this account's record. content: @@ -18526,7 +18540,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a40 + "403": *a41 "404": description: No such grant held by this account. content: @@ -18908,7 +18922,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a40 + "403": *a41 "409": description: "The record this browser session is on is not the record the request asserted. `meta.errorCode` is `sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records — @@ -19238,7 +19252,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 - "403": &a41 + "403": &a42 description: The operator has disabled the API instance-wide. Every route in this module refuses while that setting is off, for a cookie session as well as for a Bearer token; it is not a per-user permission and retrying will not help. @@ -19271,7 +19285,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 - "403": *a41 + "403": *a42 "404": description: No live connection with this id for this caller — also the answer for an already-revoked one, so a repeat revoke is 404 rather than an idempotent 200. @@ -19298,7 +19312,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a41 + "403": *a42 "422": *a4 "429": *a5 post: @@ -19328,7 +19342,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a41 + "403": *a42 "413": description: Body exceeds 16 KiB. content: @@ -19371,7 +19385,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 - "403": *a41 + "403": *a42 "404": description: No `health:read` token with this id for this caller. An already-revoked token is still found, so a repeat revoke answers 200. @@ -19429,7 +19443,7 @@ paths: description: Returns the named family's resolved settings for the record the session is acting on. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - &a42 + - &a43 name: family in: path required: true @@ -19473,7 +19487,7 @@ paths: ride the audit row. Body capped at 64 KiB. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - *a42 + - *a43 requestBody: required: true content: @@ -19625,7 +19639,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentOverviewEnvelope" "401": *a1 - "403": *a12 + "403": *a13 "422": *a4 "429": *a5 /api/environment/home: @@ -19662,7 +19676,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentHomeEnvelope" "401": *a1 - "403": *a12 + "403": *a13 "413": description: Body exceeds 4 KiB. content: @@ -19716,7 +19730,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentBackfillEnvelope" "401": *a1 - "403": *a12 + "403": *a13 "409": description: No home location is set, so there is nothing to resolve the days against. `meta.errorCode` = `environment.no_home`. @@ -19775,7 +19789,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentGeocodeEnvelope" "401": *a1 - "403": *a12 + "403": *a13 "422": description: "`q` was missing, empty after trimming, or longer than 120 characters. `meta.errorCode` = `environment.invalid`." @@ -19814,7 +19828,7 @@ paths: schema: $ref: "#/components/schemas/CreateEnvironmentTravelEnvelope" "401": *a1 - "403": *a12 + "403": *a13 "413": description: Body exceeds 4 KiB. content: @@ -19867,7 +19881,7 @@ paths: schema: $ref: "#/components/schemas/DeleteEnvironmentTravelEnvelope" "401": *a1 - "403": *a12 + "403": *a13 "404": description: No such override for this account. `meta.errorCode` = `environment.travel.not_found`. Also the answer for an id owned by somebody else — existence is not leaked. @@ -20008,13 +20022,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a43 + "403": &a44 description: Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": &a44 + "404": &a45 description: No stored copy with this id. content: application/json: @@ -20073,8 +20087,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a43 - "404": *a44 + "403": *a44 + "404": *a45 "409": description: Either the payload declares a different owner than the stored row, or a request under the same `Idempotency-Key` is still in flight. Nothing was changed. diff --git a/src/__tests__/malformed-json-status-guard.test.ts b/src/__tests__/malformed-json-status-guard.test.ts new file mode 100644 index 000000000..b26fae67a --- /dev/null +++ b/src/__tests__/malformed-json-status-guard.test.ts @@ -0,0 +1,158 @@ +/** + * A body that will not parse is always a 400. + * + * `safeJson` has answered 400 since it was written and roughly two hundred and + * twenty routes go through it; `apiHandler`'s own `SyntaxError` branch answers + * 400 as well. Seven `/api/auth/me/*` writes hand-rolled `await req.json()` in + * a try/catch and threw 422 for the same condition, so a client's "my + * serializer produced garbage" branch had to accept two statuses on a subset of + * routes it could not predict — and the subset was invisible from the outside. + * + * The guard reads every hand-rolled parse out of the route tree rather than + * listing them, so a route that grows one later is judged too. The `catch` is + * read on its own: a `try` that parses a body and then refuses with anything + * other than 400 fails here. + * + * Its limit: a route that parses a body somewhere other than inside a `try` + * whose `catch` builds the refusal — a shared helper, a promise chain — is + * invisible to it. `safeJson` remains the documented entry, and the assertion + * on the helper below is what pins the status the rest of the tree inherits. + */ +import { readFileSync, readdirSync } from "node:fs"; +import { join, relative, sep } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { safeJson } from "@/lib/api-response"; + +const API_ROOT = join(process.cwd(), "src", "app", "api"); + +/** + * Hand-rolled parses that must keep a different status, with the reason. + * + * `/api/auth/me/health-score-config` is here because the published contract + * names its answer: the operation's own 422 description spells out that a body + * which is not JSON at all comes back with the bare message + * `health-score-config.body.invalid_json`. Moving it to 400 would change a + * promise a shipped client was generated against, so the status stays and the + * change is a contract decision of its own rather than a cleanup. + */ +const ALLOWLIST: Record = { + "src/app/api/auth/me/health-score-config/route.ts": { + status: 422, + reason: + "The 422 and its exact message are written into the operation's published description.", + }, +}; + +/** 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 brace-balanced block starting at the `{` at or after `from`. */ +function block(src: string, from: number): { text: string; end: number } { + let i = from; + while (i < src.length && src[i] !== "{") 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 { text: src.slice(i, j + 1), end: j + 1 }; + } + } + return { text: src.slice(i), end: src.length }; +} + +interface ParseRefusal { + route: string; + line: number; + status: number | null; +} + +/** Every `try { … .json() … } catch { … }` under the route tree. */ +function scanHandRolledParses(): ParseRefusal[] { + const found: ParseRefusal[] = []; + for (const file of routeFiles(API_ROOT)) { + const src = readFileSync(file, "utf8"); + for (const m of src.matchAll(/\btry\s*\{/g)) { + const start = m.index ?? 0; + const tryBlock = block(src, start); + if (!/(?:req|request)\w*\.json\(\)/.test(tryBlock.text)) continue; + const after = src.slice(tryBlock.end); + const catchAt = after.match(/^\s*catch\s*(?:\([^)]*\)\s*)?\{/); + if (!catchAt) continue; + const catchBlock = block(after, catchAt.index ?? 0); + const status = catchBlock.text.match(/\b(4\d\d|5\d\d)\b/); + found.push({ + route: relative(process.cwd(), file).split(sep).join("/"), + line: src.slice(0, start).split("\n").length, + status: status ? Number(status[1]) : null, + }); + } + } + return found; +} + +describe("malformed JSON answers one status", () => { + const parses = scanHandRolledParses(); + + it("finds the hand-rolled parses it is meant to judge", () => { + // Eight when the guard was written. A matcher that stops matching would + // otherwise report a clean pass it did not earn. + expect(parses.length).toBeGreaterThanOrEqual(8); + }); + + it("refuses an unparseable body with 400", () => { + const wrong = parses + .filter((parse) => { + const allowed = ALLOWLIST[parse.route]; + if (allowed) return parse.status !== allowed.status; + return parse.status !== 400; + }) + .map((parse) => `${parse.route}:${parse.line} -> ${parse.status}`); + expect(wrong).toEqual([]); + }); + + it("keeps no stale allowlist entries", () => { + const stale = Object.keys(ALLOWLIST).filter( + (route) => !parses.some((parse) => parse.route === route), + ); + expect(stale).toEqual([]); + }); + + it("is the status the shared helper already answers with", async () => { + const request = new Request("https://example.test/api/anything", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{ not json", + }); + const result = await safeJson(request); + expect(result.error?.status).toBe(400); + expect(await result.error?.clone().json()).toMatchObject({ + data: null, + error: "Invalid JSON body", + }); + }); + + it("keeps the wrong-content-type refusal separate at 415", async () => { + const request = new Request("https://example.test/api/anything", { + method: "POST", + headers: { "content-type": "text/plain" }, + body: "{}", + }); + const result = await safeJson(request); + expect(result.error?.status).toBe(415); + }); +}); diff --git a/src/app/api/auth/me/coach-prefs/route.ts b/src/app/api/auth/me/coach-prefs/route.ts index 2c0c3d26d..7739a4ea2 100644 --- a/src/app/api/auth/me/coach-prefs/route.ts +++ b/src/app/api/auth/me/coach-prefs/route.ts @@ -15,8 +15,9 @@ * helper. The Coach prompt builder + snapshot builder both read this * row on every turn — there's no caching layer to invalidate. */ -import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; import { + apiError, apiSuccess, apiValidationError, sanitiseZodIssues, @@ -61,7 +62,9 @@ export const PUT = apiHandler(async (req: Request) => { try { body = await req.json(); } catch { - throw new HttpError(422, "coach-prefs.body.invalid_json"); + return apiError("Invalid JSON body", 400, { + errorCode: "coach-prefs.body.invalid_json", + }); } // v1.32.22 (M4) — pull the optimistic-concurrency base token off before the diff --git a/src/app/api/auth/me/cycle-prefs/route.ts b/src/app/api/auth/me/cycle-prefs/route.ts index ec6d97132..81f145a57 100644 --- a/src/app/api/auth/me/cycle-prefs/route.ts +++ b/src/app/api/auth/me/cycle-prefs/route.ts @@ -65,7 +65,7 @@ export const PATCH = apiHandler(async (req: Request) => { try { body = await req.json(); } catch { - return apiError("Invalid JSON body", 422, { + return apiError("Invalid JSON body", 400, { errorCode: "cycle-prefs.body.invalid_json", }); } diff --git a/src/app/api/auth/me/disable-coach/__tests__/route.test.ts b/src/app/api/auth/me/disable-coach/__tests__/route.test.ts index 5ed9ad5b2..0726fb835 100644 --- a/src/app/api/auth/me/disable-coach/__tests__/route.test.ts +++ b/src/app/api/auth/me/disable-coach/__tests__/route.test.ts @@ -212,7 +212,11 @@ describe("PATCH /api/auth/me/disable-coach", () => { expect(prisma.user.update).not.toHaveBeenCalled(); }); - it("rejects malformed JSON with 422", async () => { + // 400, not the 422 this used to pin. A body that will not parse is a + // client-side serialisation fault, and `safeJson` plus roughly two hundred + // and twenty other routes have always answered 400 for it; the dotted token + // moved to `meta.errorCode`, which is where a machine code belongs. + it("rejects malformed JSON with 400", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const req = new Request("http://localhost/api/auth/me/disable-coach", { @@ -222,7 +226,11 @@ describe("PATCH /api/auth/me/disable-coach", () => { }); const res = await (PATCH as (r: Request) => Promise)(req); - expect(res.status).toBe(422); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + error: "Invalid JSON body", + meta: { errorCode: "disable-coach.body.invalid_json" }, + }); expect(prisma.user.update).not.toHaveBeenCalled(); }); diff --git a/src/app/api/auth/me/disable-coach/route.ts b/src/app/api/auth/me/disable-coach/route.ts index a5cb1bd7f..7dddc8624 100644 --- a/src/app/api/auth/me/disable-coach/route.ts +++ b/src/app/api/auth/me/disable-coach/route.ts @@ -21,7 +21,7 @@ */ import { z } from "zod"; -import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -78,7 +78,9 @@ export const PATCH = apiHandler(async (req: Request) => { try { body = await req.json(); } catch { - throw new HttpError(422, "disable-coach.body.invalid_json"); + return apiError("Invalid JSON body", 400, { + errorCode: "disable-coach.body.invalid_json", + }); } const parsed = patchBodySchema.safeParse(body); diff --git a/src/app/api/auth/me/modules/__tests__/route.test.ts b/src/app/api/auth/me/modules/__tests__/route.test.ts index 7a14aef25..bb675fc73 100644 --- a/src/app/api/auth/me/modules/__tests__/route.test.ts +++ b/src/app/api/auth/me/modules/__tests__/route.test.ts @@ -285,7 +285,11 @@ describe("PATCH /api/auth/me/modules", () => { expect(prisma.user.update).not.toHaveBeenCalled(); }); - it("rejects malformed JSON with 422", async () => { + // 400, not the 422 this used to pin. A body that will not parse is a + // client-side serialisation fault, and `safeJson` plus roughly two hundred + // and twenty other routes have always answered 400 for it; the dotted token + // moved to `meta.errorCode`, which is where a machine code belongs. + it("rejects malformed JSON with 400", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const req = new Request("http://localhost/api/auth/me/modules", { method: "PATCH", @@ -293,7 +297,11 @@ describe("PATCH /api/auth/me/modules", () => { headers: { "Content-Type": "application/json" }, }); const res = await (PATCH as (r: Request) => Promise)(req); - expect(res.status).toBe(422); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + error: "Invalid JSON body", + meta: { errorCode: "modules.body.invalid_json" }, + }); expect(prisma.user.update).not.toHaveBeenCalled(); }); diff --git a/src/app/api/auth/me/modules/route.ts b/src/app/api/auth/me/modules/route.ts index 05060be90..5c10e8d93 100644 --- a/src/app/api/auth/me/modules/route.ts +++ b/src/app/api/auth/me/modules/route.ts @@ -25,7 +25,7 @@ * carries it. The semantics are a DISABLED allowlist: a key set to * `false` disables that module; `true` (or absence) leaves it enabled. */ -import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -84,7 +84,9 @@ export const PATCH = apiHandler(async (req: Request) => { try { body = await req.json(); } catch { - throw new HttpError(422, "modules.body.invalid_json"); + return apiError("Invalid JSON body", 400, { + errorCode: "modules.body.invalid_json", + }); } // v1.32.22 (M3) — strip the optimistic-concurrency base token BEFORE the diff --git a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts index c8d503649..dc485fe69 100644 --- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts @@ -324,7 +324,11 @@ describe("PATCH /api/auth/me/notification-prefs", () => { ); }); - it("rejects malformed JSON with 422", async () => { + // 400, not the 422 this used to pin. A body that will not parse is a + // client-side serialisation fault, and `safeJson` plus roughly two hundred + // and twenty other routes have always answered 400 for it; the dotted token + // moved to `meta.errorCode`, which is where a machine code belongs. + it("rejects malformed JSON with 400", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const req = new Request("http://localhost/api/auth/me/notification-prefs", { @@ -334,7 +338,11 @@ describe("PATCH /api/auth/me/notification-prefs", () => { }); const res = await (PATCH as (r: Request) => Promise)(req); - expect(res.status).toBe(422); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + error: "Invalid JSON body", + meta: { errorCode: "notification-prefs.body.invalid_json" }, + }); expect(prisma.user.update).not.toHaveBeenCalled(); }); diff --git a/src/app/api/auth/me/notification-prefs/route.ts b/src/app/api/auth/me/notification-prefs/route.ts index b7e64cfd3..3770a380e 100644 --- a/src/app/api/auth/me/notification-prefs/route.ts +++ b/src/app/api/auth/me/notification-prefs/route.ts @@ -23,7 +23,7 @@ * extra round-trip. Rate-limit is intentionally generous (60/min) — * matches the disable-coach route which is the closest analogue. */ -import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -86,7 +86,9 @@ export const PATCH = apiHandler(async (req: Request) => { try { body = await req.json(); } catch { - throw new HttpError(422, "notification-prefs.body.invalid_json"); + return apiError("Invalid JSON body", 400, { + errorCode: "notification-prefs.body.invalid_json", + }); } // v1.32.22 (M1) — pull the optimistic-concurrency base token off the body diff --git a/src/app/api/auth/me/report-selection/route.ts b/src/app/api/auth/me/report-selection/route.ts index 621c3ac78..e7aeed85b 100644 --- a/src/app/api/auth/me/report-selection/route.ts +++ b/src/app/api/auth/me/report-selection/route.ts @@ -18,6 +18,7 @@ */ import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; import { + apiError, apiSuccess, apiValidationError, getClientIp, @@ -53,7 +54,9 @@ export const PUT = apiHandler(async (req: Request) => { try { body = await req.json(); } catch { - throw new HttpError(422, "report-selection.body.invalid_json"); + return apiError("Invalid JSON body", 400, { + errorCode: "report-selection.body.invalid_json", + }); } const parsed = savedReportProfileSchema.safeParse(body ?? {}); diff --git a/src/app/api/auth/me/source-priority/route.ts b/src/app/api/auth/me/source-priority/route.ts index 264de6c7e..f1c50337e 100644 --- a/src/app/api/auth/me/source-priority/route.ts +++ b/src/app/api/auth/me/source-priority/route.ts @@ -18,8 +18,9 @@ * decides which source's rows the cached targets / derived / analytics * payloads were built from. */ -import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; import { + apiError, apiSuccess, apiValidationError, getClientIp, @@ -53,7 +54,9 @@ export const PUT = apiHandler(async (req: Request) => { try { body = await req.json(); } catch { - throw new HttpError(422, "source-priority.body.invalid_json"); + return apiError("Invalid JSON body", 400, { + errorCode: "source-priority.body.invalid_json", + }); } const parsed = sourcePrioritySchema.safeParse(body ?? {}); diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index e32173f30..8f7995eca 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -33,6 +33,7 @@ import { errorEnvelope, idempotencyKeyParameter, idempotentWrite, + malformedJsonResponse, recordRefusal, stdResponses, } from "./shared"; @@ -991,6 +992,7 @@ export const cyclePaths: NonNullable = { }, }, ...stdResponses, + ...malformedJsonResponse, }, }, }, diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 36c2de803..5c02cfcb7 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -38,16 +38,17 @@ import { healthProfileFactKindSchema, } from "@/lib/validations/health-profile-facts"; import { - coachPrefsSchema, - dataEnvelope, - errorEnvelope, - stdResponses, baseUpdatedAtField, - updatedAtTokenField, + coachPrefsSchema, conflictResponse409, - invalidBaseTokenResponse, + dataEnvelope, deviceRevokeResponse, + errorEnvelope, + invalidBaseTokenResponse, + malformedJsonResponse, recordRefusal, + stdResponses, + updatedAtTokenField, } from "./shared"; // v1.18.0 — module enable/disable. The PATCH request is the REAL runtime @@ -1063,6 +1064,7 @@ export const profilePaths: NonNullable = { }, ...conflictResponse409("Coach preferences", "coach_prefs_conflict"), ...stdResponses, + ...malformedJsonResponse, ...invalidBaseTokenResponse, }, }, @@ -1108,6 +1110,7 @@ export const profilePaths: NonNullable = { }, }, ...stdResponses, + ...malformedJsonResponse, }, }, }, @@ -1263,6 +1266,7 @@ export const profilePaths: NonNullable = { }, ...conflictResponse409("Module preferences", "modules_conflict"), ...stdResponses, + ...malformedJsonResponse, ...invalidBaseTokenResponse, }, }, @@ -1310,6 +1314,7 @@ export const profilePaths: NonNullable = { }, }, ...stdResponses, + ...malformedJsonResponse, }, }, }, @@ -1505,6 +1510,7 @@ export const profilePaths: NonNullable = { "notification_prefs_conflict", ), ...stdResponses, + ...malformedJsonResponse, ...invalidBaseTokenResponse, }, }, @@ -1552,6 +1558,7 @@ export const profilePaths: NonNullable = { }, }, ...stdResponses, + ...malformedJsonResponse, }, }, }, diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 43b625a9e..051be4acb 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -225,6 +225,29 @@ export const invalidBaseTokenResponse = { }, }; +/** + * The 400 an unparseable JSON body earns. + * + * `safeJson` has answered 400 for a body that will not parse since it was + * written, and roughly two hundred and twenty routes go through it — but seven + * `/api/auth/me/*` writes hand-rolled the parse and answered 422, so a client's + * "my serializer produced garbage" branch had to accept two statuses on a + * subset of routes it could not predict. They answer 400 now, and this is the + * response that says so. The dotted token each route already carried moved to + * `meta.errorCode`, where a machine code belongs. + * + * Spread only on the operations that hand-rolled the parse. It is deliberately + * NOT folded into `stdResponses`: that set is spread onto reads as well, and a + * GET that accepts no body has no malformed body to refuse. + */ +export const malformedJsonResponse = { + "400": { + description: + "The request body is not parseable JSON. Nothing was read and nothing was written. `meta.errorCode` names the surface that refused it (`.body.invalid_json`). This is a client-side serialisation fault, not a validation failure — a body that parses but fails the schema is the 422 beside this.", + content: { "application/json": { schema: errorEnvelope } }, + }, +}; + // ── Standard 401 / 422 / 429 responses ─────────────────────────────── export const stdResponses = { From 040b430e7348da8f16a6a69e2dc1db3f02b6a05d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 07:40:05 +0200 Subject: [PATCH 03/56] fix(api): accept an Idempotency-Key on the titration step and the custom symptom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more creates the client replays out of its offline outbox ran the handler again on the replay. A titration step carries no natural key at all, so a lost success response left the medication with two dose changes on the same day and the runway estimate reading off the wrong one. A custom cycle symptom is unique on its label, so the replay did not duplicate — it answered the 409 that uniqueness raises, reporting a conflict the client cannot act on for a row it has no record of creating. Both wrap in `withIdempotency`, the composition their siblings already ship, and both operations publish the header and the in-flight 409 so a client generated against the contract can tell a retry is safe. The integration file that drives the medication creates twice against real Postgres gains the titration case: one dose-change row after the replay, and a replayed 201 carrying `X-Idempotent-Replay: true`. `POST /api/mcp/tokens` is deliberately not on this list. Its response carries a freshly minted `hlk_` credential, and the wrapper refuses to persist a secret-shaped body — publishing the header there would promise a replay the mechanism will never perform. --- docs/api/openapi.yaml | 73 +++--- src/app/api/cycle/symptoms/custom/route.ts | 16 +- src/app/api/medications/[id]/glp1/route.ts | 214 +++++++++--------- src/lib/openapi/routes/cycle.ts | 2 + src/lib/openapi/routes/medications/paths.ts | 2 + .../medication-write-idempotency.test.ts | 44 +++- 6 files changed, 211 insertions(+), 140 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 264c54a16..3a2f547d0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -152,6 +152,22 @@ paths: burns their own allowance rather than locking the owner out and cannot collect a fresh one by switching records. Audits as `cycle.symptom.custom.create` with the icon only — never the label. Delegable at MANAGE level over the `cycle` section: the record's own symptom vocabulary is what the day-log writes need in order to say anything." + parameters: + - &a6 + name: Idempotency-Key + in: header + required: false + schema: + type: string + pattern: ^[A-Za-z0-9_\-:.]{8,128}$ + minLength: 8 + maxLength: 128 + description: "Makes this write safe to retry. Send the same key again and the first response is replayed rather than the + work repeated, marked with `X-Idempotent-Replay: true`. A key that does not match the pattern is IGNORED + rather than refused — the write then proceeds unprotected and looks exactly like a request that carried no + key, so validate the key on your side. Scoped per user and per (method, path): the same key on a different + route is a different key. While a first request under this key is still in flight, a second is refused with + 409 rather than queued." requestBody: required: true content: @@ -167,6 +183,25 @@ paths: $ref: "#/components/schemas/CycleCustomSymptomCreatedEnvelope" "401": *a1 "403": *a2 + "409": &a7 + description: "A request with this `Idempotency-Key` is already in progress. Nothing was written by this call. Retry + after a short delay; the response carries `X-Idempotent-Replay: false` to separate it from a replay." + headers: + X-Idempotent-Replay: + required: true + description: "`true` when this response was replayed from a stored one rather than produced now; `false` on the 409 that + refuses a duplicate still in flight. Absent when the request carried no usable key." + schema: + type: string + enum: + - "true" + - "false" + description: "`true` when this response was replayed from a stored one rather than produced now; `false` on the 409 that + refuses a duplicate still in flight. Absent when the request carried no usable key." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": description: Either the body failed validation — the multi-issue envelope with `meta.errorCode` = `cycle.symptom.custom.invalid` (an empty or over-40-character label, an icon outside the allow-list, a @@ -332,21 +367,7 @@ paths: description: "Upserts on `(userId, source, externalId)` when externalId present, else `(userId, date)`. `note` encrypts at rest. 201 on insert, 200 on update. Gated: `cycle.disabled` 403 when the feature is off." parameters: - - &a6 - name: Idempotency-Key - in: header - required: false - schema: - type: string - pattern: ^[A-Za-z0-9_\-:.]{8,128}$ - minLength: 8 - maxLength: 128 - description: "Makes this write safe to retry. Send the same key again and the first response is replayed rather than the - work repeated, marked with `X-Idempotent-Replay: true`. A key that does not match the pattern is IGNORED - rather than refused — the write then proceeds unprotected and looks exactly like a request that carried no - key, so validate the key on your side. Scoped per user and per (method, path): the same key on a different - route is a different key. While a first request under this key is still in flight, a second is refused with - 409 rather than queued." + - *a6 requestBody: required: true content: @@ -368,25 +389,7 @@ paths: $ref: "#/components/schemas/CycleDayLogCreatedEnvelope" "401": *a1 "403": *a2 - "409": &a7 - description: "A request with this `Idempotency-Key` is already in progress. Nothing was written by this call. Retry - after a short delay; the response carries `X-Idempotent-Replay: false` to separate it from a replay." - headers: - X-Idempotent-Replay: - required: true - description: "`true` when this response was replayed from a stored one rather than produced now; `false` on the 409 that - refuses a duplicate still in flight. Absent when the request carried no usable key." - schema: - type: string - enum: - - "true" - - "false" - description: "`true` when this response was replayed from a stored one rather than produced now; `false` on the 409 that - refuses a duplicate still in flight. Absent when the request carried no usable key." - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" + "409": *a7 "422": *a4 "429": *a5 /api/cycle/day-logs/{id}: @@ -4550,6 +4553,7 @@ paths: schema: type: string required: true + - *a6 requestBody: required: true content: @@ -4613,6 +4617,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "409": *a7 "422": *a4 "429": *a5 /api/medications/{id}/api-endpoint: diff --git a/src/app/api/cycle/symptoms/custom/route.ts b/src/app/api/cycle/symptoms/custom/route.ts index 798a4a7e3..a716de0d7 100644 --- a/src/app/api/cycle/symptoms/custom/route.ts +++ b/src/app/api/cycle/symptoms/custom/route.ts @@ -26,6 +26,7 @@ import { } from "@/lib/api-response"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { withIdempotency } from "@/lib/idempotency"; import { auditLog } from "@/lib/auth/audit"; import { checkRateLimit } from "@/lib/rate-limit"; import { requireCycleEnabled } from "@/lib/cycle/gate"; @@ -67,7 +68,18 @@ export const GET = apiHandler(async () => { return apiSuccess({ symptoms }); }); -export const POST = apiHandler(async (request: NextRequest) => { +/** + * Wrapped in `withIdempotency`: a custom symptom is minted from the client's + * offline outbox under the same `Idempotency-Key`, and a replay after a lost + * success response would otherwise answer the 409 the label's unique key + * raises rather than the 201 the first attempt produced — a create the client + * has no record of, reported as a conflict it cannot act on. + */ +export const POST = apiHandler( + withIdempotency<[NextRequest]>(postCustomSymptom), +); + +async function postCustomSymptom(request: NextRequest): Promise { // v1.37.0 — MANAGE. The record's own symptom vocabulary, which the day-log // writes the level admits need in order to say anything. const { user, actor } = await requireRecordAuth("manage", "cycle"); @@ -151,4 +163,4 @@ export const POST = apiHandler(async (request: NextRequest) => { }, 201, ); -}); +} diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts index 180badac9..46a18ac2e 100644 --- a/src/app/api/medications/[id]/glp1/route.ts +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -28,6 +28,7 @@ import { import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { glp1PostBodySchema } from "@/lib/validations/medication"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { withIdempotency } from "@/lib/idempotency"; import { estimateDailyDoseCount, estimateRunwayDays, @@ -236,103 +237,71 @@ export const GET = apiHandler( * Rate-limit: 30/min/user (same as sibling POST routes). * Audit: `medication.glp1.update` for every successful mutation. */ +/** + * Wrapped in `withIdempotency`: a titration step is replayed out of the + * client's offline outbox under the same `Idempotency-Key`, and a replay after + * a lost success response would otherwise write a second dose change or a + * second supply delta. Neither row carries a natural key, so the wrapper is the + * only thing that can collapse the retry. + */ export const POST = apiHandler( - async (request: NextRequest, { params }: RouteParams) => { - // v1.37.0 — MANAGE. A titration step is record data and the dose is what - // the server acts on; administering one is the case the level names. - const { user, actor } = await requireRecordAuth("manage", "medications"); - const { id } = await params; + withIdempotency<[NextRequest, RouteParams]>(postGlp1Update), +); - const guard = await assertMedicationOwnership(id, user.id); - if (guard) return guard; +async function postGlp1Update( + request: NextRequest, + { params }: RouteParams, +): Promise { + // v1.37.0 — MANAGE. A titration step is record data and the dose is what + // the server acts on; administering one is the case the level names. + const { user, actor } = await requireRecordAuth("manage", "medications"); + const { id } = await params; - // Per-caller POST rate-limit — matches the 30/min sibling routes - // (inventory, side-effects). Generous for a hand-driven session, - // tight enough to cut off the spam case. - // - // v1.37.0 — C1: keyed on the ACTOR, so a manager burns their own - // allowance and cannot collect a fresh one by switching records. - const rl = await checkRateLimit( - `medication-glp1:post:${actor.id}`, - POST_RATE_LIMIT, - POST_WINDOW_MS, - ); - if (!rl.allowed) { - return apiError("Too many requests", 429, { - headers: rateLimitHeaders(rl), - }); - } + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; - const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: 64 * 1024, + // Per-caller POST rate-limit — matches the 30/min sibling routes + // (inventory, side-effects). Generous for a hand-driven session, + // tight enough to cut off the spam case. + // + // v1.37.0 — C1: keyed on the ACTOR, so a manager burns their own + // allowance and cannot collect a fresh one by switching records. + const rl = await checkRateLimit( + `medication-glp1:post:${actor.id}`, + POST_RATE_LIMIT, + POST_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many requests", 429, { + headers: rateLimitHeaders(rl), }); - if (jsonError) return jsonError; + } - const parsed = glp1PostBodySchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 W6 — multi-issue 422. - return returnAllZodIssues(parsed.error, 422); - } + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; - const ip = getClientIp(request); + const parsed = glp1PostBodySchema.safeParse(body); + if (!parsed.success) { + // v1.4.43 W6 — multi-issue 422. + return returnAllZodIssues(parsed.error, 422); + } - if (parsed.data.doseChange) { - const { effectiveFrom, doseValue, doseUnit, note } = - parsed.data.doseChange; - const created = await prisma.medicationDoseChange.create({ - data: { - medicationId: id, - effectiveFrom, - doseValue, - doseUnit, - // Encrypt the titration note at rest; the plaintext column stays null. - noteEncrypted: encryptNote(note), - note: null, - }, - }); + const ip = getClientIp(request); - await auditLog("medication.glp1.update", { - userId: user.id, - ipAddress: ip, - details: { - medicationId: id, - kind: "doseChange", - doseChangeId: created.id, - doseValue, - doseUnit, - }, - }); - - annotate({ - action: { - name: "medication.glp1.doseChange.create", - entity_type: "medication_dose_change", - entity_id: created.id, - }, - meta: { medication_id: id, doseValue, doseUnit }, - }); - - // A dose change shifts the daily-dose estimate the list payload's - // runway derivation uses. Hard-evict the medications + compliance - // buckets so the card reflects the new runway on the next read. - invalidateUserMedications(user.id, { evict: true }); - - return apiSuccess({ doseChange: shapeDoseChangeNote(created) }, 201); - } - - // The schema's XOR refinement guarantees `inventory` is present - // when `doseChange` is not, so the non-null assertion is safe. - // - // DEPRECATED branch (v1.16.10): this writes the legacy - // MedicationInventoryEvent running-sum ledger. The per-item - // endpoints (`POST /api/medications/[id]/inventory`, - // `PATCH /api/medications/[id]/inventory/[itemId]`) replaced it; - // GET above and the Coach snapshot read the ledger only while the - // medication has zero inventory items. New callers must register - // containers instead of posting deltas. - const { delta, reason } = parsed.data.inventory!; - const created = await prisma.medicationInventoryEvent.create({ - data: { medicationId: id, delta, reason }, + if (parsed.data.doseChange) { + const { effectiveFrom, doseValue, doseUnit, note } = parsed.data.doseChange; + const created = await prisma.medicationDoseChange.create({ + data: { + medicationId: id, + effectiveFrom, + doseValue, + doseUnit, + // Encrypt the titration note at rest; the plaintext column stays null. + noteEncrypted: encryptNote(note), + note: null, + }, }); await auditLog("medication.glp1.update", { @@ -340,27 +309,70 @@ export const POST = apiHandler( ipAddress: ip, details: { medicationId: id, - kind: "inventory", - inventoryEventId: created.id, - delta, - reason, + kind: "doseChange", + doseChangeId: created.id, + doseValue, + doseUnit, }, }); annotate({ action: { - name: "medication.glp1.inventory.create", - entity_type: "medication_inventory_event", + name: "medication.glp1.doseChange.create", + entity_type: "medication_dose_change", entity_id: created.id, }, - meta: { medication_id: id, delta }, + meta: { medication_id: id, doseValue, doseUnit }, }); - // A legacy ledger delta changes the stock the list payload reports - // for a medication with no per-item containers. Hard-evict so the - // card reflects it on the next read. + // A dose change shifts the daily-dose estimate the list payload's + // runway derivation uses. Hard-evict the medications + compliance + // buckets so the card reflects the new runway on the next read. invalidateUserMedications(user.id, { evict: true }); - return apiSuccess({ inventory: created }, 201); - }, -); + return apiSuccess({ doseChange: shapeDoseChangeNote(created) }, 201); + } + + // The schema's XOR refinement guarantees `inventory` is present + // when `doseChange` is not, so the non-null assertion is safe. + // + // DEPRECATED branch (v1.16.10): this writes the legacy + // MedicationInventoryEvent running-sum ledger. The per-item + // endpoints (`POST /api/medications/[id]/inventory`, + // `PATCH /api/medications/[id]/inventory/[itemId]`) replaced it; + // GET above and the Coach snapshot read the ledger only while the + // medication has zero inventory items. New callers must register + // containers instead of posting deltas. + const { delta, reason } = parsed.data.inventory!; + const created = await prisma.medicationInventoryEvent.create({ + data: { medicationId: id, delta, reason }, + }); + + await auditLog("medication.glp1.update", { + userId: user.id, + ipAddress: ip, + details: { + medicationId: id, + kind: "inventory", + inventoryEventId: created.id, + delta, + reason, + }, + }); + + annotate({ + action: { + name: "medication.glp1.inventory.create", + entity_type: "medication_inventory_event", + entity_id: created.id, + }, + meta: { medication_id: id, delta }, + }); + + // A legacy ledger delta changes the stock the list payload reports + // for a medication with no per-item containers. Hard-evict so the + // card reflects it on the next read. + invalidateUserMedications(user.id, { evict: true }); + + return apiSuccess({ inventory: created }, 201); +} diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index 8f7995eca..4db1ac99a 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -516,6 +516,7 @@ export const cyclePaths: NonNullable = { }, }, post: { + parameters: [idempotencyKeyParameter], tags: ["Cycle"], summary: "Create a custom cycle symptom (v1.15.1)", description: @@ -527,6 +528,7 @@ export const cyclePaths: NonNullable = { }, }, responses: { + ...idempotentWrite(), "201": { description: "The created custom symptom.", content: { diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index bdf31f54c..5c240f3b9 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -321,6 +321,7 @@ export const medicationPaths: NonNullable = { }, }, post: { + parameters: [idempotencyKeyParameter], tags: ["Medications"], summary: "Record a titration step or a legacy stock correction", description: @@ -331,6 +332,7 @@ export const medicationPaths: NonNullable = { content: { "application/json": { schema: glp1PostBodySchema } }, }, responses: { + ...idempotentWrite(), ...recordRefusal(), "201": { description: diff --git a/tests/integration/medication-write-idempotency.test.ts b/tests/integration/medication-write-idempotency.test.ts index 9f3277c4a..b83a9d138 100644 --- a/tests/integration/medication-write-idempotency.test.ts +++ b/tests/integration/medication-write-idempotency.test.ts @@ -1,5 +1,5 @@ /** - * The three medication create routes the native client replays out of its + * The four medication create routes the native client replays out of its * offline outbox, driven as routes against a real Postgres. * * Each case imports the shipped `POST` export and calls it twice with the same @@ -10,8 +10,8 @@ * keep passing after a route dropped it. * * The client sends the header on every replay, so a lost success response used - * to cost the user a duplicate medication, a duplicate symptom entry, or a - * duplicate pen in the supply count. `POST /api/medications` had a dedupe of + * to cost the user a duplicate medication, a duplicate symptom entry, a + * duplicate pen in the supply count, or a second titration step. `POST /api/medications` had a dedupe of * its own, but only for a MIRRORED create keyed on * `(externalSource, externalId)`; a manually entered medication carries * neither field and had nothing to collapse a retry onto — so the manual shape @@ -228,3 +228,41 @@ describe("POST /api/medications/[id]/inventory — replayed container", () => { expect(rows).toHaveLength(1); }); }); + +describe("POST /api/medications/[id]/glp1 — replayed titration step", () => { + it("replays the first response and writes exactly one dose change", async () => { + const medicationId = await seedMedication(); + const { POST } = await import("@/app/api/medications/[id]/glp1/route"); + const body = { + doseChange: { + effectiveFrom: "2026-09-01T00:00:00Z", + doseValue: 2.5, + doseUnit: "mg", + note: "stepped up after four weeks", + }, + }; + + const first = await (POST as Handler)( + post(`/api/medications/${medicationId}/glp1`, body, "outbox-glp1-1"), + { params: Promise.resolve({ id: medicationId }) }, + ); + expect(first.status).toBe(201); + const firstBody = await first.json(); + + const replay = await (POST as Handler)( + post(`/api/medications/${medicationId}/glp1`, body, "outbox-glp1-1"), + { params: Promise.resolve({ id: medicationId }) }, + ); + expect(replay.status).toBe(201); + expect(replay.headers.get("X-Idempotent-Replay")).toBe("true"); + expect(await replay.json()).toEqual(firstBody); + + // The titration row carries no natural key at all, so without the wrapper + // the replay would leave the medication with two dose changes on the same + // day and the runway estimate reading off the wrong one. + const rows = await getPrismaClient().medicationDoseChange.findMany({ + where: { medicationId }, + }); + expect(rows).toHaveLength(1); + }); +}); From ab6c1cd917917a60599ad7cb41184b2e047bafa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 07:42:07 +0200 Subject: [PATCH 04/56] fix(api): put a ceiling under the single-record writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The batch endpoints have been capped at 60 calls a minute since they were written. The per-record siblings a naive or older client loops over — the measurement create, the mood entry, both medication-intake writes, a lab result, a custom-metric entry, a cycle day-log, a vaccination, an allergy, a biomarker and an encounter — had no ceiling at all. That is backwards from the risk: the batch endpoint is the one a well-behaved client uses, and the unlimited path is the one a broken loop finds. All eleven share one generous per-account bucket, `record-write`, at 300 requests a minute. Five writes a second sustained for a full minute is beyond any hand-driven session and any sync burst, and every one of these routes is already wrapped in `withIdempotency`, so an honest retry storm is deduplicated before it is counted — the bucket exists to cut off the pathological loop, not to shape traffic. It keys on the ACTING account, the precedent the delegable medication routes set, so a delegate burns their own allowance rather than locking the record's owner out of their own log. The eleven operations publish the bucket name and the cap instead of the generic refusal, on the shared 429 that already declares `Retry-After` and the rate-limit triple. A test pulls the key and the window out of the query the limiter runs and holds the published sentence to them, so the paragraph cannot drift away from the bucket it describes. --- docs/api/openapi.yaml | 713 +++++++++--------- .../record-write-rate-limit-contract.test.ts | 144 ++++ src/app/api/allergies/route.ts | 12 +- .../__tests__/error-contract.test.ts | 11 + src/app/api/biomarkers/route.ts | 11 +- .../[id]/entries/__tests__/route.test.ts | 11 + .../api/custom-metrics/[id]/entries/route.ts | 9 + .../cycle/__tests__/external-id-floor.test.ts | 8 + src/app/api/cycle/day-logs/route.ts | 11 +- src/app/api/encounters/route.ts | 11 +- .../api/labs/__tests__/error-contract.test.ts | 11 + src/app/api/labs/route.ts | 11 +- .../__tests__/arrival-wiring.test.ts | 11 + .../__tests__/zod-multi-issue.test.ts | 11 + src/app/api/measurements/route.ts | 9 + .../[id]/intake/__tests__/route.test.ts | 11 + .../__tests__/source-provenance.test.ts | 11 + src/app/api/medications/[id]/intake/route.ts | 9 + .../intake/__tests__/route.test.ts | 11 + src/app/api/medications/intake/route.ts | 9 + .../api/mood-entries/__tests__/route.test.ts | 11 + src/app/api/mood-entries/route.ts | 9 + src/app/api/vaccinations/route.ts | 11 +- src/lib/openapi/routes/allergies.ts | 2 + src/lib/openapi/routes/biomarkers.ts | 2 + src/lib/openapi/routes/custom-metrics.ts | 2 + src/lib/openapi/routes/cycle.ts | 2 + src/lib/openapi/routes/encounters.ts | 2 + src/lib/openapi/routes/labs.ts | 2 + src/lib/openapi/routes/measurements.ts | 2 + src/lib/openapi/routes/medications/paths.ts | 4 +- src/lib/openapi/routes/mood.ts | 2 + src/lib/openapi/routes/shared.ts | 26 + src/lib/openapi/routes/vaccinations.ts | 2 + src/lib/rate-limit.ts | 38 + 35 files changed, 804 insertions(+), 358 deletions(-) create mode 100644 src/__tests__/record-write-rate-limit-contract.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3a2f547d0..3cb879a6b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -117,7 +117,7 @@ paths: `X-RateLimit-Reset` (the reset instant, ISO-8601, not epoch seconds). Back off on `Retry-After` rather than guessing. A 429 raised by a ceiling that is not one of these buckets — a daily AI budget, an upstream provider's own refusal relayed onward — carries none of them, because there is no bucket to describe. - headers: + headers: &a8 Retry-After: description: Whole seconds to wait before retrying. Rounded up, never below 1. schema: @@ -391,7 +391,18 @@ paths: "403": *a2 "409": *a7 "422": *a4 - "429": *a5 + "429": &a10 + description: Rate limit exceeded. This route shares one per-account bucket with the other single-record writes — + `record-write:`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns + their own allowance rather than the record owner's. Nothing was written. The `Retry-After`, + `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that bucket; back off + on `Retry-After` rather than guessing. A client with more than a handful of rows to send should use the + batch endpoint for its domain instead of looping this one. + headers: *a8 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/cycle/day-logs/{id}: patch: tags: @@ -675,7 +686,7 @@ paths: application/json: schema: $ref: "#/components/schemas/CyclePrefsPatchEnvelope" - "400": &a10 + "400": &a12 description: The request body is not parseable JSON. Nothing was read and nothing was written. `meta.errorCode` names the surface that refused it (`.body.invalid_json`). This is a client-side serialisation fault, not a validation failure — a body that parses but fails the schema is the 422 beside this. @@ -855,7 +866,7 @@ paths: type: string format: binary "401": *a1 - "403": &a8 + "403": &a9 description: "Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections do not reach this surface, and a read grant on a request that writes — the @@ -3457,7 +3468,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesBatchResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/measurements/series: @@ -3509,7 +3520,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/measurements: @@ -3706,7 +3717,7 @@ paths: schema: $ref: "#/components/schemas/ListMeasurementsResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -3745,7 +3756,7 @@ paths: schema: $ref: "#/components/schemas/CreateMeasurementResponse" "401": *a1 - "403": *a8 + "403": *a9 "409": description: A measurement with this data already exists — the `(userId, type, source, externalId)` dedup index rejected the write. @@ -3761,7 +3772,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a5 + "429": *a10 /api/measurements/{id}: get: tags: @@ -3782,7 +3793,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Measurement not found (or owned by another user). content: @@ -3830,7 +3841,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMeasurementResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Measurement not found (or owned by another user). content: @@ -3866,7 +3877,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMeasurementResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Measurement not found (or owned by another user). content: @@ -3968,7 +3979,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsBulkDeleteResponse" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -3997,7 +4008,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsRestoreResponse" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -4028,7 +4039,7 @@ paths: schema: $ref: "#/components/schemas/SleepNightResponse" "401": *a1 - "403": &a9 + "403": &a11 description: >- Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a @@ -4069,7 +4080,7 @@ paths: schema: $ref: "#/components/schemas/SleepRhythmResponse" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/personal-records: @@ -4116,7 +4127,7 @@ paths: schema: $ref: "#/components/schemas/ListPersonalRecordsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": description: "`metricType` or `limit` carried an unacceptable value. `meta.errorCode` = `personal_records.invalid_query`, with the offending path under `details.issues`." @@ -4171,7 +4182,7 @@ paths: schema: $ref: "#/components/schemas/ListWorkoutsResponse" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/workouts/{id}: @@ -4207,7 +4218,7 @@ paths: schema: $ref: "#/components/schemas/GetWorkoutResponse" "401": *a1 - "403": &a13 + "403": &a15 description: 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.' @@ -4376,7 +4387,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeAggregateEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": description: The query failed validation — a missing or unknown `scope`, or a `days` outside 1..365. Multi-issue envelope; an audit breadcrumb is filed under the RECORD as `medications.intake.list.validation-failed` with @@ -4501,7 +4512,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a5 + "429": *a10 /api/medications/{id}/glp1: get: tags: @@ -4526,7 +4537,7 @@ paths: schema: $ref: "#/components/schemas/Glp1DetailResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -4610,7 +4621,7 @@ paths: schema: $ref: "#/components/schemas/Glp1PostResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -4745,7 +4756,7 @@ paths: schema: $ref: "#/components/schemas/ReminderPhaseConfigEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -4861,7 +4872,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationSideEffectsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -4900,7 +4911,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationSideEffectEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -4945,7 +4956,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationSideEffectEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: No such entry for this medication and this record. An unknown id, another medication's entry and another record's entry are indistinguishable. @@ -4993,7 +5004,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationIntakeEventEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: No live event with that id under this medication and record. An unknown id and an already-tombstoned one are indistinguishable. @@ -5041,7 +5052,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationIntakeEventEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: No event with that id under this medication and record. content: @@ -5084,7 +5095,7 @@ paths: schema: $ref: "#/components/schemas/BulkDeleteMedicationIntakeEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -5126,7 +5137,7 @@ paths: schema: $ref: "#/components/schemas/PurgeMedicationIntakeEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -5171,7 +5182,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportAcceptedEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -5251,7 +5262,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationsResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -5282,7 +5293,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationResponse" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -5302,7 +5313,7 @@ paths: schema: $ref: "#/components/schemas/MedicationListLayoutResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 put: @@ -5336,7 +5347,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a11 + "422": &a13 description: Request validation failed. When the body carried a `baseUpdatedAt` that could not be parsed as an ISO-8601 timestamp — including an explicit `null` — `meta.errorCode` = `invalid_base_updated_at` and nothing was written. Omit the key entirely for the unconditional write; do not send `null` for it. @@ -5381,7 +5392,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5418,7 +5429,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5491,7 +5502,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationIntakeResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5500,7 +5511,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 "422": *a4 - "429": *a5 + "429": *a10 get: tags: - Medications @@ -5565,7 +5576,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationIntakeEventsResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5598,7 +5609,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationInventoryResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5634,7 +5645,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationInventoryItemResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5678,7 +5689,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationInventoryItemResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -5713,7 +5724,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationInventoryItemResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -5791,7 +5802,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationCadenceResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5819,7 +5830,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationComplianceResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/medications/{id}/compliance: @@ -5994,7 +6005,7 @@ paths: schema: $ref: "#/components/schemas/ListScheduleRevisionsResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -6032,7 +6043,7 @@ paths: schema: $ref: "#/components/schemas/CreateScheduleRevisionResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -6076,7 +6087,7 @@ paths: schema: $ref: "#/components/schemas/UpdateScheduleRevisionResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication or revision not found (or owned by another user). content: @@ -6117,7 +6128,7 @@ paths: schema: $ref: "#/components/schemas/DeleteScheduleRevisionResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication or revision not found (or owned by another user). content: @@ -6180,7 +6191,7 @@ paths: schema: $ref: "#/components/schemas/MedicationDoseHistoryImportQueuedEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": description: A dose-history import is already in progress. content: @@ -6242,7 +6253,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: No such job for this medication (or owned by another user). content: @@ -6276,7 +6287,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: No such job for this account (or owned by another user). content: @@ -6306,7 +6317,7 @@ paths: schema: $ref: "#/components/schemas/ProfileSummaryEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/ai/test: @@ -6496,7 +6507,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutCoachPrefsResponse" - "400": *a10 + "400": *a12 "401": *a1 "409": description: Coach preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read @@ -6506,7 +6517,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a11 + "422": *a13 "429": *a5 /api/auth/me/disable-coach: get: @@ -6544,7 +6555,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchDisableCoachResponse" - "400": *a10 + "400": *a12 "401": *a1 "422": *a4 "429": *a5 @@ -6699,7 +6710,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchModulesResponse" - "400": *a10 + "400": *a12 "401": *a1 "409": description: Module preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. @@ -6709,7 +6720,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a11 + "422": *a13 "429": *a5 /api/auth/me/source-priority: get: @@ -7540,7 +7551,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutSourcePriorityResponse" - "400": *a10 + "400": *a12 "401": *a1 "422": *a4 "429": *a5 @@ -7727,7 +7738,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchNotificationPrefsResponse" - "400": *a10 + "400": *a12 "401": *a1 "409": description: Notification preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. @@ -7737,7 +7748,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a11 + "422": *a13 "429": *a5 /api/auth/me/report-selection: get: @@ -7778,7 +7789,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutReportSelectionResponseEnvelope" - "400": *a10 + "400": *a12 "401": *a1 "422": *a4 "429": *a5 @@ -8753,7 +8764,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a11 + "422": *a13 "429": *a5 delete: tags: @@ -8842,7 +8853,7 @@ paths: schema: $ref: "#/components/schemas/CoachNudgeStatus" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/coach/seen: @@ -8957,7 +8968,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a11 + "422": *a13 "429": *a5 /api/anamnesis/facts: get: @@ -8975,7 +8986,7 @@ paths: schema: $ref: "#/components/schemas/GetAnamnesisFactsResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -9085,7 +9096,7 @@ paths: schema: $ref: "#/components/schemas/CorrectAnamnesisFactResponse" "401": *a1 - "404": &a12 + "404": &a14 description: No current revision with this id exists for the authenticated account. content: application/json: @@ -9121,7 +9132,7 @@ paths: schema: $ref: "#/components/schemas/RemoveAnamnesisFactResponse" "401": *a1 - "404": *a12 + "404": *a14 "409": description: Anamnesis fact changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -9853,7 +9864,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSnapshotResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/comprehensive: @@ -9911,7 +9922,7 @@ paths: schema: $ref: "#/components/schemas/InsightsCardsEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/insights/pregenerate: @@ -10222,7 +10233,7 @@ paths: schema: $ref: "#/components/schemas/CoachReadStripEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/insights/coach/seeded-question: @@ -10245,7 +10256,7 @@ paths: schema: $ref: "#/components/schemas/CoachSeededQuestionEnvelope" "401": *a1 - "403": *a13 + "403": *a15 "422": *a4 "429": *a5 /api/insights/narrative: @@ -10291,7 +10302,7 @@ paths: schema: $ref: "#/components/schemas/NarrativeEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/insights/glp1-timeline: @@ -10328,7 +10339,7 @@ paths: schema: $ref: "#/components/schemas/Glp1TimelineEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/pulse/intraday: @@ -10363,7 +10374,7 @@ paths: schema: $ref: "#/components/schemas/IntradayPulseEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/insights/settings: @@ -10437,7 +10448,7 @@ paths: schema: $ref: "#/components/schemas/TargetsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/analytics/range: @@ -10507,7 +10518,7 @@ paths: schema: $ref: "#/components/schemas/BloodPressureStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/pulse-status: @@ -10536,7 +10547,7 @@ paths: schema: $ref: "#/components/schemas/PulseStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/weight-status: @@ -10565,7 +10576,7 @@ paths: schema: $ref: "#/components/schemas/WeightStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/bmi-status: @@ -10594,7 +10605,7 @@ paths: schema: $ref: "#/components/schemas/BmiStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/mood-status: @@ -10623,7 +10634,7 @@ paths: schema: $ref: "#/components/schemas/MoodStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/medication-compliance-status: @@ -10652,7 +10663,7 @@ paths: schema: $ref: "#/components/schemas/MedicationComplianceStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/metric-status: @@ -10744,7 +10755,7 @@ paths: schema: $ref: "#/components/schemas/MetricStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/biomarker-assessment: @@ -10786,7 +10797,7 @@ paths: schema: $ref: "#/components/schemas/BiomarkerAssessmentResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/derived: @@ -10895,7 +10906,7 @@ paths: schema: $ref: "#/components/schemas/DerivedMetricResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/derived/batch: @@ -10934,7 +10945,7 @@ paths: schema: $ref: "#/components/schemas/DerivedBatchResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/glp1-plateau: @@ -10954,7 +10965,7 @@ paths: schema: $ref: "#/components/schemas/InsightsGlp1PlateauResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/ecg: @@ -10977,7 +10988,7 @@ paths: schema: $ref: "#/components/schemas/EcgListResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 post: @@ -11015,7 +11026,7 @@ paths: schema: $ref: "#/components/schemas/EcgIngestCreatedEnvelope" "401": *a1 - "403": *a13 + "403": *a15 "422": *a4 "429": *a5 /api/insights/ecg/{id}: @@ -11055,7 +11066,7 @@ paths: schema: $ref: "#/components/schemas/EcgDetailResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "404": description: No ECG recording with that id for the authenticated user (existence sealed — a foreign id is indistinguishable from a missing one). @@ -11087,7 +11098,7 @@ paths: schema: $ref: "#/components/schemas/RhythmEventsResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/insights/correlations: @@ -11108,7 +11119,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationDiscoveryResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/patterns: @@ -11126,7 +11137,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationPatternListResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/insights/patterns/{id}: @@ -11156,7 +11167,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCorrelationPatternResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "404": description: No current pattern with that id for this account. content: @@ -11181,7 +11192,7 @@ paths: schema: $ref: "#/components/schemas/InsightsHealthStatusEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/breathing-screening: @@ -11200,7 +11211,7 @@ paths: schema: $ref: "#/components/schemas/InsightsBreathingScreeningEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/labs-changes: @@ -11219,7 +11230,7 @@ paths: schema: $ref: "#/components/schemas/InsightsLabsChangesEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/mood/analytics: @@ -11242,7 +11253,7 @@ paths: schema: $ref: "#/components/schemas/MoodDailySeriesEnvelope" "401": *a1 - "403": *a13 + "403": *a15 "422": *a4 "429": *a5 /api/mood/insights: @@ -11264,7 +11275,7 @@ paths: schema: $ref: "#/components/schemas/MoodAggregatesEnvelope" "401": *a1 - "403": *a13 + "403": *a15 "422": *a4 "429": *a5 /api/mood/prognosis: @@ -11287,7 +11298,7 @@ paths: schema: $ref: "#/components/schemas/MoodPrognosisEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/mood-entries/bulk: @@ -11397,7 +11408,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" "401": *a1 - "404": &a14 + "404": &a16 description: Not found / not owned by the caller. content: application/json: @@ -11432,7 +11443,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" "401": *a1 - "404": *a14 + "404": *a16 "422": *a4 "429": *a5 /api/mood/tags/{key}/hidden: @@ -11468,7 +11479,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "404": *a14 + "404": *a16 "422": *a4 "429": *a5 /api/mood/tags/groups: @@ -11521,7 +11532,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" "401": *a1 - "404": *a14 + "404": *a16 "422": *a4 "429": *a5 delete: @@ -11552,7 +11563,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" "401": *a1 - "404": *a14 + "404": *a16 "422": *a4 "429": *a5 /api/mood/tags/layout: @@ -11600,7 +11611,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a11 + "422": *a13 "429": *a5 /api/mood-entries: get: @@ -11683,7 +11694,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntryListEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -11716,8 +11727,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a8 - "409": &a15 + "403": *a9 + "409": &a17 description: A mood entry with the same `(date, moodLoggedAt)` instant already exists. `meta.errorCode` = `mood.duplicate_timestamp`. Only a request without `externalId` can answer this — with one, the write upserts in place. @@ -11726,7 +11737,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a5 + "429": *a10 /api/mood-entries/{id}: get: tags: @@ -11748,8 +11759,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a8 - "404": *a14 + "403": *a9 + "404": *a16 "422": *a4 "429": *a5 put: @@ -11783,9 +11794,9 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a8 - "404": *a14 - "409": *a15 + "403": *a9 + "404": *a16 + "409": *a17 "422": *a4 "429": *a5 delete: @@ -11810,8 +11821,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryDeleteEnvelope" "401": *a1 - "403": *a8 - "404": *a14 + "403": *a9 + "404": *a16 "422": *a4 "429": *a5 /api/mood-entries/restore: @@ -11840,7 +11851,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesRestoreEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -11869,7 +11880,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesBulkDeleteEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -11913,7 +11924,7 @@ paths: schema: $ref: "#/components/schemas/MoodLinkedContextEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/settings/ntfy: @@ -11961,7 +11972,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutNtfySettingsResponse" - "400": &a16 + "400": &a18 description: Body is not parseable JSON. content: application/json: @@ -11974,13 +11985,13 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": &a17 + "413": &a19 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a18 + "415": &a20 description: Content-Type is not `application/json`. content: application/json: @@ -12038,7 +12049,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutTelegramSettingsResponse" - "400": *a16 + "400": *a18 "401": *a1 "403": description: The operator switched Telegram off for this instance and the body asked to enable it. No webhook was @@ -12053,8 +12064,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": *a17 - "415": *a18 + "413": *a19 + "415": *a20 "422": description: Body matched neither accepted shape; or `enabled` is true without both a bot token and a chat id; or the deployment's `APP_URL` is not a public HTTPS host on port 80/88/443/8443; or Telegram refused the webhook @@ -12108,10 +12119,10 @@ paths: application/json: schema: $ref: "#/components/schemas/PutWebhookSettingsResponse" - "400": *a16 + "400": *a18 "401": *a1 - "413": *a17 - "415": *a18 + "413": *a19 + "415": *a20 "422": description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored. content: @@ -12135,7 +12146,7 @@ paths: schema: $ref: "#/components/schemas/GetReminderThresholdsResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/settings/global-services: @@ -12575,7 +12586,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentLatestForKindEnvelope" - $ref: "#/components/schemas/ConsentLatestByKindEnvelope" - "400": &a19 + "400": &a21 description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`. Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422." content: @@ -12624,7 +12635,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentRevokeForKindEnvelope" - $ref: "#/components/schemas/ConsentRevokeAllEnvelope" - "400": *a19 + "400": *a21 "401": *a1 "422": *a4 "429": *a5 @@ -12860,7 +12871,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderListEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -12885,7 +12896,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCreatedEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -12909,8 +12920,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 - "403": *a8 - "404": &a20 + "403": *a9 + "404": &a22 description: Measurement reminder not found / not owned. content: application/json: @@ -12944,8 +12955,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 - "403": *a8 - "404": *a20 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 delete: @@ -12967,8 +12978,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 - "403": *a8 - "404": *a20 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/satisfy: @@ -12992,8 +13003,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 - "403": *a8 - "404": *a20 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/complete: @@ -13020,8 +13031,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" "401": *a1 - "403": *a8 - "404": *a20 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/skip: @@ -13050,8 +13061,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSkipEnvelope" "401": *a1 - "403": *a8 - "404": *a20 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/snooze: @@ -13084,8 +13095,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSnoozeEnvelope" "401": *a1 - "403": *a8 - "404": *a20 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/history: @@ -13125,8 +13136,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderHistoryEnvelope" "401": *a1 - "403": *a8 - "404": *a20 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/labs: @@ -13198,7 +13209,7 @@ paths: schema: $ref: "#/components/schemas/ListLabResultsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -13225,10 +13236,10 @@ paths: schema: $ref: "#/components/schemas/CreateLabResultResponse" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 - "429": *a5 + "429": *a10 /api/labs/{id}: get: tags: @@ -13249,8 +13260,8 @@ paths: schema: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 - "403": *a8 - "404": &a21 + "403": *a9 + "404": &a23 description: Lab result not found (or owned by another user). content: application/json: @@ -13284,8 +13295,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 - "403": *a8 - "404": *a21 + "403": *a9 + "404": *a23 "422": *a4 "429": *a5 delete: @@ -13307,8 +13318,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 - "403": *a8 - "404": *a21 + "403": *a9 + "404": *a23 "422": *a4 "429": *a5 /api/labs/restore: @@ -13334,7 +13345,7 @@ paths: schema: $ref: "#/components/schemas/RestoreLabResultsResponse" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -13438,7 +13449,7 @@ paths: schema: $ref: "#/components/schemas/ListBiomarkersEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -13463,15 +13474,15 @@ paths: schema: $ref: "#/components/schemas/CreateBiomarkerResponse" "401": *a1 - "403": *a8 - "409": &a23 + "403": *a9 + "409": &a25 description: A biomarker with this name already exists for the caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a5 + "429": *a10 /api/biomarkers/{id}: get: tags: @@ -13492,8 +13503,8 @@ paths: schema: $ref: "#/components/schemas/GetBiomarkerResponse" "401": *a1 - "403": *a8 - "404": &a22 + "403": *a9 + "404": &a24 description: Biomarker not found (or owned by another user). content: application/json: @@ -13527,9 +13538,9 @@ paths: schema: $ref: "#/components/schemas/UpdateBiomarkerResponse" "401": *a1 - "403": *a8 - "404": *a22 - "409": *a23 + "403": *a9 + "404": *a24 + "409": *a25 "422": *a4 "429": *a5 delete: @@ -13552,7 +13563,7 @@ paths: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 - "404": *a22 + "404": *a24 "422": *a4 "429": *a5 /api/encounters: @@ -13610,7 +13621,7 @@ paths: schema: $ref: "#/components/schemas/ListEncountersEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -13636,10 +13647,10 @@ paths: schema: $ref: "#/components/schemas/CreateEncounterEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 - "429": *a5 + "429": *a10 /api/encounters/suggest: get: tags: @@ -13667,7 +13678,7 @@ paths: schema: $ref: "#/components/schemas/SuggestEncounterEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/encounters/{id}: @@ -13690,8 +13701,8 @@ paths: schema: $ref: "#/components/schemas/GetEncounterEnvelope" "401": *a1 - "403": *a8 - "404": &a24 + "403": *a9 + "404": &a26 description: Visit not found (or owned by another user). content: application/json: @@ -13724,8 +13735,8 @@ paths: schema: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 - "403": *a8 - "404": *a24 + "403": *a9 + "404": *a26 "422": *a4 "429": *a5 delete: @@ -13749,8 +13760,8 @@ paths: schema: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 - "403": *a8 - "404": *a24 + "403": *a9 + "404": *a26 "422": *a4 "429": *a5 /api/encounters/{id}/restore: @@ -13775,8 +13786,8 @@ paths: schema: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 - "403": *a8 - "404": *a24 + "403": *a9 + "404": *a26 "422": *a4 "429": *a5 /api/encounters/{id}/links: @@ -13806,7 +13817,7 @@ paths: schema: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 - "404": *a24 + "404": *a26 "422": *a4 "429": *a5 delete: @@ -13835,7 +13846,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 - "404": *a24 + "404": *a26 "422": *a4 "429": *a5 /api/practitioners: @@ -13865,7 +13876,7 @@ paths: schema: $ref: "#/components/schemas/ListPractitionersEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -13889,7 +13900,7 @@ paths: schema: $ref: "#/components/schemas/CreatePractitionerEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -13913,8 +13924,8 @@ paths: schema: $ref: "#/components/schemas/GetPractitionerEnvelope" "401": *a1 - "403": *a8 - "404": &a25 + "403": *a9 + "404": &a27 description: Practitioner not found (or owned by another user). content: application/json: @@ -13947,8 +13958,8 @@ paths: schema: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 - "403": *a8 - "404": *a25 + "403": *a9 + "404": *a27 "422": *a4 "429": *a5 delete: @@ -13971,8 +13982,8 @@ paths: schema: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 - "403": *a8 - "404": *a25 + "403": *a9 + "404": *a27 "422": *a4 "429": *a5 /api/practitioners/{id}/restore: @@ -13997,8 +14008,8 @@ paths: schema: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 - "403": *a8 - "404": *a25 + "403": *a9 + "404": *a27 "422": *a4 "429": *a5 /api/vaccinations: @@ -14041,7 +14052,7 @@ paths: schema: $ref: "#/components/schemas/ListVaccinationsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -14068,10 +14079,10 @@ paths: schema: $ref: "#/components/schemas/CreateVaccinationEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 - "429": *a5 + "429": *a10 /api/vaccinations/{id}: get: tags: @@ -14092,8 +14103,8 @@ paths: schema: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 - "403": *a8 - "404": &a26 + "403": *a9 + "404": &a28 description: Vaccination not found (or owned by another user). content: application/json: @@ -14127,8 +14138,8 @@ paths: schema: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 - "403": *a8 - "404": *a26 + "403": *a9 + "404": *a28 "422": *a4 "429": *a5 delete: @@ -14152,8 +14163,8 @@ paths: schema: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 - "403": *a8 - "404": *a26 + "403": *a9 + "404": *a28 "422": *a4 "429": *a5 /api/vaccinations/{id}/restore: @@ -14177,8 +14188,8 @@ paths: schema: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 - "403": *a8 - "404": *a26 + "403": *a9 + "404": *a28 "422": *a4 "429": *a5 /api/vaccinations/{id}/booster: @@ -14218,8 +14229,8 @@ paths: schema: $ref: "#/components/schemas/MintVaccinationBoosterEnvelope" "401": *a1 - "403": *a8 - "404": *a26 + "403": *a9 + "404": *a28 "422": *a4 "429": *a5 /api/vaccinations/suggest: @@ -14247,7 +14258,7 @@ paths: schema: $ref: "#/components/schemas/VaccinationSuggestEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/vaccinations/{id}/links: @@ -14277,7 +14288,7 @@ paths: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 - "404": *a26 + "404": *a28 "422": *a4 "429": *a5 delete: @@ -14306,7 +14317,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 - "404": *a26 + "404": *a28 "422": *a4 "429": *a5 /api/illness/episodes: @@ -14339,7 +14350,7 @@ paths: schema: $ref: "#/components/schemas/ListIllnessEpisodesEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -14365,7 +14376,7 @@ paths: schema: $ref: "#/components/schemas/CreateIllnessEpisodeEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -14389,8 +14400,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 - "403": *a8 - "404": &a27 + "403": *a9 + "404": &a29 description: Illness episode not found (or owned by another user). content: application/json: @@ -14424,8 +14435,8 @@ paths: schema: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 - "403": *a8 - "404": *a27 + "403": *a9 + "404": *a29 "422": *a4 "429": *a5 delete: @@ -14448,8 +14459,8 @@ paths: schema: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 - "403": *a8 - "404": *a27 + "403": *a9 + "404": *a29 "422": *a4 "429": *a5 /api/illness/episodes/{id}/restore: @@ -14476,8 +14487,8 @@ paths: schema: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 - "403": *a8 - "404": *a27 + "403": *a9 + "404": *a29 "409": *a7 "422": *a4 "429": *a5 @@ -14509,8 +14520,8 @@ paths: schema: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 - "403": *a8 - "404": *a27 + "403": *a9 + "404": *a29 "422": *a4 "429": *a5 /api/illness/episodes/{id}/day-logs: @@ -14560,8 +14571,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 - "403": *a8 - "404": *a27 + "403": *a9 + "404": *a29 "422": *a4 "429": *a5 post: @@ -14597,8 +14608,8 @@ paths: schema: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 - "403": *a8 - "404": *a27 + "403": *a9 + "404": *a29 "422": *a4 "429": *a5 /api/illness/episodes/{id}/correlation: @@ -14625,7 +14636,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a27 + "404": *a29 "422": *a4 "429": *a5 /api/illness/insights: @@ -14698,7 +14709,7 @@ paths: schema: $ref: "#/components/schemas/ListMentalHealthAssessmentsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -14724,7 +14735,7 @@ paths: schema: $ref: "#/components/schemas/CreateMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -14751,7 +14762,7 @@ paths: schema: $ref: "#/components/schemas/GetMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/allergies: @@ -14784,7 +14795,7 @@ paths: schema: $ref: "#/components/schemas/ListAllergiesEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -14809,10 +14820,10 @@ paths: schema: $ref: "#/components/schemas/CreateAllergyEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 - "429": *a5 + "429": *a10 /api/allergies/{id}: get: tags: @@ -14833,8 +14844,8 @@ paths: schema: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 - "403": *a8 - "404": &a28 + "403": *a9 + "404": &a30 description: Allergy not found (or owned by another user). content: application/json: @@ -14867,8 +14878,8 @@ paths: schema: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 - "403": *a8 - "404": *a28 + "403": *a9 + "404": *a30 "422": *a4 "429": *a5 delete: @@ -14891,8 +14902,8 @@ paths: schema: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 - "403": *a8 - "404": *a28 + "403": *a9 + "404": *a30 "422": *a4 "429": *a5 /api/family-history: @@ -14916,7 +14927,7 @@ paths: schema: $ref: "#/components/schemas/ListFamilyHistoryEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -14941,7 +14952,7 @@ paths: schema: $ref: "#/components/schemas/CreateFamilyHistoryEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -14965,8 +14976,8 @@ paths: schema: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 - "403": *a8 - "404": &a29 + "403": *a9 + "404": &a31 description: Family history entry not found (or owned by another user). content: application/json: @@ -14999,8 +15010,8 @@ paths: schema: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 - "403": *a8 - "404": *a29 + "403": *a9 + "404": *a31 "422": *a4 "429": *a5 delete: @@ -15023,8 +15034,8 @@ paths: schema: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 - "403": *a8 - "404": *a29 + "403": *a9 + "404": *a31 "422": *a4 "429": *a5 /api/documents/inbound: @@ -15137,7 +15148,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentListEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -15272,7 +15283,7 @@ paths: schema: $ref: "#/components/schemas/DocumentUsageEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/bulk: @@ -15319,7 +15330,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentDetailEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -15438,7 +15449,7 @@ paths: type: string format: binary "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/{id}/thumbnail: @@ -15467,7 +15478,7 @@ paths: type: string format: binary "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/{id}/extract: @@ -15894,7 +15905,7 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -15919,7 +15930,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a31 + "409": &a33 description: A custom metric with this name already exists for the caller. content: application/json: @@ -15947,8 +15958,8 @@ paths: schema: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 - "403": *a8 - "404": &a30 + "403": *a9 + "404": &a32 description: Custom metric not found (or owned by another user). content: application/json: @@ -15982,8 +15993,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a30 - "409": *a31 + "404": *a32 + "409": *a33 "422": *a4 "429": *a5 delete: @@ -16006,7 +16017,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a30 + "404": *a32 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -16049,8 +16060,8 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 - "403": *a8 - "404": *a30 + "403": *a9 + "404": *a32 "422": *a4 "429": *a5 post: @@ -16079,10 +16090,10 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a30 + "404": *a32 "409": *a7 "422": *a4 - "429": *a5 + "429": *a10 /api/custom-metrics/{id}/entries/{entryId}: patch: tags: @@ -16115,7 +16126,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a32 + "404": &a34 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -16148,7 +16159,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a32 + "404": *a34 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -16486,7 +16497,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a33 + "403": &a35 description: >- The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is @@ -16541,7 +16552,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a33 + "403": *a35 "422": *a4 "429": *a5 /api/nutrients/water: @@ -16570,7 +16581,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a33 + "403": *a35 "409": *a7 "422": *a4 "429": *a5 @@ -17072,26 +17083,26 @@ paths: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" - "400": &a34 + "400": &a36 description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "413": &a35 + "413": &a37 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a36 + "415": &a38 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a37 + "422": &a39 description: "`clientId` and `clientSecret` are both required and each must be 1–200 characters. The envelope carries a single fixed message, not per-field issues." content: @@ -17277,11 +17288,11 @@ paths: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" - "400": *a34 + "400": *a36 "401": *a1 - "413": *a35 - "415": *a36 - "422": *a37 + "413": *a37 + "415": *a38 + "422": *a39 "429": *a5 delete: tags: @@ -17424,11 +17435,11 @@ paths: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" - "400": *a34 + "400": *a36 "401": *a1 - "413": *a35 - "415": *a36 - "422": *a37 + "413": *a37 + "415": *a38 + "422": *a39 "429": *a5 delete: tags: @@ -17581,11 +17592,11 @@ paths: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" - "400": *a34 + "400": *a36 "401": *a1 - "413": *a35 - "415": *a36 - "422": *a37 + "413": *a37 + "415": *a38 + "422": *a39 "429": *a5 delete: tags: @@ -17661,21 +17672,21 @@ paths: schema: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 - "422": &a38 + "422": &a40 description: No usable connection for this provider — nothing stored, or the stored grant could no longer be resolved into an access token. `meta.errorCode` = `not_configured`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a39 + "429": &a41 description: More than 5 requests in 60 s from this user. `meta.errorCode` = `rate_limited_self` — this is the local limiter, distinct from a `rate_limited` 502 which is the provider's. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": &a40 + "502": &a42 description: "The probe did not complete. `meta.errorCode` names the class: `credentials_rejected` (the provider answered 401/403 — the stored grant is gone), `rate_limited` (the PROVIDER answered 429, which is not this endpoint's own limiter), `upstream_error` (5xx), `timeout` (no answer within 5 s), `connection_failed` @@ -17720,11 +17731,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" - "400": *a34 + "400": *a36 "401": *a1 - "413": *a35 - "415": *a36 - "422": *a37 + "413": *a37 + "415": *a38 + "422": *a39 "429": *a5 delete: tags: @@ -17800,9 +17811,9 @@ paths: schema: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 - "422": *a38 - "429": *a39 - "502": *a40 + "422": *a40 + "429": *a41 + "502": *a42 /api/strava/credentials: get: tags: @@ -17838,11 +17849,11 @@ paths: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" - "400": *a34 + "400": *a36 "401": *a1 - "413": *a35 - "415": *a36 - "422": *a37 + "413": *a37 + "415": *a38 + "422": *a39 "429": *a5 delete: tags: @@ -17920,9 +17931,9 @@ paths: schema: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 - "422": *a38 - "429": *a39 - "502": *a40 + "422": *a40 + "429": *a41 + "502": *a42 /api/integrations/status: get: tags: @@ -17958,7 +17969,7 @@ paths: $ref: "#/components/schemas/FitbitResumeEnvelope" "401": *a1 "422": *a4 - "429": *a39 + "429": *a41 /api/integrations/fitbit/test: post: tags: @@ -17976,9 +17987,9 @@ paths: schema: $ref: "#/components/schemas/FitbitIntegrationTestEnvelope" "401": *a1 - "422": *a38 - "429": *a39 - "502": *a40 + "422": *a40 + "429": *a41 + "502": *a42 /api/integrations/google-health/resume: post: tags: @@ -17995,7 +18006,7 @@ paths: $ref: "#/components/schemas/GoogleHealthResumeEnvelope" "401": *a1 "422": *a4 - "429": *a39 + "429": *a41 /api/integrations/google-health/test: post: tags: @@ -18028,9 +18039,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a38 - "429": *a39 - "502": *a40 + "422": *a40 + "429": *a41 + "502": *a42 /api/integrations/whoop/resume: post: tags: @@ -18047,7 +18058,7 @@ paths: $ref: "#/components/schemas/WhoopResumeEnvelope" "401": *a1 "422": *a4 - "429": *a39 + "429": *a41 /api/integrations/whoop/test: post: tags: @@ -18064,9 +18075,9 @@ paths: schema: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 - "422": *a38 - "429": *a39 - "502": *a40 + "422": *a40 + "429": *a41 + "502": *a42 /api/integrations/withings/resume: post: tags: @@ -18084,7 +18095,7 @@ paths: $ref: "#/components/schemas/WithingsResumeEnvelope" "401": *a1 "422": *a4 - "429": *a39 + "429": *a41 /api/integrations/withings/test: post: tags: @@ -18102,8 +18113,8 @@ paths: schema: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 - "422": *a38 - "429": *a39 + "422": *a40 + "429": *a41 "502": description: "The probe did not complete. The shared codes apply (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed`) and this path adds one of its own: @@ -18131,7 +18142,7 @@ paths: schema: $ref: "#/components/schemas/DailyDigestEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/daily/digest/dismiss: @@ -18167,7 +18178,7 @@ paths: schema: $ref: "#/components/schemas/DismissPriorityItemEnvelope" "401": *a1 - "403": *a13 + "403": *a15 "422": *a4 "429": *a5 /api/dashboard/chart-overlay-prefs: @@ -18224,7 +18235,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSummaryEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/dashboard/widgets: @@ -18243,7 +18254,7 @@ paths: schema: $ref: "#/components/schemas/DashboardLayout" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 put: @@ -18277,7 +18288,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a11 + "422": *a13 "429": *a5 delete: tags: @@ -18340,7 +18351,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a41 + "403": &a43 description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is byte-identical for an account that does not exist and one that granted @@ -18365,7 +18376,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a41 + "403": *a43 "422": *a4 "429": *a5 post: @@ -18460,7 +18471,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a41 + "403": *a43 "404": description: No such invitation for this account. content: @@ -18504,7 +18515,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a41 + "403": *a43 "404": description: No such grant on this account's record. content: @@ -18545,7 +18556,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a41 + "403": *a43 "404": description: No such grant held by this account. content: @@ -18927,7 +18938,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a41 + "403": *a43 "409": description: "The record this browser session is on is not the record the request asserted. `meta.errorCode` is `sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records — @@ -19257,7 +19268,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 - "403": &a42 + "403": &a44 description: The operator has disabled the API instance-wide. Every route in this module refuses while that setting is off, for a cookie session as well as for a Bearer token; it is not a per-user permission and retrying will not help. @@ -19290,7 +19301,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 - "403": *a42 + "403": *a44 "404": description: No live connection with this id for this caller — also the answer for an already-revoked one, so a repeat revoke is 404 rather than an idempotent 200. @@ -19317,7 +19328,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a42 + "403": *a44 "422": *a4 "429": *a5 post: @@ -19347,7 +19358,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a42 + "403": *a44 "413": description: Body exceeds 16 KiB. content: @@ -19390,7 +19401,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 - "403": *a42 + "403": *a44 "404": description: No `health:read` token with this id for this caller. An already-revoked token is still found, so a repeat revoke answers 200. @@ -19417,7 +19428,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/record-settings/integrations: @@ -19437,7 +19448,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsIntegrationsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/record-settings/{family}: @@ -19448,7 +19459,7 @@ paths: description: Returns the named family's resolved settings for the record the session is acting on. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - &a43 + - &a45 name: family in: path required: true @@ -19472,7 +19483,7 @@ paths: schema: $ref: "#/components/schemas/GetRecordSettingsFamilyEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Either the path named a family outside the closed set, or the record row itself is gone. The two are not distinguished, and the first is answered before the caller is authenticated at all. @@ -19492,7 +19503,7 @@ paths: ride the audit row. Body capped at 64 KiB. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - *a43 + - *a45 requestBody: required: true content: @@ -19513,7 +19524,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: The path named a family outside the closed set. Answered before authentication. content: @@ -19626,7 +19637,7 @@ paths: schema: $ref: "#/components/schemas/AchievementsEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/environment: @@ -19644,7 +19655,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentOverviewEnvelope" "401": *a1 - "403": *a13 + "403": *a15 "422": *a4 "429": *a5 /api/environment/home: @@ -19681,7 +19692,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentHomeEnvelope" "401": *a1 - "403": *a13 + "403": *a15 "413": description: Body exceeds 4 KiB. content: @@ -19735,7 +19746,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentBackfillEnvelope" "401": *a1 - "403": *a13 + "403": *a15 "409": description: No home location is set, so there is nothing to resolve the days against. `meta.errorCode` = `environment.no_home`. @@ -19794,7 +19805,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentGeocodeEnvelope" "401": *a1 - "403": *a13 + "403": *a15 "422": description: "`q` was missing, empty after trimming, or longer than 120 characters. `meta.errorCode` = `environment.invalid`." @@ -19833,7 +19844,7 @@ paths: schema: $ref: "#/components/schemas/CreateEnvironmentTravelEnvelope" "401": *a1 - "403": *a13 + "403": *a15 "413": description: Body exceeds 4 KiB. content: @@ -19886,7 +19897,7 @@ paths: schema: $ref: "#/components/schemas/DeleteEnvironmentTravelEnvelope" "401": *a1 - "403": *a13 + "403": *a15 "404": description: No such override for this account. `meta.errorCode` = `environment.travel.not_found`. Also the answer for an id owned by somebody else — existence is not leaked. @@ -20027,13 +20038,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a44 + "403": &a46 description: Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": &a45 + "404": &a47 description: No stored copy with this id. content: application/json: @@ -20092,8 +20103,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a44 - "404": *a45 + "403": *a46 + "404": *a47 "409": description: Either the payload declares a different owner than the stored row, or a request under the same `Idempotency-Key` is still in flight. Nothing was changed. diff --git a/src/__tests__/record-write-rate-limit-contract.test.ts b/src/__tests__/record-write-rate-limit-contract.test.ts new file mode 100644 index 000000000..e9dec3cfa --- /dev/null +++ b/src/__tests__/record-write-rate-limit-contract.test.ts @@ -0,0 +1,144 @@ +/** + * The single-record write ceiling is the one the contract describes. + * + * The batch endpoints have been capped at 60 calls a minute since they were + * written; the per-record creates a naive or older client loops over were not + * capped at all, which is backwards — the batch endpoint is the one a + * well-behaved client uses. Eleven of them share one generous per-account + * bucket now, and the operations publish its name and its cap so a client that + * meets the ceiling can read which one it met. + * + * A published number is only worth reading if it cannot drift from the code, so + * this pulls the bucket key and the limit out of `prisma.$queryRaw` — the same + * technique `rate-limit-auth-surface.test.ts` uses — and holds the emitted + * OpenAPI description to what came back. Reading both from the same constant + * would prove nothing: the sentence is written by hand, deliberately, and this + * is what keeps the hand-written half honest. + * + * The route half is structural: every route in the list below calls the shared + * helper. Its limit is that it reads the file rather than the running handler, + * so a call placed after an early return would still count as present. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + RECORD_WRITE_BUCKET_PREFIX, + RECORD_WRITE_LIMIT, + RECORD_WRITE_WINDOW_MS, + checkRecordWriteRateLimit, +} from "@/lib/rate-limit"; +import { buildOpenApiDocument } from "@/lib/openapi/registry"; +import { recordWriteRateLimitResponse } from "@/lib/openapi/routes/shared"; + +vi.mock("@/lib/db", () => ({ prisma: { $queryRaw: vi.fn() } })); + +import { prisma } from "@/lib/db"; + +/** + * The single-record writes on the shared bucket, paired with the operation + * that publishes it. Hand-listed on purpose: the point of the list is that + * adding a create without the limiter is a decision somebody has to make, and + * a list derived from the routes that already call it could not notice one. + */ +const ON_THE_SHARED_BUCKET: Array<[route: string, openApiPath: string]> = [ + ["src/app/api/measurements/route.ts", "/api/measurements"], + ["src/app/api/mood-entries/route.ts", "/api/mood-entries"], + ["src/app/api/medications/intake/route.ts", "/api/medications/intake"], + [ + "src/app/api/medications/[id]/intake/route.ts", + "/api/medications/{id}/intake", + ], + ["src/app/api/labs/route.ts", "/api/labs"], + [ + "src/app/api/custom-metrics/[id]/entries/route.ts", + "/api/custom-metrics/{id}/entries", + ], + ["src/app/api/cycle/day-logs/route.ts", "/api/cycle/day-logs"], + ["src/app/api/vaccinations/route.ts", "/api/vaccinations"], + ["src/app/api/allergies/route.ts", "/api/allergies"], + ["src/app/api/biomarkers/route.ts", "/api/biomarkers"], + ["src/app/api/encounters/route.ts", "/api/encounters"], +]; + +/** The published sentence, read from the object the route modules spread. */ +const published = recordWriteRateLimitResponse["429"].description; + +beforeEach(() => { + vi.mocked(prisma.$queryRaw).mockReset(); + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { count: 1, reset_at: new Date(Date.now() + 60_000) }, + ] as never); +}); + +describe("the shared single-record write bucket", () => { + it("keys on the account under the name the contract publishes", async () => { + await checkRecordWriteRateLimit("account-7"); + + const call = vi.mocked(prisma.$queryRaw).mock.calls.at(-1)!; + // `$queryRaw\`INSERT … VALUES (${key}, …)\`` lands the key at index 1 and + // the window interval at index 2. + expect(call[1]).toBe("record-write:account-7"); + expect(call[2]).toBe(`${RECORD_WRITE_WINDOW_MS} milliseconds`); + expect(published).toContain("`record-write:`"); + expect(`${RECORD_WRITE_BUCKET_PREFIX}:account-7`).toBe(call[1]); + }); + + it("refuses at the cap the contract publishes", async () => { + vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([ + { count: RECORD_WRITE_LIMIT, reset_at: new Date(Date.now() + 60_000) }, + ] as never); + await expect(checkRecordWriteRateLimit("account-7")).resolves.toMatchObject( + { allowed: true, limit: RECORD_WRITE_LIMIT, remaining: 0 }, + ); + + vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([ + { + count: RECORD_WRITE_LIMIT + 1, + reset_at: new Date(Date.now() + 60_000), + }, + ] as never); + await expect(checkRecordWriteRateLimit("account-7")).resolves.toMatchObject( + { allowed: false }, + ); + + const windowSeconds = RECORD_WRITE_WINDOW_MS / 1000; + expect(published).toContain( + `${RECORD_WRITE_LIMIT} requests per ${windowSeconds} seconds`, + ); + }); + + it("says the headers describe that bucket, and declares them", () => { + expect(Object.keys(recordWriteRateLimitResponse["429"].headers)).toEqual([ + "Retry-After", + "X-RateLimit-Limit", + "X-RateLimit-Remaining", + "X-RateLimit-Reset", + ]); + expect(published).toContain("Retry-After"); + }); + + it("is called by every route that publishes it", () => { + const missing = ON_THE_SHARED_BUCKET.filter(([route]) => { + const src = readFileSync(join(process.cwd(), route), "utf8"); + return !src.includes("checkRecordWriteRateLimit("); + }).map(([route]) => route); + expect(missing).toEqual([]); + }); + + it("is published on every operation that answers it", () => { + const doc = buildOpenApiDocument() as { + paths: Record< + string, + { post?: { responses?: Record } } + >; + }; + const unpublished = ON_THE_SHARED_BUCKET.filter( + ([, path]) => + doc.paths[path]?.post?.responses?.["429"]?.description !== published, + ).map(([, path]) => path); + expect(unpublished).toEqual([]); + }); +}); diff --git a/src/app/api/allergies/route.ts b/src/app/api/allergies/route.ts index f4e69e40c..e7b544667 100644 --- a/src/app/api/allergies/route.ts +++ b/src/app/api/allergies/route.ts @@ -15,11 +15,13 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { + apiError, apiSuccess, getClientIp, returnAllZodIssues, safeJson, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { withIdempotency } from "@/lib/idempotency"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { @@ -84,7 +86,15 @@ async function postAllergy(request: NextRequest): Promise { // A READ or WRITE delegate reaches neither: the destination is not on their // Settings list, and this line refuses them with `sharing.access.denied` // regardless. - const { user } = await requireRecordAuth("manage", "profile"); + const { user, actor } = await requireRecordAuth("manage", "profile"); + + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429); + } const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, diff --git a/src/app/api/biomarkers/__tests__/error-contract.test.ts b/src/app/api/biomarkers/__tests__/error-contract.test.ts index 044cf9d62..dcbfe3362 100644 --- a/src/app/api/biomarkers/__tests__/error-contract.test.ts +++ b/src/app/api/biomarkers/__tests__/error-contract.test.ts @@ -1,6 +1,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { biomarker: { findFirst: vi.fn(), create: vi.fn(), findMany: vi.fn() }, diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts index b0d502964..7a48c1b06 100644 --- a/src/app/api/biomarkers/route.ts +++ b/src/app/api/biomarkers/route.ts @@ -9,6 +9,7 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { withIdempotency } from "@/lib/idempotency"; @@ -95,7 +96,15 @@ async function postBiomarker(request: NextRequest) { // duplicate pre-check and the `@@unique([userId, name])` backstop both scope // to the same resolved id, so a delegate adding a marker the owner already // tracks gets the ordinary 409 rather than a second copy. - const { user } = await requireRecordAuth("write", "labs"); + const { user, actor } = await requireRecordAuth("write", "labs"); + + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429); + } const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, diff --git a/src/app/api/custom-metrics/[id]/entries/__tests__/route.test.ts b/src/app/api/custom-metrics/[id]/entries/__tests__/route.test.ts index c13967317..2f02eb2b3 100644 --- a/src/app/api/custom-metrics/[id]/entries/__tests__/route.test.ts +++ b/src/app/api/custom-metrics/[id]/entries/__tests__/route.test.ts @@ -1,6 +1,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { customMetric: { findFirst: vi.fn() }, diff --git a/src/app/api/custom-metrics/[id]/entries/route.ts b/src/app/api/custom-metrics/[id]/entries/route.ts index 5fdd9ff4b..0750ce9d3 100644 --- a/src/app/api/custom-metrics/[id]/entries/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/route.ts @@ -9,6 +9,7 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store"; @@ -105,6 +106,14 @@ async function postCustomMetricEntry( // tracked value adds it back in the same diff: the argument for admitting it // was never wrong, and the GET arm above stays delegable meanwhile. const { user } = await requireAuth(); + + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(user.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429); + } const { id } = await params; const metric = await prisma.customMetric.findFirst({ diff --git a/src/app/api/cycle/__tests__/external-id-floor.test.ts b/src/app/api/cycle/__tests__/external-id-floor.test.ts index 3b28effc5..5e1428830 100644 --- a/src/app/api/cycle/__tests__/external-id-floor.test.ts +++ b/src/app/api/cycle/__tests__/external-id-floor.test.ts @@ -34,6 +34,14 @@ vi.mock("@/lib/auth/audit", () => ({ vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn(), rateLimitHeaders: () => ({}), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), })); vi.mock("@/lib/idempotency", () => ({ withIdempotency: diff --git a/src/app/api/cycle/day-logs/route.ts b/src/app/api/cycle/day-logs/route.ts index 28e91b0c4..5a0696bc6 100644 --- a/src/app/api/cycle/day-logs/route.ts +++ b/src/app/api/cycle/day-logs/route.ts @@ -26,6 +26,7 @@ import { returnAllZodIssues, safeJson, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { withIdempotency } from "@/lib/idempotency"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { @@ -94,11 +95,19 @@ async function postDayLog(request: NextRequest): Promise { // v1.37.0 — MANAGE. The upsert replaces the day, which is what edit means // here; the gate below resolves `user.gender` against the RECORD, so the // module follows the record rather than the caller. - const { user } = await requireRecordAuth("manage", "cycle"); + const { user, actor } = await requireRecordAuth("manage", "cycle"); const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429); + } + const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, }); diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index 0fc4a5fa7..80926a0c2 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -25,6 +25,7 @@ import { returnAllZodIssues, safeJson, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { withIdempotency } from "@/lib/idempotency"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { @@ -154,7 +155,15 @@ export const GET = apiHandler(async (request: NextRequest) => { export const POST = apiHandler(withIdempotency<[NextRequest]>(postEncounter)); async function postEncounter(request: NextRequest): Promise { - const { user, grantId } = await requireRecordAuth("write", "profile"); + const { user, actor, grantId } = await requireRecordAuth("write", "profile"); + + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429); + } const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 32 * 1024, diff --git a/src/app/api/labs/__tests__/error-contract.test.ts b/src/app/api/labs/__tests__/error-contract.test.ts index 302325c71..fb2d8c4a6 100644 --- a/src/app/api/labs/__tests__/error-contract.test.ts +++ b/src/app/api/labs/__tests__/error-contract.test.ts @@ -1,6 +1,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { labResult: { diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 97fc344ce..81cbf8a80 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -10,6 +10,7 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; @@ -148,7 +149,15 @@ async function postLabResult(request: NextRequest) { // it mints it under `user.id`: the marker joins the record's catalogue, not // the caller's, which is the only reading of "add a result to your record" // that leaves the owner with a usable catalogue afterwards. - const { user } = await requireRecordAuth("write", "labs"); + const { user, actor } = await requireRecordAuth("write", "labs"); + + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429); + } const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, diff --git a/src/app/api/measurements/__tests__/arrival-wiring.test.ts b/src/app/api/measurements/__tests__/arrival-wiring.test.ts index c8f39732e..ee77a756a 100644 --- a/src/app/api/measurements/__tests__/arrival-wiring.test.ts +++ b/src/app/api/measurements/__tests__/arrival-wiring.test.ts @@ -12,6 +12,17 @@ const SELF = { timezone: "Europe/Berlin", }; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/api-handler", () => ({ apiHandler: (fn: unknown) => fn, requireRecordAuth: vi.fn(async () => ({ user: SELF, actor: SELF })), diff --git a/src/app/api/measurements/__tests__/zod-multi-issue.test.ts b/src/app/api/measurements/__tests__/zod-multi-issue.test.ts index 10cca436b..34c4b295e 100644 --- a/src/app/api/measurements/__tests__/zod-multi-issue.test.ts +++ b/src/app/api/measurements/__tests__/zod-multi-issue.test.ts @@ -9,6 +9,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { measurement: { diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 6dcfe5456..385f6a640 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -16,6 +16,7 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { createMeasurementSchema, createBatchMeasurementSchema, @@ -801,6 +802,14 @@ async function postMeasurement(request: NextRequest) { const { user } = auth; const scoped = isScopedCredential(auth); + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(auth.actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429); + } + const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, }); diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index 60569dcb6..5e068be58 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -22,6 +22,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { NextRequest } from "next/server"; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { medication: { diff --git a/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts index 2d1b52967..b7a9a4519 100644 --- a/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts @@ -18,6 +18,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { medication: { diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 635ee69f1..b5c3baebb 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -10,6 +10,7 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { intakeSchema, listIntakeEventsSchema, @@ -58,6 +59,14 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { "medications", ); + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429); + } + // v1.36.1 follow-up — the sibling route `POST /api/medications/intake` // refuses a delegate changing a dose the owner already recorded. This route // reaches the same rows through the slot upsert, so without the same rule it diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index 1b63f0ed8..cc1bf78d9 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -1,6 +1,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { NextRequest } from "next/server"; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { medicationIntakeEvent: { diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index fd0b5c2ca..e6c6e759d 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -21,6 +21,7 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; @@ -203,6 +204,14 @@ export const POST = apiHandler(async (request: NextRequest) => { // notification at the end of this handler. const { user, actor } = await requireRecordAuth("write", "medications"); + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429); + } + const { data: body, error } = await safeJson(request, { maxBytes: 64 * 1024, }); diff --git a/src/app/api/mood-entries/__tests__/route.test.ts b/src/app/api/mood-entries/__tests__/route.test.ts index 383671464..3af7aca11 100644 --- a/src/app/api/mood-entries/__tests__/route.test.ts +++ b/src/app/api/mood-entries/__tests__/route.test.ts @@ -31,6 +31,17 @@ const txClient = { }, }; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { moodEntry: { diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index c4faecd3b..279ba0256 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -8,6 +8,7 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { createMoodEntrySchema, listMoodEntriesSchema, @@ -105,6 +106,14 @@ async function postMoodEntry(request: NextRequest) { // the `externalId` refusal below. const { user, actor } = await requireRecordAuth("manage", "mind"); + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429); + } + const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, }); diff --git a/src/app/api/vaccinations/route.ts b/src/app/api/vaccinations/route.ts index 16d2be8b7..62a60b12c 100644 --- a/src/app/api/vaccinations/route.ts +++ b/src/app/api/vaccinations/route.ts @@ -29,6 +29,7 @@ import { returnAllZodIssues, safeJson, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { withIdempotency } from "@/lib/idempotency"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { @@ -112,7 +113,15 @@ export const GET = apiHandler(async (request: NextRequest) => { export const POST = apiHandler(withIdempotency<[NextRequest]>(postVaccination)); async function postVaccination(request: NextRequest): Promise { - const { user } = await requireRecordAuth("write", "profile"); + const { user, actor } = await requireRecordAuth("write", "profile"); + + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429); + } const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 32 * 1024, diff --git a/src/lib/openapi/routes/allergies.ts b/src/lib/openapi/routes/allergies.ts index 3f1629054..63a1f7a40 100644 --- a/src/lib/openapi/routes/allergies.ts +++ b/src/lib/openapi/routes/allergies.ts @@ -30,6 +30,7 @@ import { idempotencyKeyParameter, idempotentWrite, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "./shared"; @@ -125,6 +126,7 @@ export const allergyPaths: NonNullable = { }, }, ...stdResponses, + ...recordWriteRateLimitResponse, }, }, }, diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index 230e0e47e..537bf4c07 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -25,6 +25,7 @@ import { idempotencyKeyParameter, idempotentWrite, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "./shared"; @@ -125,6 +126,7 @@ export const biomarkerPaths: NonNullable = { }, ...conflict, ...stdResponses, + ...recordWriteRateLimitResponse, }, }, }, diff --git a/src/lib/openapi/routes/custom-metrics.ts b/src/lib/openapi/routes/custom-metrics.ts index 77e9ac8fc..d091a0dd7 100644 --- a/src/lib/openapi/routes/custom-metrics.ts +++ b/src/lib/openapi/routes/custom-metrics.ts @@ -27,6 +27,7 @@ import { idempotencyKeyParameter, idempotentWrite, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "./shared"; @@ -314,6 +315,7 @@ export const customMetricPaths: NonNullable = { }, ...notFound, ...stdResponses, + ...recordWriteRateLimitResponse, }, }, }, diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index 4db1ac99a..f251500e6 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -35,6 +35,7 @@ import { idempotentWrite, malformedJsonResponse, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "./shared"; @@ -710,6 +711,7 @@ export const cyclePaths: NonNullable = { }, ...cycleDisabledOnADelegableRoute, ...stdResponses, + ...recordWriteRateLimitResponse, }, }, }, diff --git a/src/lib/openapi/routes/encounters.ts b/src/lib/openapi/routes/encounters.ts index f1e3f4a64..b5df36a31 100644 --- a/src/lib/openapi/routes/encounters.ts +++ b/src/lib/openapi/routes/encounters.ts @@ -38,6 +38,7 @@ import { idempotencyKeyParameter, idempotentWrite, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "./shared"; @@ -273,6 +274,7 @@ export const encounterPaths: NonNullable = { }, }, ...stdResponses, + ...recordWriteRateLimitResponse, }, }, }, diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts index 7cddeae2a..81dd942af 100644 --- a/src/lib/openapi/routes/labs.ts +++ b/src/lib/openapi/routes/labs.ts @@ -22,6 +22,7 @@ import { idempotencyKeyParameter, idempotentWrite, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "./shared"; @@ -161,6 +162,7 @@ export const labsPaths: NonNullable = { }, }, ...stdResponses, + ...recordWriteRateLimitResponse, }, }, }, diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index f138557cb..ffd097081 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -21,6 +21,7 @@ import { measurementSourceEnum, measurementTypeEnum, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "./shared"; @@ -885,6 +886,7 @@ export const measurementPaths: NonNullable = { // After the spread: `stdResponses` carries a generic 422 that would // otherwise overwrite this one. ...stdResponses, + ...recordWriteRateLimitResponse, "422": { description: "Validation failed, or (`measurement.create.source_not_permitted`) a narrow `measurements:write` credential named `APPLE_HEALTH` as the source. Refused rather than relabelled, so the caller can fix the payload instead of discovering rows it did not ask for.", diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index 5c240f3b9..ace53ad90 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -35,6 +35,7 @@ import { idempotentWrite, invalidBaseTokenResponse, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "../shared"; @@ -289,7 +290,7 @@ export const medicationPaths: NonNullable = { content: { "application/json": { schema: errorEnvelope } }, }, "401": stdResponses["401"], - "429": stdResponses["429"], + ...recordWriteRateLimitResponse, }, }, }, @@ -1153,6 +1154,7 @@ export const medicationPaths: NonNullable = { content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, + ...recordWriteRateLimitResponse, }, }, get: { diff --git a/src/lib/openapi/routes/mood.ts b/src/lib/openapi/routes/mood.ts index 92cb9afe4..186331aa3 100644 --- a/src/lib/openapi/routes/mood.ts +++ b/src/lib/openapi/routes/mood.ts @@ -56,6 +56,7 @@ import { invalidBaseTokenResponse, moduleDisabledResponse, recordRefusal, + recordWriteRateLimitResponse, stdResponses, updatedAtTokenField, } from "./shared"; @@ -1636,6 +1637,7 @@ export const moodPaths: NonNullable = { ...duplicateTimestampResponse, ...recordRefusal(), ...stdResponses, + ...recordWriteRateLimitResponse, }, }, }, diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 051be4acb..9c68b30d0 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -289,6 +289,32 @@ export const stdResponses = { }, }; +/** + * The 429 the shared single-record write bucket answers with. + * + * The batch endpoints have been capped at 60 calls a minute since they were + * written and the per-record siblings were not capped at all, which is exactly + * backwards: the batch endpoint is the one a well-behaved client uses. The + * eleven single-record creates now share one generous per-account bucket, and + * this is the response that names it — a client that meets a ceiling should be + * able to read which one it met without a bug report. + * + * Spread AFTER `...stdResponses` so it replaces the generic 429 on those + * operations. The headers block is the one the standard 429 already declares. + * + * `record-write-rate-limit-contract.test.ts` holds the numbers in this sentence + * to the constants in `src/lib/rate-limit.ts`, so the paragraph cannot drift + * away from the bucket it describes. + */ +export const recordWriteRateLimitResponse = { + "429": { + description: + "Rate limit exceeded. This route shares one per-account bucket with the other single-record writes — `record-write:`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns their own allowance rather than the record owner's. Nothing was written. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that bucket; back off on `Retry-After` rather than guessing. A client with more than a handful of rows to send should use the batch endpoint for its domain instead of looping this one.", + content: { "application/json": { schema: errorEnvelope } }, + headers: stdResponses["429"].headers, + }, +}; + // ── Idempotent writes ──────────────────────────────────────────────── /** diff --git a/src/lib/openapi/routes/vaccinations.ts b/src/lib/openapi/routes/vaccinations.ts index 193a7904a..de80ef54d 100644 --- a/src/lib/openapi/routes/vaccinations.ts +++ b/src/lib/openapi/routes/vaccinations.ts @@ -38,6 +38,7 @@ import { idempotencyKeyParameter, idempotentWrite, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "./shared"; @@ -263,6 +264,7 @@ export const vaccinationPaths: NonNullable = { }, }, ...stdResponses, + ...recordWriteRateLimitResponse, }, }, }, diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index d76113579..0bef620e2 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -117,6 +117,44 @@ export async function checkAnalyticsReadRateLimit( ); } +/** + * Shared per-account ceiling for the single-record writes. + * + * The batch endpoints have been capped at 60 calls a minute since they were + * written; the per-record siblings a naive or older client loops over were not + * capped at all, which is backwards — the batch endpoint is the one a + * well-behaved client uses. This is the floor under the other direction: + * `POST /api/measurements`, `/api/mood-entries`, both medication-intake writes, + * `/api/labs`, a custom-metric entry, a cycle day-log, a vaccination, an + * allergy, a biomarker and an encounter. + * + * One shared bucket, not one per route, for the reason + * `checkAnalyticsReadRateLimit` gives: a loop that rotates across ten creates + * should still meet a cap. Three hundred a minute is five writes a second + * sustained for a full minute — no hand-driven session and no sync burst + * reaches it, and every one of these routes is already wrapped in + * `withIdempotency`, so an honest retry storm is deduplicated before it is + * counted. It exists to cut off the pathological loop, not to shape traffic. + * + * Keyed on the ACTING account rather than the record, the frozen precedent from + * the delegable medication routes: a delegate burns their own allowance rather + * than locking the record's owner out of their own log, and cannot collect a + * fresh one by switching records. + */ +export const RECORD_WRITE_BUCKET_PREFIX = "record-write"; +export const RECORD_WRITE_LIMIT = 300; +export const RECORD_WRITE_WINDOW_MS = 60 * 1000; + +export async function checkRecordWriteRateLimit( + accountId: string, +): Promise { + return checkRateLimit( + `${RECORD_WRITE_BUCKET_PREFIX}:${accountId}`, + RECORD_WRITE_LIMIT, + RECORD_WRITE_WINDOW_MS, + ); +} + /** * v1.22.0 — per-credential ceiling for the remote MCP endpoint (`/mcp`). * The bucket is keyed by the `:` binding the Bearer From 868efc1c13cb646125cef56e39d67a32b25086d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 07:47:10 +0200 Subject: [PATCH 05/56] docs(api): publish the error codes the server actually emits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `meta.errorCode` is the mechanism this API chose for machine-readable errors, and the envelope's own description tells a client to branch on it rather than on the prose. Of the two hundred and forty-two codes in the tree, sixteen appeared anywhere in the contract. A client had to discover each one by triggering the error in testing, and had no way at all to learn that a new one had appeared. They are enumerated now, grouped by the surface that emits them, in the description of the field they belong to. The list is generated from the code and committed as a literal so it reads in a diff, and the sentence in the document is rendered from it rather than written beside it. Nothing is renamed. Four naming conventions coexist — dot.case, bare snake_case, a camelCase segment and a hyphen — and a code is a wire value a shipped client branches on, so they stay. New codes are dot.case, and the guard is what stops a fifth convention arriving unnoticed. Three families stay outside the list and say why: the assistant refusal builds its last segment from a template, the integration probes are enumerated per operation where the per-provider differences can be stated, and a route checking a credential of its own may answer 401 with no code. The guard re-derives the set from the tree and fails both ways — a code emitted and not listed, and a code listed and no longer emitted, which is the worse of the two because a client can write a branch for it and wait forever. --- docs/api/openapi.yaml | 93 +++++- src/__tests__/error-code-catalogue.test.ts | 189 ++++++++++++ src/lib/openapi/error-codes.ts | 339 +++++++++++++++++++++ src/lib/openapi/routes/shared.ts | 5 +- 4 files changed, 623 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/error-code-catalogue.test.ts create mode 100644 src/lib/openapi/error-codes.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3cb879a6b..c0da54ed3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -29501,8 +29501,97 @@ components: requestId: type: string errorCode: - description: Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be - reworded. + description: "Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be + reworded. Every code the API emits today is enumerated below, grouped by the surface that emits it; four + naming conventions coexist and none of them will be renamed, because a code is a wire value a shipped + client branches on. Treat an unlisted code the way you would treat an unlisted enum member — as a + refusal you do not recognise, not as a malformed response — since the list grows with the surfaces. + Three families are outside it on purpose: `assistant.disabled.` is built from a template so the + last segment is open (`assistant.disabled.coach` is the one the native client names); the + integration-probe classes (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, + `connection_failed` and the per-provider additions) are enumerated in each `/test` operation instead, + where the differences can be stated; and a 401 raised by a route checking a credential of its own may + carry no code at all. ai_provider — ai_provider.invalid, ai_provider.no_fields; allergy — + allergy.invalid; analytics — analytics.invalid_query; anamnesis — anamnesis.fact.conflict, + anamnesis.fact.currentExists, anamnesis.fact.invalidValue; auth — auth.admin.required, + auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, + auth.scope.insufficient, auth.stepup.required, auth.token.expired, auth.token.invalid; backup — + backup.payload.undecryptable, backup.section.missing; coach — coach.conversation.invalidTitle, + coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, + coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, + coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs — + coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.required; cycle — + cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, + cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, + cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — + cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; + documents — documents.chat.conversationNotFound, documents.chat.invalid, + documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, + documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, + documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound, + documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, + documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery, + documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notFound, + documents.inbound.notIndexed, documents.inbound.pdfNeedsAnthropic, + documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited, + documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.uploadBusy, + documents.inbound.uploadTimeout; encounter — encounter.invalid, encounter.link.invalid, + encounter.practitioner-not-found, encounter.reminder-conflict, encounter.reminder-not-found; environment + — environment.invalid, environment.no_home, environment.range_too_large, environment.travel.not_found; + export — export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid; + feedback — feedback.body.invalid; health_score_config — health_score_config.invalid, + health_score_config.too_narrow; illness — illness.day-log.invalid, illness.disabled, + illness.episode.chronic-no-resolve, illness.episode.invalid, illness.episode.invalid-window, + illness.episode.parent-not-found, illness.insights.invalid; import — import.write_failed; ingest — + ingest.rate_limited; insights — insights.generate.budgetExceeded, insights.generate.outboundScreened, + insights.generate.profileScopeChanged, insights.generate.scopeChanged; labs — + labs.biomarker.create.invalid, labs.biomarker.duplicate, labs.biomarker.notFound, + labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, labs.create.invalid, + labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge, labs.ocr.fileType, + labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported, + labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.update.invalid, + labs.update.linkedFieldsImmutable, labs.update.numericExpected, labs.update.qualitativeExpected, + labs.update.referenceRangeInvalid, labs.update.sourceReferenceRangeInvalid; managed_profile — + managed_profile.guardian.duplicate, managed_profile.guardian.managed_invitee, + managed_profile.guardian.required, managed_profile.not_found; measurement — + measurement.batch.source_not_permitted, measurement.batch.too_large, measurement.bulk-delete.invalid, + measurement.create.invalid, measurement.create.source_not_permitted, measurement.delete.too_large, + measurement.duplicate_timestamp, measurement.restore.invalid, measurement.update.server_owned_source; + medication — medication.intake.import.invalid_format; medications — + medications.intake.bulk.apple_health_not_mirrored, medications.intake.bulk.invalid, + medications.intake.bulk.too_large, medications.intake.force_slot.invalid, + medications.intake.force_slot.occupied, medications.intake.injection_site.disallowed, + medications.intake.taken_at.before_start, medications.mirror.limit_exceeded; mentalHealth — + mentalHealth.rateLimited; module — module.disabled; modules — modules.body.invalid_json, + modules.invalid; mood — mood.bulk-delete.invalid, mood.bulk.invalid, mood.bulk.too_large, + mood.create.external_id_not_delegable, mood.create.invalid, mood.duplicate_timestamp, + mood.linkedContext.invalid, mood.list.invalid, mood.not_found, mood.ratedFactor.out_of_range, + mood.restore.invalid, mood.update.invalid; notification-prefs — notification-prefs.body.invalid_json; + nutrient — nutrient.batch.invalid, nutrient.batch.too_large, nutrient.daily.invalid, + nutrient.read.invalid, nutrient.water.invalid, nutrient.water.invalid_day; onboarding — + onboarding.complete.invalid, onboarding.disclaimer.invalid, onboarding.step.completed, + onboarding.step.concurrent, onboarding.step.invalid, onboarding.step.outOfOrder, + onboarding.step.rateLimited, onboarding.tour.invalid, onboarding.user.notFound; personal_records — + personal_records.invalid_query; practitioner — practitioner.invalid; profile — + profile.update.emailInUse, profile.update.invalidBody, profile.update.nothingSaved; report-selection — + report-selection.body.invalid_json, report-selection.body.invalid_shape; route — route.retired; + share-link — share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited, + share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing — + sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked, + sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope, + sharing.invite.manage_browser_only, sharing.invite.refused, sharing.invite.self, sharing.not_permitted, + sharing.renounce.already_ended, sharing.renounce.not_found, sharing.revoke.already_ended, + sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, + sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json, + source-priority.body.invalid_shape; tokens — tokens.measurements.ceiling_reached; vaccination — + vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.encounter-not-found, + vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found, + vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, + workout.batch.too_large; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict, + dashboard_layout_conflict, health_score_config_conflict, insights_layout_conflict, + invalid_base_updated_at, medication_layout_conflict, modules_conflict, mood_tag_layout_conflict, + not_configured, notification_prefs_conflict, oidc_only, private_origin_not_approved, rate_limited_self, + too_many_rows, upstream_invalid_json, vapid_not_configured" type: string additionalProperties: {} required: diff --git a/src/__tests__/error-code-catalogue.test.ts b/src/__tests__/error-code-catalogue.test.ts new file mode 100644 index 000000000..a6a7fff08 --- /dev/null +++ b/src/__tests__/error-code-catalogue.test.ts @@ -0,0 +1,189 @@ +/** + * The published `meta.errorCode` list is the list the server actually emits. + * + * `meta.errorCode` is the mechanism this API chose for machine-readable errors + * and the envelope tells a client to branch on it — but of the two hundred and + * forty-two codes in the tree, sixteen appeared anywhere in the contract. A + * client had to discover each one by triggering the error, and had no way at + * all to learn a new one had appeared. The catalogue publishes them; this holds + * the catalogue to the code, in both directions: + * + * - A code emitted and not listed is today's defect repeating itself, and it + * is also how a fifth naming convention would get in unnoticed. + * - A code listed and no longer emitted is a promise the server stopped + * keeping, which is worse than an omission: a client can write a branch for + * it and wait forever. + * + * What it reads: every `.ts` under `src/app/api` outside a `__tests__` + * directory, plus the named library modules that build a refusal envelope of + * their own. Comment lines are stripped first, so a code quoted in prose is not + * mistaken for one that ships. + * + * Its limits, stated rather than implied. A new library module that raises a + * coded refusal has to be added to `ENVELOPE_MODULES` below — the route tree is + * swept by rule, that list is not. A code assembled at runtime from a template + * cannot be enumerated at all, and the one family that does so is named in the + * catalogue's own doc comment. And `IntegrationStatus.errorCode` is a different + * field with the same name — it records an upstream HTTP status in the sync + * ledger and never reaches a response — which is why the sweep is scoped to + * the modules that answer requests rather than to `src/lib` wholesale. + */ +import { readFileSync, readdirSync } from "node:fs"; +import { join, relative, sep } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { ERROR_CODE_CATALOGUE } from "@/lib/openapi/error-codes"; + +const API_ROOT = join(process.cwd(), "src", "app", "api"); + +/** + * Library modules that build a coded refusal without going through a route + * file. Everything else under `src/lib` is out of scope on purpose — see the + * note on `IntegrationStatus.errorCode` in the file header. + */ +const ENVELOPE_MODULES = [ + "src/lib/ai/consent-guard.ts", + "src/lib/api-errors.ts", + "src/lib/api-handler.ts", + "src/lib/auth/profile-update.ts", + "src/lib/clinician-share/report-download.ts", + "src/lib/cycle/gate.ts", + "src/lib/documents/ai-route-support.ts", + "src/lib/documents/attach-validate.ts", + "src/lib/export/backup-blob.ts", + "src/lib/http/retired-routes.ts", + "src/lib/illness/gate.ts", + "src/lib/modules/gate.ts", + "src/lib/optimistic-lock.ts", + "src/lib/sharing/record-session-fence-contract.ts", +]; + +function apiFiles(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(...apiFiles(full)); + continue; + } + if (entry.name.endsWith(".ts")) found.push(full); + } + return found; +} + +/** Drop comment lines so a code quoted in prose is not read as one that ships. */ +function withoutComments(source: string): string { + return source + .split("\n") + .filter((line) => { + const trimmed = line.trim(); + return !( + trimmed.startsWith("*") || + trimmed.startsWith("//") || + trimmed.startsWith("/*") + ); + }) + .join("\n"); +} + +/** The three shapes a code is written in across the tree. */ +function codesIn(source: string): string[] { + const found: string[] = []; + // `errorCode: "x"`, `errorCode = "x"`, `readonly errorCode = "x"`, and the + // annotated `errorCode: string = "x"` on the step-up error's constructor. + for (const m of source.matchAll( + /errorCode\s*(?::\s*[A-Za-z.[\]|" ]+)?\s*[:=]\s*"([^"]+)"/g, + )) { + found.push(m[1]); + } + // A single shared constant: `export const MODULE_DISABLED_ERROR_CODE = "…"`. + for (const m of source.matchAll(/\b[A-Z][A-Z0-9_]*_CODE\s*=\s*"([^"]+)"/g)) { + found.push(m[1]); + } + // A family in one object: `export const AUTH_ERROR_CODES = { … }`. + for (const m of source.matchAll(/\b[A-Z][A-Z0-9_]*_CODES\s*=\s*\{/g)) { + const start = source.indexOf("{", m.index); + let depth = 0; + let end = start; + for (let j = start; j < source.length; j++) { + if (source[j] === "{") depth++; + else if (source[j] === "}") { + depth--; + if (depth === 0) { + end = j; + break; + } + } + } + for (const value of source.slice(start, end).matchAll(/:\s*"([^"]+)"/g)) { + found.push(value[1]); + } + } + return found; +} + +/** Every code the tree emits, mapped to the files that emit it. */ +function emittedCodes(): Map { + const emitted = new Map(); + const files = [ + ...apiFiles(API_ROOT), + ...ENVELOPE_MODULES.map((path) => join(process.cwd(), path)), + ]; + for (const file of files) { + const source = withoutComments(readFileSync(file, "utf8")); + for (const code of codesIn(source)) { + const where = relative(process.cwd(), file).split(sep).join("/"); + const seen = emitted.get(code); + if (seen) { + if (!seen.includes(where)) seen.push(where); + } else { + emitted.set(code, [where]); + } + } + } + return emitted; +} + +const emitted = emittedCodes(); +const published = new Set(Object.values(ERROR_CODE_CATALOGUE).flat()); + +describe("the published errorCode catalogue", () => { + it("finds the codes it is meant to judge", () => { + // A matcher that stopped matching would otherwise agree with an empty + // catalogue. Two hundred and forty-two when this was written. + expect(emitted.size).toBeGreaterThan(200); + }); + + it("lists every code the server emits", () => { + const undocumented = [...emitted.entries()] + .filter(([code]) => !published.has(code)) + .map(([code, files]) => `${code} (${files[0]})`) + .sort(); + expect(undocumented).toEqual([]); + }); + + it("emits every code it lists", () => { + const stale = [...published].filter((code) => !emitted.has(code)).sort(); + expect(stale).toEqual([]); + }); + + it("files every code under the surface its own prefix names", () => { + const misfiled: string[] = []; + for (const [surface, codes] of Object.entries(ERROR_CODE_CATALOGUE)) { + for (const code of codes) { + const expected = code.includes(".") + ? code.slice(0, code.indexOf(".")) + : "(unprefixed)"; + if (expected !== surface) misfiled.push(`${code} under ${surface}`); + } + } + expect(misfiled).toEqual([]); + }); + + it("keeps no duplicate between two surfaces", () => { + const all = Object.values(ERROR_CODE_CATALOGUE).flat(); + expect(all.length).toBe(new Set(all).size); + }); +}); diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts new file mode 100644 index 000000000..d30ffabba --- /dev/null +++ b/src/lib/openapi/error-codes.ts @@ -0,0 +1,339 @@ +/** + * Every `meta.errorCode` the API emits, grouped by the surface that emits it. + * + * `meta.errorCode` is the mechanism this API chose for machine-readable errors + * — the envelope's own description tells a client to branch on it rather than + * on the prose — and until this file existed a client could not enumerate it. + * Sixteen of the two hundred and forty-two codes appeared somewhere in + * `docs/api/openapi.yaml`; the rest existed only in server source, so the only + * way to learn one was to trigger the error in testing and the only way to + * learn a new one had appeared was to trigger that too. + * + * The list is generated from the code and then committed as a literal, so it is + * reviewable in a diff and stable across a release. `error-code-catalogue.test.ts` + * re-derives it from the tree on every run and fails in both directions: a code + * emitted and not listed, and a code listed and no longer emitted. + * + * Naming: four conventions coexist here — dot.case (the majority and the one + * to use), bare snake_case, a camelCase segment, and a hyphen. Nothing is + * renamed: a code is a wire value and a shipped client branches on it. New + * codes are dot.case. + * + * Not in this list, and deliberately: + * + * - `assistant.disabled.` is built from a template at runtime + * (`src/lib/feature-flags/index.ts`), so the surface segment is open. + * `assistant.disabled.coach` is the one the iOS contract names. + * - The integration-probe classes — `credentials_rejected`, `rate_limited`, + * `upstream_error`, `timeout`, `connection_failed`, and the per-provider + * additions beside them — are chosen by each `/test` route's own + * categoriser and are already enumerated in those operations' + * descriptions, where the per-provider differences can be stated. + * - `IntegrationStatus.errorCode` is a different field with the same name: it + * records an upstream HTTP status in the sync ledger and never reaches a + * response envelope. + */ +export const ERROR_CODE_CATALOGUE: Readonly> = + { + ai_provider: ["ai_provider.invalid", "ai_provider.no_fields"], + allergy: ["allergy.invalid"], + analytics: ["analytics.invalid_query"], + anamnesis: [ + "anamnesis.fact.conflict", + "anamnesis.fact.currentExists", + "anamnesis.fact.invalidValue", + ], + auth: [ + "auth.admin.required", + "auth.mfa.code_invalid", + "auth.missing", + "auth.refresh.invalid", + "auth.refresh.reuse", + "auth.refresh.revoked", + "auth.scope.insufficient", + "auth.stepup.required", + "auth.token.expired", + "auth.token.invalid", + ], + backup: ["backup.payload.undecryptable", "backup.section.missing"], + coach: [ + "coach.conversation.invalidTitle", + "coach.conversation.notFound", + "coach.fenced.attachmentConflict", + "coach.fenced.attachmentLimit", + "coach.fenced.attachmentNotFound", + "coach.fenced.attachmentNotIndexed", + "coach.fenced.attachmentRequired", + "coach.fenced.attachmentUnavailable", + "coach.fenced.invalid", + "coach.request.invalid", + ], + "coach-prefs": [ + "coach-prefs.body.invalid_json", + "coach-prefs.body.invalid_shape", + ], + consent: ["consent.ai.required"], + cycle: [ + "cycle.bulk.invalid", + "cycle.bulk.too_large", + "cycle.calendar.invalid", + "cycle.calendar.range", + "cycle.cycles.invalid", + "cycle.day-log.conflict", + "cycle.day-log.invalid", + "cycle.disabled", + "cycle.period.invalid", + "cycle.symptom.custom.invalid", + "cycle.symptom.custom.limit", + ], + "cycle-prefs": ["cycle-prefs.body.invalid_json", "cycle-prefs.invalid"], + "disable-coach": ["disable-coach.body.invalid_json"], + documents: [ + "documents.chat.conversationNotFound", + "documents.chat.invalid", + "documents.inbound.alreadyConfirmed", + "documents.inbound.alreadyPartlyConfirmed", + "documents.inbound.budgetExceeded", + "documents.inbound.decryptFailed", + "documents.inbound.encounterNotFound", + "documents.inbound.episodeNotFound", + "documents.inbound.extractFailed", + "documents.inbound.factNotFound", + "documents.inbound.factNotPending", + "documents.inbound.factTypeMismatch", + "documents.inbound.fileTooLarge", + "documents.inbound.fileType", + "documents.inbound.invalidMetadata", + "documents.inbound.invalidQuery", + "documents.inbound.invalidUpdate", + "documents.inbound.localOcrDisabled", + "documents.inbound.notFound", + "documents.inbound.notIndexed", + "documents.inbound.pdfNeedsAnthropic", + "documents.inbound.providerUnsupported", + "documents.inbound.quotaExceeded", + "documents.inbound.rateLimited", + "documents.inbound.restoreDuplicate", + "documents.inbound.restoreGone", + "documents.inbound.uploadBusy", + "documents.inbound.uploadTimeout", + ], + encounter: [ + "encounter.invalid", + "encounter.link.invalid", + "encounter.practitioner-not-found", + "encounter.reminder-conflict", + "encounter.reminder-not-found", + ], + environment: [ + "environment.invalid", + "environment.no_home", + "environment.range_too_large", + "environment.travel.not_found", + ], + export: ["export.selection.empty", "export.selection.unknown_leaf"], + familyHistory: ["familyHistory.invalid"], + feedback: ["feedback.body.invalid"], + health_score_config: [ + "health_score_config.invalid", + "health_score_config.too_narrow", + ], + illness: [ + "illness.day-log.invalid", + "illness.disabled", + "illness.episode.chronic-no-resolve", + "illness.episode.invalid", + "illness.episode.invalid-window", + "illness.episode.parent-not-found", + "illness.insights.invalid", + ], + import: ["import.write_failed"], + ingest: ["ingest.rate_limited"], + insights: [ + "insights.generate.budgetExceeded", + "insights.generate.outboundScreened", + "insights.generate.profileScopeChanged", + "insights.generate.scopeChanged", + ], + labs: [ + "labs.biomarker.create.invalid", + "labs.biomarker.duplicate", + "labs.biomarker.notFound", + "labs.biomarker.update.invalid", + "labs.biomarker.update.referenceRangeInvalid", + "labs.create.invalid", + "labs.ocr.budgetExceeded", + "labs.ocr.extractFailed", + "labs.ocr.fileTooLarge", + "labs.ocr.fileType", + "labs.ocr.localOcrDisabled", + "labs.ocr.pdfNeedsAnthropic", + "labs.ocr.providerUnsupported", + "labs.ocr.rateLimited", + "labs.restore.invalid", + "labs.result.notFound", + "labs.update.invalid", + "labs.update.linkedFieldsImmutable", + "labs.update.numericExpected", + "labs.update.qualitativeExpected", + "labs.update.referenceRangeInvalid", + "labs.update.sourceReferenceRangeInvalid", + ], + managed_profile: [ + "managed_profile.guardian.duplicate", + "managed_profile.guardian.managed_invitee", + "managed_profile.guardian.required", + "managed_profile.not_found", + ], + measurement: [ + "measurement.batch.source_not_permitted", + "measurement.batch.too_large", + "measurement.bulk-delete.invalid", + "measurement.create.invalid", + "measurement.create.source_not_permitted", + "measurement.delete.too_large", + "measurement.duplicate_timestamp", + "measurement.restore.invalid", + "measurement.update.server_owned_source", + ], + medication: ["medication.intake.import.invalid_format"], + medications: [ + "medications.intake.bulk.apple_health_not_mirrored", + "medications.intake.bulk.invalid", + "medications.intake.bulk.too_large", + "medications.intake.force_slot.invalid", + "medications.intake.force_slot.occupied", + "medications.intake.injection_site.disallowed", + "medications.intake.taken_at.before_start", + "medications.mirror.limit_exceeded", + ], + mentalHealth: ["mentalHealth.rateLimited"], + module: ["module.disabled"], + modules: ["modules.body.invalid_json", "modules.invalid"], + mood: [ + "mood.bulk-delete.invalid", + "mood.bulk.invalid", + "mood.bulk.too_large", + "mood.create.external_id_not_delegable", + "mood.create.invalid", + "mood.duplicate_timestamp", + "mood.linkedContext.invalid", + "mood.list.invalid", + "mood.not_found", + "mood.ratedFactor.out_of_range", + "mood.restore.invalid", + "mood.update.invalid", + ], + "notification-prefs": ["notification-prefs.body.invalid_json"], + nutrient: [ + "nutrient.batch.invalid", + "nutrient.batch.too_large", + "nutrient.daily.invalid", + "nutrient.read.invalid", + "nutrient.water.invalid", + "nutrient.water.invalid_day", + ], + onboarding: [ + "onboarding.complete.invalid", + "onboarding.disclaimer.invalid", + "onboarding.step.completed", + "onboarding.step.concurrent", + "onboarding.step.invalid", + "onboarding.step.outOfOrder", + "onboarding.step.rateLimited", + "onboarding.tour.invalid", + "onboarding.user.notFound", + ], + personal_records: ["personal_records.invalid_query"], + practitioner: ["practitioner.invalid"], + profile: [ + "profile.update.emailInUse", + "profile.update.invalidBody", + "profile.update.nothingSaved", + ], + "report-selection": [ + "report-selection.body.invalid_json", + "report-selection.body.invalid_shape", + ], + route: ["route.retired"], + "share-link": [ + "share-link.documents.invalid", + "share-link.report.notFound", + "share-link.report.rateLimited", + "share-link.selection.forbidden_leaf", + "share-link.selection.unknown_leaf", + ], + sharing: [ + "sharing.accept.expired", + "sharing.accept.not_found", + "sharing.accept.not_pending", + "sharing.accept.revoked", + "sharing.access.denied", + "sharing.invite.duplicate", + "sharing.invite.invalid", + "sharing.invite.invalid_scope", + "sharing.invite.manage_browser_only", + "sharing.invite.refused", + "sharing.invite.self", + "sharing.not_permitted", + "sharing.renounce.already_ended", + "sharing.renounce.not_found", + "sharing.revoke.already_ended", + "sharing.revoke.not_found", + "sharing.session.changed", + "sharing.switch.invalid", + "sharing.switch.wrong_transport", + ], + "source-priority": [ + "source-priority.body.invalid_json", + "source-priority.body.invalid_shape", + ], + tokens: ["tokens.measurements.ceiling_reached"], + vaccination: [ + "vaccination.booster-invalid", + "vaccination.booster-no-antigen", + "vaccination.encounter-not-found", + "vaccination.identity-required", + "vaccination.invalid", + "vaccination.link.invalid", + "vaccination.not-found", + "vaccination.practitioner-not-found", + ], + workout: [ + "workout.batch.invalid", + "workout.batch.payload_too_large", + "workout.batch.too_large", + ], + "(unprefixed)": [ + "about_me_conflict", + "ai_response_truncated", + "coach_prefs_conflict", + "dashboard_layout_conflict", + "health_score_config_conflict", + "insights_layout_conflict", + "invalid_base_updated_at", + "medication_layout_conflict", + "modules_conflict", + "mood_tag_layout_conflict", + "not_configured", + "notification_prefs_conflict", + "oidc_only", + "private_origin_not_approved", + "rate_limited_self", + "too_many_rows", + "upstream_invalid_json", + "vapid_not_configured", + ], + } as const; + +/** + * The catalogue as one sentence for the published `meta.errorCode` description. + * + * Rendered rather than hand-written so the document cannot drift from the list + * the guard checks: there is one source, and it is the object above. + */ +export function renderErrorCodeCatalogue(): string { + return Object.entries(ERROR_CODE_CATALOGUE) + .map(([surface, codes]) => `${surface} — ${codes.join(", ")}`) + .join("; "); +} diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 9c68b30d0..15da53657 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -6,6 +6,8 @@ * runtime request parsing, so the wire contract stays single-source. */ import { z } from "zod/v4"; + +import { renderErrorCodeCatalogue } from "../error-codes"; import { createMeasurementSchema as createMeasurementSchemaBase, listMeasurementsSchema as listMeasurementsSchemaBase, @@ -82,7 +84,8 @@ export const errorEnvelope = z .string() .optional() .describe( - "Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be reworded.", + "Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be reworded. Every code the API emits today is enumerated below, grouped by the surface that emits it; four naming conventions coexist and none of them will be renamed, because a code is a wire value a shipped client branches on. Treat an unlisted code the way you would treat an unlisted enum member — as a refusal you do not recognise, not as a malformed response — since the list grows with the surfaces. Three families are outside it on purpose: `assistant.disabled.` is built from a template so the last segment is open (`assistant.disabled.coach` is the one the native client names); the integration-probe classes (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each `/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a credential of its own may carry no code at all. " + + renderErrorCodeCatalogue(), ), }) .optional(), From f9561395c77c1d243d2cb9c3b34091c71ed995c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 08:31:44 +0200 Subject: [PATCH 06/56] fix(api): read every module a route reaches when listing the error codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalogue guard read the route tree plus a hand-kept list of library modules. The dose-history import builds its file-level codes in a module that list never named, so `empty_file`, `missing_required_columns`, `unreadable_json`, `json_not_an_array` and `json_carries_no_intake_time` shipped on `POST /api/medications/intake/dose-history-import` while the envelope's own description promised every code was enumerated below, and the guard was green because it had not read the file. The sweep now walks the import graph from every route under `src/app`: 1423 modules, resolved through the same `@/…` alias the app uses, with the generated client never opened. A code is read where it is written, however many hops from the route that answers with it, and nothing is hand-listed any more. Seeding from the whole app tree rather than `src/app/api` also reached the clinician share-link document route, whose three codes were missing for the same reason. A tuple of codes is read as a family, like the object form already was, which is what makes the import's union visible; the constant holding it is renamed to the `*_ERROR_CODES` spelling the sweep reads, since a list named for anything else is as likely to be nutrients as error codes. The two sync-ledger writers are cut out of the text first — `errorCode` on `IntegrationStatus` is a different field with the same name. Proven by breaking it both ways: dropping `empty_file` from the catalogue turns the guard red, and a code planted in `src/lib/analytics/compliance.ts`, a module no list ever named, turns it red naming that file. --- docs/api/openapi.yaml | 24 +-- src/__tests__/error-code-catalogue.test.ts | 182 +++++++++++++----- .../medications/import/auto-export-parse.ts | 15 +- src/lib/openapi/error-codes.ts | 8 + 4 files changed, 164 insertions(+), 65 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c0da54ed3..34ac2cfe8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -29575,23 +29575,25 @@ components: personal_records.invalid_query; practitioner — practitioner.invalid; profile — profile.update.emailInUse, profile.update.invalidBody, profile.update.nothingSaved; report-selection — report-selection.body.invalid_json, report-selection.body.invalid_shape; route — route.retired; - share-link — share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited, - share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing — - sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked, - sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope, - sharing.invite.manage_browser_only, sharing.invite.refused, sharing.invite.self, sharing.not_permitted, - sharing.renounce.already_ended, sharing.renounce.not_found, sharing.revoke.already_ended, - sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, + share-link — share-link.document.decryptFailed, share-link.document.notFound, + share-link.document.rateLimited, share-link.documents.invalid, share-link.report.notFound, + share-link.report.rateLimited, share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; + sharing — sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, + sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, + sharing.invite.invalid_scope, sharing.invite.manage_browser_only, sharing.invite.refused, + sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, sharing.renounce.not_found, + sharing.revoke.already_ended, sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json, source-priority.body.invalid_shape; tokens — tokens.measurements.ceiling_reached; vaccination — vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, workout.batch.too_large; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict, - dashboard_layout_conflict, health_score_config_conflict, insights_layout_conflict, - invalid_base_updated_at, medication_layout_conflict, modules_conflict, mood_tag_layout_conflict, - not_configured, notification_prefs_conflict, oidc_only, private_origin_not_approved, rate_limited_self, - too_many_rows, upstream_invalid_json, vapid_not_configured" + dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict, + invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, + missing_required_columns, modules_conflict, mood_tag_layout_conflict, not_configured, + notification_prefs_conflict, oidc_only, private_origin_not_approved, rate_limited_self, too_many_rows, + unreadable_json, upstream_invalid_json, vapid_not_configured" type: string additionalProperties: {} required: diff --git a/src/__tests__/error-code-catalogue.test.ts b/src/__tests__/error-code-catalogue.test.ts index a6a7fff08..58f5e2ec3 100644 --- a/src/__tests__/error-code-catalogue.test.ts +++ b/src/__tests__/error-code-catalogue.test.ts @@ -15,57 +15,59 @@ * it and wait forever. * * What it reads: every `.ts` under `src/app/api` outside a `__tests__` - * directory, plus the named library modules that build a refusal envelope of - * their own. Comment lines are stripped first, so a code quoted in prose is not + * directory, and then every module those files import, transitively, so a code + * built one or five hops away from the route that answers with it is read + * where it is written. Import specifiers are resolved the way the app resolves + * them (`@/…` and relative paths, `.ts` / `.tsx` / `index.*`); anything that + * leaves `src` is not followed, and the generated Prisma client is never + * opened. Comment lines are stripped first, so a code quoted in prose is not * mistaken for one that ships. * - * Its limits, stated rather than implied. A new library module that raises a - * coded refusal has to be added to `ENVELOPE_MODULES` below — the route tree is - * swept by rule, that list is not. A code assembled at runtime from a template - * cannot be enumerated at all, and the one family that does so is named in the - * catalogue's own doc comment. And `IntegrationStatus.errorCode` is a different - * field with the same name — it records an upstream HTTP status in the sync - * ledger and never reaches a response — which is why the sweep is scoped to - * the modules that answer requests rather than to `src/lib` wholesale. + * This used to be the route tree plus a hand-kept list of library modules, and + * the hand-kept list is exactly what it sounds like: the dose-history import + * built five codes in a module nobody had thought to add, so the guard read + * neither the codes nor the file and agreed the catalogue was complete. The + * import graph is not hand-kept — a new module reached by a route is swept the + * first time a route imports it. + * + * Its limits, stated rather than implied. A code assembled at runtime from a + * template cannot be enumerated at all, and the one family that does so is + * named in the catalogue's own doc comment. A code that reaches the envelope + * through a variable is found where the values are declared, which means a + * literal in a constant the sweep's shapes do not cover would still be missed: + * a constant that holds wire codes is named `*_CODE` / `*_CODES` for that + * reason. And `IntegrationStatus.errorCode` is a different field with the same + * name — it records an upstream HTTP status in the sync ledger and never + * reaches a response — so the two ledger writers that take it are cut out of + * the text before it is matched. */ -import { readFileSync, readdirSync } from "node:fs"; -import { join, relative, sep } from "node:path"; +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, join, relative, resolve, sep } from "node:path"; import { describe, expect, it } from "vitest"; import { ERROR_CODE_CATALOGUE } from "@/lib/openapi/error-codes"; -const API_ROOT = join(process.cwd(), "src", "app", "api"); +const SRC_ROOT = join(process.cwd(), "src"); +const APP_ROOT = join(SRC_ROOT, "app"); +/** Never opened: the generated Prisma client is megabytes of machine output. */ +const GENERATED_ROOT = join(SRC_ROOT, "generated"); /** - * Library modules that build a coded refusal without going through a route - * file. Everything else under `src/lib` is out of scope on purpose — see the - * note on `IntegrationStatus.errorCode` in the file header. + * The sync ledger's writers. `IntegrationStatus.errorCode` records an upstream + * HTTP status and never reaches a response envelope, so their arguments are cut + * out before a code is looked for. A third writer would surface as an + * undocumented code rather than as silence, which is the direction to fail in. */ -const ENVELOPE_MODULES = [ - "src/lib/ai/consent-guard.ts", - "src/lib/api-errors.ts", - "src/lib/api-handler.ts", - "src/lib/auth/profile-update.ts", - "src/lib/clinician-share/report-download.ts", - "src/lib/cycle/gate.ts", - "src/lib/documents/ai-route-support.ts", - "src/lib/documents/attach-validate.ts", - "src/lib/export/backup-blob.ts", - "src/lib/http/retired-routes.ts", - "src/lib/illness/gate.ts", - "src/lib/modules/gate.ts", - "src/lib/optimistic-lock.ts", - "src/lib/sharing/record-session-fence-contract.ts", -]; +const LEDGER_WRITERS = ["recordSyncFailure", "parkIntegrationAtReauth"]; -function apiFiles(dir: string): string[] { +function tsFiles(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(...apiFiles(full)); + found.push(...tsFiles(full)); continue; } if (entry.name.endsWith(".ts")) found.push(full); @@ -73,6 +75,76 @@ function apiFiles(dir: string): string[] { return found; } +/** Resolve one import specifier the way the app's path alias resolves it. */ +function resolveSpecifier(specifier: string, fromFile: string): string | null { + let base: string; + if (specifier.startsWith("@/")) base = join(SRC_ROOT, specifier.slice(2)); + else if (specifier.startsWith(".")) + base = resolve(dirname(fromFile), specifier); + else return null; + for (const candidate of [ + `${base}.ts`, + `${base}.tsx`, + join(base, "index.ts"), + join(base, "index.tsx"), + ]) { + if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; + } + return null; +} + +/** Every module a route reaches, however many hops away it is written. */ +function modulesUnderTheRoutes(): string[] { + const queue = tsFiles(APP_ROOT); + const seen = new Set(queue); + for (let i = 0; i < queue.length; i++) { + const file = queue[i]; + const source = readFileSync(file, "utf8"); + const specifiers = [ + ...source.matchAll( + /(?:^|\n)\s*(?:import|export)[\s\S]{0,400}?from\s*"([^"]+)"/g, + ), + ...source.matchAll(/import\(\s*"([^"]+)"\s*\)/g), + ]; + for (const match of specifiers) { + const resolved = resolveSpecifier(match[1], file); + if (!resolved) continue; + if (resolved.startsWith(GENERATED_ROOT)) continue; + if (resolved.includes(`${sep}__tests__${sep}`)) continue; + if (seen.has(resolved)) continue; + seen.add(resolved); + queue.push(resolved); + } + } + return [...seen]; +} + +/** Cut the sync-ledger writers' arguments out, parenthesis-balanced. */ +function withoutLedgerWrites(source: string): string { + let text = source; + for (const writer of LEDGER_WRITERS) { + for (;;) { + const call = text.indexOf(`${writer}(`); + if (call === -1) break; + const open = call + writer.length; + let depth = 0; + let close = text.length; + for (let j = open; j < text.length; j++) { + if (text[j] === "(") depth++; + else if (text[j] === ")") { + depth--; + if (depth === 0) { + close = j + 1; + break; + } + } + } + text = text.slice(0, call) + text.slice(close); + } + } + return text; +} + /** Drop comment lines so a code quoted in prose is not read as one that ships. */ function withoutComments(source: string): string { return source @@ -88,7 +160,7 @@ function withoutComments(source: string): string { .join("\n"); } -/** The three shapes a code is written in across the tree. */ +/** The shapes a code is written in across the tree. */ function codesIn(source: string): string[] { const found: string[] = []; // `errorCode: "x"`, `errorCode = "x"`, `readonly errorCode = "x"`, and the @@ -102,14 +174,22 @@ function codesIn(source: string): string[] { for (const m of source.matchAll(/\b[A-Z][A-Z0-9_]*_CODE\s*=\s*"([^"]+)"/g)) { found.push(m[1]); } - // A family in one object: `export const AUTH_ERROR_CODES = { … }`. - for (const m of source.matchAll(/\b[A-Z][A-Z0-9_]*_CODES\s*=\s*\{/g)) { - const start = source.indexOf("{", m.index); + // A family in one object: `export const AUTH_ERROR_CODES = { … }`, or in one + // tuple: `export const AUTO_EXPORT_FATAL_ERROR_CODES = [ … ] as const`. The + // tuple is how a union type of codes is declared, and that union is what an + // envelope relays when it writes `errorCode: parsed.fatal.reason`. Only the + // `_ERROR_CODES` spelling is read in tuple form: plenty of unrelated + // catalogues (nutrients, say) are lists of codes that are not error codes. + for (const m of source.matchAll(/\b([A-Z][A-Z0-9_]*_CODES)\s*=\s*([{[])/g)) { + const [, name, opener] = m; + if (opener === "[" && !name.endsWith("_ERROR_CODES")) continue; + const closer = opener === "{" ? "}" : "]"; + const start = source.indexOf(opener, m.index); let depth = 0; let end = start; for (let j = start; j < source.length; j++) { - if (source[j] === "{") depth++; - else if (source[j] === "}") { + if (source[j] === opener) depth++; + else if (source[j] === closer) { depth--; if (depth === 0) { end = j; @@ -117,9 +197,12 @@ function codesIn(source: string): string[] { } } } - for (const value of source.slice(start, end).matchAll(/:\s*"([^"]+)"/g)) { - found.push(value[1]); - } + const body = source.slice(start, end); + const values = + opener === "{" + ? body.matchAll(/:\s*"([^"]+)"/g) + : body.matchAll(/"([^"]+)"/g); + for (const value of values) found.push(value[1]); } return found; } @@ -127,12 +210,10 @@ function codesIn(source: string): string[] { /** Every code the tree emits, mapped to the files that emit it. */ function emittedCodes(): Map { const emitted = new Map(); - const files = [ - ...apiFiles(API_ROOT), - ...ENVELOPE_MODULES.map((path) => join(process.cwd(), path)), - ]; - for (const file of files) { - const source = withoutComments(readFileSync(file, "utf8")); + for (const file of modulesUnderTheRoutes()) { + const source = withoutLedgerWrites( + withoutComments(readFileSync(file, "utf8")), + ); for (const code of codesIn(source)) { const where = relative(process.cwd(), file).split(sep).join("/"); const seen = emitted.get(code); @@ -152,7 +233,8 @@ const published = new Set(Object.values(ERROR_CODE_CATALOGUE).flat()); describe("the published errorCode catalogue", () => { it("finds the codes it is meant to judge", () => { // A matcher that stopped matching would otherwise agree with an empty - // catalogue. Two hundred and forty-two when this was written. + // catalogue. Two hundred and twenty-nine when this was written, read out + // of the fourteen hundred modules the route tree reaches. expect(emitted.size).toBeGreaterThan(200); }); diff --git a/src/lib/medications/import/auto-export-parse.ts b/src/lib/medications/import/auto-export-parse.ts index 357d9ac76..ca146e1e9 100644 --- a/src/lib/medications/import/auto-export-parse.ts +++ b/src/lib/medications/import/auto-export-parse.ts @@ -92,8 +92,14 @@ export interface AutoExportRefusal { reason: AutoExportRefusalReason; } -/** A file-level problem: nothing can be read at all. */ -export const AUTO_EXPORT_FATAL_REASONS = [ +/** + * A file-level problem: nothing can be read at all. + * + * These values ship as `meta.errorCode` on the import route's 422, which is + * why the constant is named for what they are on the wire rather than for the + * field that carries them internally. + */ +export const AUTO_EXPORT_FATAL_ERROR_CODES = [ "empty_file", "missing_required_columns", "unreadable_json", @@ -102,10 +108,11 @@ export const AUTO_EXPORT_FATAL_REASONS = [ "json_carries_no_intake_time", ] as const; -export type AutoExportFatalReason = (typeof AUTO_EXPORT_FATAL_REASONS)[number]; +export type AutoExportFatalErrorCode = + (typeof AUTO_EXPORT_FATAL_ERROR_CODES)[number]; export interface AutoExportParseOutcome { - fatal?: { reason: AutoExportFatalReason; detail?: string }; + fatal?: { reason: AutoExportFatalErrorCode; detail?: string }; doses: AutoExportDose[]; refusals: AutoExportRefusal[]; /** Data rows read, refusals included. `doses + refusals` must equal it. */ diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index d30ffabba..9ba5e0a47 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -257,6 +257,9 @@ export const ERROR_CODE_CATALOGUE: Readonly> = ], route: ["route.retired"], "share-link": [ + "share-link.document.decryptFailed", + "share-link.document.notFound", + "share-link.document.rateLimited", "share-link.documents.invalid", "share-link.report.notFound", "share-link.report.rateLimited", @@ -309,10 +312,14 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "ai_response_truncated", "coach_prefs_conflict", "dashboard_layout_conflict", + "empty_file", "health_score_config_conflict", "insights_layout_conflict", "invalid_base_updated_at", + "json_carries_no_intake_time", + "json_not_an_array", "medication_layout_conflict", + "missing_required_columns", "modules_conflict", "mood_tag_layout_conflict", "not_configured", @@ -321,6 +328,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "private_origin_not_approved", "rate_limited_self", "too_many_rows", + "unreadable_json", "upstream_invalid_json", "vapid_not_configured", ], From b2c8fe85679c1e697fe146d1b0f90cb44fdcfe91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 08:35:35 +0200 Subject: [PATCH 07/56] fix(api): answer the unknown-leaf refusal with a sentence and a code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PUT /api/auth/me/report-selection` threw a bare 422 whose whole `error` string was `report-selection.leaves.unknown` — a machine token in the field a client shows a person, with nothing beside it to branch on and no way to learn which leaves were refused. It now answers a sentence, with the token in `meta.errorCode` and the refused ids in `meta.unknownLeaves`, and the operation's description says so. The site was invisible to the refusal guard because the refusal is decided by `if (!minted.ok)` rather than by a parse result, so the guard now reads the refused string as well as the deciding block: every 422 under `src/app/api` whose `error` is a dotted token is inventoried with the reason it still is one. Eleven remain, each with its reason, and the inventory may only shrink. Removing the fix above turns it red naming that route. The four sibling shape refusals keep their token in `error` on purpose, and the comment now says why that is not the same decision as the seven `invalid_json` sites: those moved their string because their status moved to 400 anyway, while a shape refusal that keeps its 422 would be changing the wire for nothing. Both halves publish `meta.errorCode`. Two loose ends in the same guard: a block calling `returnAllZodIssues` or `apiValidationError` without spelling `422` is now judged (their default status is 422, so a future `refuse422(err)` helper cannot slip past), and the site floor moves from 40 to 200 against the 248 the tree carries — the old floor would have survived losing five matches in six. --- docs/api/openapi.yaml | 30 ++-- .../zod-refusal-issue-list-guard.test.ts | 140 ++++++++++++++++-- src/app/api/auth/me/coach-prefs/route.ts | 8 +- src/app/api/auth/me/report-selection/route.ts | 22 ++- src/app/api/auth/me/source-priority/route.ts | 8 +- .../chat/messages/[id]/feedback/route.ts | 8 +- src/app/api/insights/chat/route.ts | 8 +- src/lib/openapi/error-codes.ts | 1 + src/lib/openapi/routes/profile.ts | 2 +- 9 files changed, 186 insertions(+), 41 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 34ac2cfe8..8d65be753 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -7773,7 +7773,8 @@ paths: - Auth summary: Replace the owner's saved doctor-report selection description: Replace, not merge — the body states the whole scope or it is not a scope; a leaf omitted from the body is - a leaf removed from the saved profile. An unknown leaf id is refused with a 422 that names it. The saved + a leaf removed from the saved profile. An unknown leaf id is refused with a 422 carrying `meta.errorCode` + `report-selection.leaves.unknown` and `meta.unknownLeaves`, the ids this build does not know. The saved selection is also what the FHIR REST face and the MCP doctor-visit surfaces replay for a caller that cannot ask a human, so every write is audit-logged. Auth via cookie or Bearer. requestBody: @@ -29574,19 +29575,20 @@ components: onboarding.step.rateLimited, onboarding.tour.invalid, onboarding.user.notFound; personal_records — personal_records.invalid_query; practitioner — practitioner.invalid; profile — profile.update.emailInUse, profile.update.invalidBody, profile.update.nothingSaved; report-selection — - report-selection.body.invalid_json, report-selection.body.invalid_shape; route — route.retired; - share-link — share-link.document.decryptFailed, share-link.document.notFound, - share-link.document.rateLimited, share-link.documents.invalid, share-link.report.notFound, - share-link.report.rateLimited, share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; - sharing — sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, - sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, - sharing.invite.invalid_scope, sharing.invite.manage_browser_only, sharing.invite.refused, - sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, sharing.renounce.not_found, - sharing.revoke.already_ended, sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, - sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json, - source-priority.body.invalid_shape; tokens — tokens.measurements.ceiling_reached; vaccination — - vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.encounter-not-found, - vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found, + report-selection.body.invalid_json, report-selection.body.invalid_shape, + report-selection.leaves.unknown; route — route.retired; share-link — share-link.document.decryptFailed, + share-link.document.notFound, share-link.document.rateLimited, share-link.documents.invalid, + share-link.report.notFound, share-link.report.rateLimited, share-link.selection.forbidden_leaf, + share-link.selection.unknown_leaf; sharing — sharing.accept.expired, sharing.accept.not_found, + sharing.accept.not_pending, sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, + sharing.invite.invalid, sharing.invite.invalid_scope, sharing.invite.manage_browser_only, + sharing.invite.refused, sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, + sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found, + sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority — + source-priority.body.invalid_json, source-priority.body.invalid_shape; tokens — + tokens.measurements.ceiling_reached; vaccination — vaccination.booster-invalid, + vaccination.booster-no-antigen, vaccination.encounter-not-found, vaccination.identity-required, + vaccination.invalid, vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, workout.batch.too_large; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict, diff --git a/src/__tests__/zod-refusal-issue-list-guard.test.ts b/src/__tests__/zod-refusal-issue-list-guard.test.ts index 00031d521..57426b41b 100644 --- a/src/__tests__/zod-refusal-issue-list-guard.test.ts +++ b/src/__tests__/zod-refusal-issue-list-guard.test.ts @@ -18,13 +18,21 @@ * judged — a 400, a 401 or a 403 raised off a parse result is a different * refusal and is left alone. * + * The second half of the same audit item is the dotted token in `error`, and + * it is checked by its own sweep at the bottom of this file rather than by the + * per-site matcher above: `report-selection.leaves.unknown` was decided by + * `if (!minted.ok)` rather than by a parse result, so the block matcher could + * not see it while the string it answered with was the defect itself. The + * token sweep reads the refused string, not the condition that chose it, so a + * site cannot hide behind the shape of its own `if`. + * * 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. + * invisible to the issue-list half, 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 assertions below are what keep it from passing + * on a matcher that silently stopped matching anything. */ import { readFileSync, readdirSync } from "node:fs"; import { join, relative, sep } from "node:path"; @@ -106,11 +114,21 @@ function scanRefusalSites(): RefusalSite[] { )) { if (!parsed.has(m[1])) continue; const block = refusalBlock(src, (m.index ?? 0) + m[0].length); - if (!/\b422\b/.test(block)) continue; + // `returnAllZodIssues` and `apiValidationError` answer 422 unless told + // otherwise, so a block that calls one is a 422 site whether or not the + // number is spelled: a future `refuse422(parsed.error)` helper would + // otherwise be invisible for the same reason. + const carriesIssues = /returnAllZodIssues|apiValidationError/.test(block); + if (!/\b422\b/.test(block) && !carriesIssues) continue; + if ( + /\b(?:400|401|403|409|413|415|429|500|502)\b/.test(block) && + !/\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), + carriesIssues, }); } } @@ -122,9 +140,9 @@ describe("Zod shape refusals carry the issue list", () => { 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); + // not earn. Two hundred and forty-eight sites when this was written, so + // the old floor of 40 would have survived losing five matches in six. + expect(sites.length).toBeGreaterThan(200); }); it("answers every 422 with details.issues", () => { @@ -141,3 +159,107 @@ describe("Zod shape refusals carry the issue list", () => { expect(stale).toEqual([]); }); }); + +/** + * The dotted token in `error`, wherever it is refused from. + * + * `error` is the field a client shows a person. A 422 that puts + * `report-selection.leaves.unknown` there hands the UI a string it must not + * render and gives it nothing else to branch on. That site was decided by + * `if (!minted.ok)`, not by a parse result, so the per-site matcher above ran + * past it — which is the argument for reading the refused string directly: + * whatever chose the refusal, the string is the defect. + * + * The inventory below is frozen and may only shrink. Each entry says why the + * token is still the whole `error` string; a new one fails until somebody + * either writes a sentence or writes the reason. + */ +const TOKEN_IN_ERROR_422: Record = { + "src/app/api/auth/me/coach-prefs/route.ts coach-prefs.body.invalid_shape": + "The status does not move for a shape refusal, so moving the string too would be an unforced second wire change; `meta.errorCode` now carries the same token.", + "src/app/api/auth/me/report-selection/route.ts report-selection.body.invalid_shape": + "Same shape refusal, same reason.", + "src/app/api/auth/me/source-priority/route.ts source-priority.body.invalid_shape": + "Same shape refusal, same reason.", + "src/app/api/insights/chat/messages/[id]/feedback/route.ts feedback.body.invalid": + "Same shape refusal, same reason.", + "src/app/api/insights/chat/route.ts coach.request.invalid": + "Same shape refusal, same reason.", + "src/app/api/auth/me/health-score-config/route.ts health-score-config.body.invalid_json": + "The published 422 description spells this exact body out as what a non-JSON request earns. It is the one operation whose contract promises the token, so the token is what it answers with.", + "src/app/api/coach/reminder-suggestions/route.ts coach.suggestion.unknownCadence": + "Predates this sweep and carries no `meta.errorCode` yet; the Coach reminder surfaces move as one piece or not at all.", + "src/app/api/coach/reminders/route.ts coach.reminder.invalidWhen": + "Predates this sweep, same surface.", + "src/app/api/coach/reminders/[id]/route.ts coach.reminder.invalidWhen": + "Predates this sweep, same surface.", + "src/app/api/coach/suggested-actions/route.ts coach.action.unknownInterval": + "Predates this sweep, same surface.", + "src/app/api/coach/suggested-actions/route.ts coach.reminder.invalidWhen": + "Predates this sweep, same surface.", +}; + +/** A dotted machine token: no spaces, at least one dot, no sentence in sight. */ +const DOTTED_TOKEN = /^[a-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)+$/; + +/** Every `.ts` under `src/app/api`, tests excluded — not only `route.ts`. */ +function apiFiles(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(...apiFiles(full)); + continue; + } + if (entry.name.endsWith(".ts")) found.push(full); + } + return found; +} + +/** Every 422 whose `error` string is a dotted token, keyed ` `. */ +function tokenRefusals(): string[] { + const found: string[] = []; + for (const file of apiFiles(API_ROOT)) { + const src = readFileSync(file, "utf8"); + const route = relative(process.cwd(), file).split(sep).join("/"); + const shapes = [ + /apiError\(\s*"([^"]+)"\s*,\s*422/g, + /apiValidationError\(\s*"([^"]+)"/g, + /new HttpError\(\s*422\s*,\s*"([^"]+)"/g, + ]; + for (const shape of shapes) { + for (const m of src.matchAll(shape)) { + if (!DOTTED_TOKEN.test(m[1])) continue; + const key = `${route} ${m[1]}`; + if (!found.includes(key)) found.push(key); + } + } + } + return found; +} + +describe("a 422 does not answer with a machine token where the sentence goes", () => { + const refusals = tokenRefusals(); + + it("finds the sites it is meant to judge", () => { + // Eleven when this was written, twelve before the report-selection leaf + // refusal was rewritten. A matcher that stopped matching would agree with + // an empty inventory. + expect(refusals.length).toBeGreaterThan(8); + }); + + it("keeps the token in `error` only where a reason is written down", () => { + const unexplained = refusals + .filter((key) => !(key in TOKEN_IN_ERROR_422)) + .sort(); + expect(unexplained).toEqual([]); + }); + + it("keeps no stale inventory entry", () => { + const stale = Object.keys(TOKEN_IN_ERROR_422) + .filter((key) => !refusals.includes(key)) + .sort(); + expect(stale).toEqual([]); + }); +}); diff --git a/src/app/api/auth/me/coach-prefs/route.ts b/src/app/api/auth/me/coach-prefs/route.ts index 7739a4ea2..4e1bf8593 100644 --- a/src/app/api/auth/me/coach-prefs/route.ts +++ b/src/app/api/auth/me/coach-prefs/route.ts @@ -81,9 +81,11 @@ export const PUT = apiHandler(async (req: Request) => { action: { name: "auth.me.coach-prefs.put.invalid" }, meta: { issues: parsed.error.issues.length }, }); - // 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. + // The dotted token keeps its place in `error`: this refusal keeps its 422, + // so moving the string would be a wire change with nothing forcing it — + // the `invalid_json` siblings moved only because their status moved to 400 + // anyway. `meta.errorCode` publishes the same token in the field a machine + // code belongs in, and `details.issues` names the fields that were refused. return apiValidationError( "coach-prefs.body.invalid_shape", sanitiseZodIssues(parsed.error.issues), diff --git a/src/app/api/auth/me/report-selection/route.ts b/src/app/api/auth/me/report-selection/route.ts index e7aeed85b..bbb85b9c2 100644 --- a/src/app/api/auth/me/report-selection/route.ts +++ b/src/app/api/auth/me/report-selection/route.ts @@ -16,7 +16,7 @@ * Replaces `GET`/`PUT /api/auth/me/doctor-report-prefs`, whose column nothing * outside that route ever read. */ -import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -65,9 +65,11 @@ export const PUT = apiHandler(async (req: Request) => { action: { name: "auth.me.report-selection.put.invalid" }, meta: { issues: parsed.error.issues.length }, }); - // 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. + // The dotted token keeps its place in `error`: this refusal keeps its 422, + // so moving the string would be a wire change with nothing forcing it — + // the `invalid_json` siblings moved only because their status moved to 400 + // anyway. `meta.errorCode` publishes the same token in the field a machine + // code belongs in, and `details.issues` names the fields that were refused. return apiValidationError( "report-selection.body.invalid_shape", sanitiseZodIssues(parsed.error.issues), @@ -82,7 +84,17 @@ export const PUT = apiHandler(async (req: Request) => { action: { name: "auth.me.report-selection.put.invalid" }, meta: { unknownLeaves: minted.error.unknownLeaves }, }); - throw new HttpError(422, "report-selection.leaves.unknown"); + // Not a Zod refusal, so there is no issue list to carry — but the same + // rule applies: a sentence in `error`, the machine token in + // `meta.errorCode`, and the leaves that were refused named beside it. + return apiError( + "Report selection names leaves this build does not know", + 422, + { + errorCode: "report-selection.leaves.unknown", + unknownLeaves: minted.error.unknownLeaves, + }, + ); } // Persist the canonical ordering rather than the caller's, so two clients diff --git a/src/app/api/auth/me/source-priority/route.ts b/src/app/api/auth/me/source-priority/route.ts index f1c50337e..d5897cd1d 100644 --- a/src/app/api/auth/me/source-priority/route.ts +++ b/src/app/api/auth/me/source-priority/route.ts @@ -65,9 +65,11 @@ export const PUT = apiHandler(async (req: Request) => { action: { name: "auth.me.source-priority.put.invalid" }, meta: { issues: parsed.error.issues.length }, }); - // 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. + // The dotted token keeps its place in `error`: this refusal keeps its 422, + // so moving the string would be a wire change with nothing forcing it — + // the `invalid_json` siblings moved only because their status moved to 400 + // anyway. `meta.errorCode` publishes the same token in the field a machine + // code belongs in, and `details.issues` names the fields that were refused. return apiValidationError( "source-priority.body.invalid_shape", sanitiseZodIssues(parsed.error.issues), diff --git a/src/app/api/insights/chat/messages/[id]/feedback/route.ts b/src/app/api/insights/chat/messages/[id]/feedback/route.ts index 668a06599..4056d7eb9 100644 --- a/src/app/api/insights/chat/messages/[id]/feedback/route.ts +++ b/src/app/api/insights/chat/messages/[id]/feedback/route.ts @@ -93,9 +93,11 @@ async function handlePost(request: NextRequest, ctx: RouteContext) { action: { name: "insights.coach.message.feedback" }, meta: { outcome: "validation_failed" }, }); - // 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. + // The dotted token keeps its place in `error`: this refusal keeps its 422, + // so moving the string would be a wire change with nothing forcing it — + // the `invalid_json` siblings moved only because their status moved to 400 + // anyway. `meta.errorCode` publishes the same token in the field a machine + // code belongs in, and `details.issues` names the fields that were refused. return apiValidationError( "feedback.body.invalid", sanitiseZodIssues(parsed.error.issues), diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index e14cdff7e..9dc19d79d 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -214,9 +214,11 @@ async function handleChatRequest(request: NextRequest): Promise { action: { name: "insights.coach.invalid" }, meta: { issues: parsed.error.issues.length }, }); - // 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. + // The dotted token keeps its place in `error`: this refusal keeps its 422, + // so moving the string would be a wire change with nothing forcing it — + // the `invalid_json` siblings moved only because their status moved to 400 + // anyway. `meta.errorCode` publishes the same token in the field a machine + // code belongs in, and `details.issues` names the fields that were refused. return apiValidationError( "coach.request.invalid", sanitiseZodIssues(parsed.error.issues), diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 9ba5e0a47..67ef9f148 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -254,6 +254,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "report-selection": [ "report-selection.body.invalid_json", "report-selection.body.invalid_shape", + "report-selection.leaves.unknown", ], route: ["route.retired"], "share-link": [ diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 5c02cfcb7..2ce21fc07 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -1540,7 +1540,7 @@ export const profilePaths: NonNullable = { tags: ["Auth"], summary: "Replace the owner's saved doctor-report selection", description: - "Replace, not merge — the body states the whole scope or it is not a scope; a leaf omitted from the body is a leaf removed from the saved profile. An unknown leaf id is refused with a 422 that names it. The saved selection is also what the FHIR REST face and the MCP doctor-visit surfaces replay for a caller that cannot ask a human, so every write is audit-logged. Auth via cookie or Bearer.", + "Replace, not merge — the body states the whole scope or it is not a scope; a leaf omitted from the body is a leaf removed from the saved profile. An unknown leaf id is refused with a 422 carrying `meta.errorCode` `report-selection.leaves.unknown` and `meta.unknownLeaves`, the ids this build does not know. The saved selection is also what the FHIR REST face and the MCP doctor-visit surfaces replay for a caller that cannot ask a human, so every write is audit-logged. Auth via cookie or Bearer.", requestBody: { required: true, content: { "application/json": { schema: savedReportProfile } }, From 68964b4bac1fe58bbf99d364bf9b2bab70808fa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 08:38:16 +0200 Subject: [PATCH 08/56] docs(api): correct two comments that describe behaviour the code does not have MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-record write ceiling justified itself with "every one of these routes is already wrapped in `withIdempotency`". Ten of the eleven are. `POST /api/medications/intake` is not, and it is the one of the eleven a native client actually loops — it is replay-tolerant for its own reason, being an update of a named `intakeId` rather than a create. The comment now says which is which, and adds the clause it was missing: the check runs before the body is read, so a refused request spends the allowance too. The custom-symptom wrapper said a replay "answered the 409 that uniqueness raises". There is no such 409. The row's key is minted fresh per request and the label lives in an AES-GCM column with a per-write IV, which can carry no unique index, so an un-keyed replay wrote a second row with the same label — a silent duplicate in the person's own vocabulary, up to the fifty-row cap. The wrapper is worth more than the old sentence claimed, not less. --- src/app/api/cycle/symptoms/custom/route.ts | 13 +++++++++---- src/lib/rate-limit.ts | 11 ++++++++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/app/api/cycle/symptoms/custom/route.ts b/src/app/api/cycle/symptoms/custom/route.ts index a716de0d7..ac4d56572 100644 --- a/src/app/api/cycle/symptoms/custom/route.ts +++ b/src/app/api/cycle/symptoms/custom/route.ts @@ -70,10 +70,15 @@ export const GET = apiHandler(async () => { /** * Wrapped in `withIdempotency`: a custom symptom is minted from the client's - * offline outbox under the same `Idempotency-Key`, and a replay after a lost - * success response would otherwise answer the 409 the label's unique key - * raises rather than the 201 the first attempt produced — a create the client - * has no record of, reported as a conflict it cannot act on. + * offline outbox under the same `Idempotency-Key`, and nothing else here would + * catch the replay. The row's key is minted fresh per request + * (`custom:`), and the label it would duplicate lives in + * `labelEncrypted` — AES-GCM with a per-write IV, so no unique index can be + * put on it. A replay after a lost success response therefore wrote a SECOND + * row carrying the same label, up to the fifty-row cap: a silent duplicate in + * the person's own symptom vocabulary rather than a conflict anything refused. + * The wrapper answers the first attempt's 201 instead, and the in-flight 409 it + * defines is the only 409 this route can produce. */ export const POST = apiHandler( withIdempotency<[NextRequest]>(postCustomSymptom), diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 0bef620e2..d6cc7b624 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -132,9 +132,14 @@ export async function checkAnalyticsReadRateLimit( * `checkAnalyticsReadRateLimit` gives: a loop that rotates across ten creates * should still meet a cap. Three hundred a minute is five writes a second * sustained for a full minute — no hand-driven session and no sync burst - * reaches it, and every one of these routes is already wrapped in - * `withIdempotency`, so an honest retry storm is deduplicated before it is - * counted. It exists to cut off the pathological loop, not to shape traffic. + * reaches it. Ten of the eleven are wrapped in `withIdempotency`, so an honest + * retry storm is deduplicated before it is counted; `POST + * /api/medications/intake` is not, because it updates a named `intakeId` + * rather than creating a row, so a replay writes the status that is already + * there. It exists to cut off the pathological loop, not to shape traffic. + * + * A refused request is counted too: the check runs before the body is read, + * so a client looping on a 422 spends its allowance without writing a row. * * Keyed on the ACTING account rather than the record, the frozen precedent from * the delegable medication routes: a delegate burns their own allowance rather From 3ffdfe15a19053592d087ebfbea71e099d88b984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 08:39:09 +0200 Subject: [PATCH 09/56] fix(api): give the shared write ceiling a code a client can branch on The eleven single-record writes answered their shared 429 with prose and nothing else, while the envelope's own description tells a client to branch on `meta.errorCode`. On `POST /api/medications/[id]/intake` and `POST /api/cycle/day-logs` a second, narrower bucket can answer 429 as well, so the presence of the rate-limit headers was the only thing telling the two refusals apart. All eleven now carry `record_write.rate_limited`, the catalogue lists it, the published 429 names it, and the contract test fails if a site stops emitting it or the sentence stops publishing it. The same description now states what a client could not predict from "300 requests per 60 seconds": refused requests count too, because the check runs before the body is read. --- docs/api/openapi.yaml | 43 ++++++++++--------- .../record-write-rate-limit-contract.test.ts | 12 ++++++ src/app/api/allergies/route.ts | 4 +- src/app/api/biomarkers/route.ts | 4 +- .../api/custom-metrics/[id]/entries/route.ts | 4 +- src/app/api/cycle/day-logs/route.ts | 4 +- src/app/api/encounters/route.ts | 4 +- src/app/api/labs/route.ts | 4 +- src/app/api/measurements/route.ts | 4 +- src/app/api/medications/[id]/intake/route.ts | 4 +- src/app/api/medications/intake/route.ts | 4 +- src/app/api/mood-entries/route.ts | 4 +- src/app/api/vaccinations/route.ts | 4 +- src/lib/openapi/error-codes.ts | 1 + src/lib/openapi/routes/shared.ts | 2 +- 15 files changed, 70 insertions(+), 32 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8d65be753..4cc137503 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -392,12 +392,15 @@ paths: "409": *a7 "422": *a4 "429": &a10 - description: Rate limit exceeded. This route shares one per-account bucket with the other single-record writes — + description: "Rate limit exceeded. This route shares one per-account bucket with the other single-record writes — `record-write:`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns - their own allowance rather than the record owner's. Nothing was written. The `Retry-After`, - `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that bucket; back off - on `Retry-After` rather than guessing. A client with more than a handful of rows to send should use the - batch endpoint for its domain instead of looping this one. + their own allowance rather than the record owner's. Nothing was written. `meta.errorCode` is + `record_write.rate_limited`, which is what tells this refusal apart from a route's own narrower bucket when + both can answer 429. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` + headers describe that bucket; back off on `Retry-After` rather than guessing. Every request counts against + the bucket, refused ones included: the check runs before the body is read, so a client looping on a 422 + spends the same allowance as one that writes rows. A client with more than a handful of rows to send should + use the batch endpoint for its domain instead of looping this one." headers: *a8 content: application/json: @@ -29574,21 +29577,21 @@ components: onboarding.step.concurrent, onboarding.step.invalid, onboarding.step.outOfOrder, onboarding.step.rateLimited, onboarding.tour.invalid, onboarding.user.notFound; personal_records — personal_records.invalid_query; practitioner — practitioner.invalid; profile — - profile.update.emailInUse, profile.update.invalidBody, profile.update.nothingSaved; report-selection — - report-selection.body.invalid_json, report-selection.body.invalid_shape, - report-selection.leaves.unknown; route — route.retired; share-link — share-link.document.decryptFailed, - share-link.document.notFound, share-link.document.rateLimited, share-link.documents.invalid, - share-link.report.notFound, share-link.report.rateLimited, share-link.selection.forbidden_leaf, - share-link.selection.unknown_leaf; sharing — sharing.accept.expired, sharing.accept.not_found, - sharing.accept.not_pending, sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, - sharing.invite.invalid, sharing.invite.invalid_scope, sharing.invite.manage_browser_only, - sharing.invite.refused, sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, - sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found, - sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority — - source-priority.body.invalid_json, source-priority.body.invalid_shape; tokens — - tokens.measurements.ceiling_reached; vaccination — vaccination.booster-invalid, - vaccination.booster-no-antigen, vaccination.encounter-not-found, vaccination.identity-required, - vaccination.invalid, vaccination.link.invalid, vaccination.not-found, + profile.update.emailInUse, profile.update.invalidBody, profile.update.nothingSaved; record_write — + record_write.rate_limited; report-selection — report-selection.body.invalid_json, + report-selection.body.invalid_shape, report-selection.leaves.unknown; route — route.retired; share-link + — share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited, + share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited, + share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing — + sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked, + sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope, + sharing.invite.manage_browser_only, sharing.invite.refused, sharing.invite.self, sharing.not_permitted, + sharing.renounce.already_ended, sharing.renounce.not_found, sharing.revoke.already_ended, + sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, + sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json, + source-priority.body.invalid_shape; tokens — tokens.measurements.ceiling_reached; vaccination — + vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.encounter-not-found, + vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, workout.batch.too_large; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict, diff --git a/src/__tests__/record-write-rate-limit-contract.test.ts b/src/__tests__/record-write-rate-limit-contract.test.ts index e9dec3cfa..55c327147 100644 --- a/src/__tests__/record-write-rate-limit-contract.test.ts +++ b/src/__tests__/record-write-rate-limit-contract.test.ts @@ -128,6 +128,18 @@ describe("the shared single-record write bucket", () => { expect(missing).toEqual([]); }); + it("names the code it publishes at every site", () => { + // The prose is not the discriminator: two of these routes consult a second + // bucket of their own, and a client that can only read `error` cannot tell + // which one refused it. + const silent = ON_THE_SHARED_BUCKET.filter(([route]) => { + const src = readFileSync(join(process.cwd(), route), "utf8"); + return !src.includes('errorCode: "record_write.rate_limited"'); + }).map(([route]) => route); + expect(silent).toEqual([]); + expect(published).toContain("record_write.rate_limited"); + }); + it("is published on every operation that answers it", () => { const doc = buildOpenApiDocument() as { paths: Record< diff --git a/src/app/api/allergies/route.ts b/src/app/api/allergies/route.ts index e7b544667..2f9d4d360 100644 --- a/src/app/api/allergies/route.ts +++ b/src/app/api/allergies/route.ts @@ -93,7 +93,9 @@ async function postAllergy(request: NextRequest): Promise { // client hits were not. const writeRl = await checkRecordWriteRateLimit(actor.id); if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429); + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); } const { data: rawBody, error: jsonError } = await safeJson(request, { diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts index 7a48c1b06..8de993f70 100644 --- a/src/app/api/biomarkers/route.ts +++ b/src/app/api/biomarkers/route.ts @@ -103,7 +103,9 @@ async function postBiomarker(request: NextRequest) { // client hits were not. const writeRl = await checkRecordWriteRateLimit(actor.id); if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429); + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); } const { data: body, error: jsonError } = await safeJson(request, { diff --git a/src/app/api/custom-metrics/[id]/entries/route.ts b/src/app/api/custom-metrics/[id]/entries/route.ts index 0750ce9d3..166304e3f 100644 --- a/src/app/api/custom-metrics/[id]/entries/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/route.ts @@ -112,7 +112,9 @@ async function postCustomMetricEntry( // client hits were not. const writeRl = await checkRecordWriteRateLimit(user.id); if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429); + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); } const { id } = await params; diff --git a/src/app/api/cycle/day-logs/route.ts b/src/app/api/cycle/day-logs/route.ts index 5a0696bc6..cacf927fd 100644 --- a/src/app/api/cycle/day-logs/route.ts +++ b/src/app/api/cycle/day-logs/route.ts @@ -105,7 +105,9 @@ async function postDayLog(request: NextRequest): Promise { // client hits were not. const writeRl = await checkRecordWriteRateLimit(actor.id); if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429); + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); } const { data: rawBody, error: jsonError } = await safeJson(request, { diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index 80926a0c2..5abab7694 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -162,7 +162,9 @@ async function postEncounter(request: NextRequest): Promise { // client hits were not. const writeRl = await checkRecordWriteRateLimit(actor.id); if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429); + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); } const { data: rawBody, error: jsonError } = await safeJson(request, { diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 81cbf8a80..d4fbf47d9 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -156,7 +156,9 @@ async function postLabResult(request: NextRequest) { // client hits were not. const writeRl = await checkRecordWriteRateLimit(actor.id); if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429); + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); } const { data: body, error: jsonError } = await safeJson(request, { diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 385f6a640..ca5e59685 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -807,7 +807,9 @@ async function postMeasurement(request: NextRequest) { // client hits were not. const writeRl = await checkRecordWriteRateLimit(auth.actor.id); if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429); + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); } const { data: body, error: jsonError } = await safeJson(request, { diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index b5c3baebb..b61961363 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -64,7 +64,9 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { // client hits were not. const writeRl = await checkRecordWriteRateLimit(actor.id); if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429); + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); } // v1.36.1 follow-up — the sibling route `POST /api/medications/intake` diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index e6c6e759d..193966b4e 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -209,7 +209,9 @@ export const POST = apiHandler(async (request: NextRequest) => { // client hits were not. const writeRl = await checkRecordWriteRateLimit(actor.id); if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429); + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); } const { data: body, error } = await safeJson(request, { diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 279ba0256..96fb20d0a 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -111,7 +111,9 @@ async function postMoodEntry(request: NextRequest) { // client hits were not. const writeRl = await checkRecordWriteRateLimit(actor.id); if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429); + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); } const { data: body, error: jsonError } = await safeJson(request, { diff --git a/src/app/api/vaccinations/route.ts b/src/app/api/vaccinations/route.ts index 62a60b12c..b1ddc7ae5 100644 --- a/src/app/api/vaccinations/route.ts +++ b/src/app/api/vaccinations/route.ts @@ -120,7 +120,9 @@ async function postVaccination(request: NextRequest): Promise { // client hits were not. const writeRl = await checkRecordWriteRateLimit(actor.id); if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429); + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); } const { data: rawBody, error: jsonError } = await safeJson(request, { diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 67ef9f148..5f8c6037b 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -251,6 +251,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "profile.update.invalidBody", "profile.update.nothingSaved", ], + record_write: ["record_write.rate_limited"], "report-selection": [ "report-selection.body.invalid_json", "report-selection.body.invalid_shape", diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 15da53657..09f524625 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -312,7 +312,7 @@ export const stdResponses = { export const recordWriteRateLimitResponse = { "429": { description: - "Rate limit exceeded. This route shares one per-account bucket with the other single-record writes — `record-write:`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns their own allowance rather than the record owner's. Nothing was written. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that bucket; back off on `Retry-After` rather than guessing. A client with more than a handful of rows to send should use the batch endpoint for its domain instead of looping this one.", + "Rate limit exceeded. This route shares one per-account bucket with the other single-record writes — `record-write:`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns their own allowance rather than the record owner's. Nothing was written. `meta.errorCode` is `record_write.rate_limited`, which is what tells this refusal apart from a route's own narrower bucket when both can answer 429. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that bucket; back off on `Retry-After` rather than guessing. Every request counts against the bucket, refused ones included: the check runs before the body is read, so a client looping on a 422 spends the same allowance as one that writes rows. A client with more than a handful of rows to send should use the batch endpoint for its domain instead of looping this one.", content: { "application/json": { schema: errorEnvelope } }, headers: stdResponses["429"].headers, }, From 1c10bbbde8e1aceda0f7b41971de8d3fba6ac937 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 09:14:14 +0200 Subject: [PATCH 10/56] feat(admin): say per account how fresh the off-host copy is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nightly off-host job reports counts — uploaded, failed, total — and a count cannot name the account it is failing for. A cohort where one object has been refused every night for a month reads as "99 uploaded, 1 failed" each time, which looks like weather rather than like an account with no disaster-recovery copy at all. The worker now writes a ledger row when an object lands (account, instant, size), and Admin → Backups gains a second card that rates every account against the nightly schedule: fresh inside one run, due when one run produced nothing, stale when two did not, never when no object has ever landed. On a host without BACKUP_S3_* the card says off-host backup is not configured and stops there. The verdicts are computed from the ledger, never from a bucket listing: the worker's grant is deliberately PutObject + GetObject, and an admin page must not put the operator's credentials on its render path to state a number the database already holds. --- docs/ops/backup-restore.md | 20 +++ messages/de.json | 16 +- messages/en.json | 16 +- messages/es.json | 16 +- messages/fr.json | 16 +- messages/it.json | 16 +- messages/ko.json | 16 +- messages/pl.json | 16 +- .../0336_offhost_backup_ledger/migration.sql | 16 ++ prisma/schema.prisma | 29 ++++ .../success-affordance-guard.test.ts | 4 + src/app/admin/[section]/renderer.tsx | 4 + .../api/admin/backups/__tests__/route.test.ts | 146 ++++++++++++++++ src/app/api/admin/backups/route.ts | 60 ++++++- src/components/admin/backups-section.tsx | 9 +- .../admin/offhost-backups-section.tsx | 162 ++++++++++++++++++ src/lib/data-wipe/wipe-plan.ts | 5 + src/lib/export/backup-plan.ts | 2 + .../offhost-backup-freshness.test.ts | 82 +++++++++ src/lib/jobs/__tests__/offhost-backup.test.ts | 19 ++ src/lib/jobs/offhost-backup-freshness.ts | 65 +++++++ src/lib/jobs/offhost-backup.ts | 36 ++++ src/types/backups.ts | 36 ++++ 23 files changed, 797 insertions(+), 10 deletions(-) create mode 100644 prisma/migrations/0336_offhost_backup_ledger/migration.sql create mode 100644 src/app/api/admin/backups/__tests__/route.test.ts create mode 100644 src/components/admin/offhost-backups-section.tsx create mode 100644 src/lib/jobs/__tests__/offhost-backup-freshness.test.ts create mode 100644 src/lib/jobs/offhost-backup-freshness.ts diff --git a/docs/ops/backup-restore.md b/docs/ops/backup-restore.md index 2b6fe2af9..12b834de3 100644 --- a/docs/ops/backup-restore.md +++ b/docs/ops/backup-restore.md @@ -215,6 +215,26 @@ A run where SOME account got a copy still succeeds, with the rest counted in `offhost_backup_failed`. Failing the whole queue over one account's object would re-upload everybody's on every retry. +### Which account has no copy + +The counts above say how many accounts were uploaded, never which. An account +whose object has failed every night for a month reads as `99 uploaded, 1 +failed` each time, which looks like weather. + +**Admin console → Backups → Off-host copies** answers it per account: when this +host last put that account's encrypted copy in the bucket, how big it was, and +how that compares with the nightly schedule. **Fresh** is inside one run, +**due** means one run produced nothing for that account, **stale** means two +did not, and **never** means no object has ever landed. On a host without +`BACKUP_S3_*` the card says off-host backup is not configured and stops there. + +The verdicts come from a ledger the worker writes when the object lands +(`offhost_backup_state`), not from a listing of the bucket. Keep the worker's +grant as documented — PutObject, GetObject, AbortMultipartUpload — and the card +still tells the truth. A row that goes stale while the run reports success is +that account's object being refused, and the reason is in the run's +`offhost_backup_failures` meta. + ## The weekly in-database backup (`data-backup`) Separate from the off-host job above, and easy to confuse with it. A second diff --git a/messages/de.json b/messages/de.json index fca6667b9..84d2a27b9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7012,7 +7012,21 @@ "restoreMissingDocuments": "Dokumente", "restoreMissingWorkouts": "Workouts", "restoreMissingMentalHealth": "Psychische Fragebögen", - "restoreMissingConsent": "Einwilligungsnachweise" + "restoreMissingConsent": "Einwilligungsnachweise", + "offhost": { + "title": "Kopien außer Haus", + "description": "Wann dieser Host zuletzt die verschlüsselte Kopie jedes Kontos in deinen Bucket gelegt hat.", + "loadError": "Der Status außer Haus konnte nicht geladen werden.", + "notConfiguredTitle": "Sicherung außer Haus ist nicht eingerichtet", + "notConfiguredDescription": "Es sind weder Bucket noch Schlüssel gesetzt, also verlässt nichts diesen Host. Setze BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY und BACKUP_ENCRYPTION_KEY, um den nächtlichen Upload einzuschalten.", + "stateFresh": "Frisch", + "stateDue": "Fällig", + "stateStale": "Veraltet", + "stateNever": "Nie", + "neverDetail": "Für dieses Konto ist noch nie eine Kopie im Bucket angekommen.", + "age": "vor {hours} h", + "scheduleNote": "Der Upload läuft alle {hours} h. Frisch heißt innerhalb eines Laufs, fällig heißt ein Lauf blieb ohne Ergebnis, veraltet heißt zwei." + } }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index 5891d7ddf..dcd603d32 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7012,7 +7012,21 @@ "restoreMissingDocuments": "Documents", "restoreMissingWorkouts": "Workouts", "restoreMissingMentalHealth": "Mental-health screeners", - "restoreMissingConsent": "Consent receipts" + "restoreMissingConsent": "Consent receipts", + "offhost": { + "title": "Off-host copies", + "description": "When this host last put each account's encrypted copy in your bucket.", + "loadError": "Could not load the off-host status.", + "notConfiguredTitle": "Off-host backup is not configured", + "notConfiguredDescription": "No bucket and no key are set, so nothing leaves this host. Set BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY and BACKUP_ENCRYPTION_KEY to turn the nightly upload on.", + "stateFresh": "Fresh", + "stateDue": "Due", + "stateStale": "Stale", + "stateNever": "Never", + "neverDetail": "No copy has ever reached the bucket for this account.", + "age": "{hours} h ago", + "scheduleNote": "The upload runs every {hours} h. Fresh means inside one run, due means one run produced nothing, stale means two did not." + } }, "danger-zone": { "title": "Danger Zone", diff --git a/messages/es.json b/messages/es.json index 990f5c414..0188dd089 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7012,7 +7012,21 @@ "restoreMissingDocuments": "Documentos", "restoreMissingWorkouts": "Entrenamientos", "restoreMissingMentalHealth": "Cuestionarios de salud mental", - "restoreMissingConsent": "Comprobantes de consentimiento" + "restoreMissingConsent": "Comprobantes de consentimiento", + "offhost": { + "title": "Copias fuera del host", + "description": "Cuándo puso este host por última vez la copia cifrada de cada cuenta en tu bucket.", + "loadError": "No se pudo cargar el estado fuera del host.", + "notConfiguredTitle": "La copia fuera del host no está configurada", + "notConfiguredDescription": "No hay bucket ni clave definidos, así que nada sale de este host. Define BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY y BACKUP_ENCRYPTION_KEY para activar la subida nocturna.", + "stateFresh": "Reciente", + "stateDue": "Pendiente", + "stateStale": "Obsoleta", + "stateNever": "Nunca", + "neverDetail": "Para esta cuenta nunca ha llegado una copia al bucket.", + "age": "hace {hours} h", + "scheduleNote": "La subida se ejecuta cada {hours} h. Reciente significa dentro de una ejecución, pendiente que una no produjo nada, obsoleta que fueron dos." + } }, "danger-zone": { "title": "Zona de peligro", diff --git a/messages/fr.json b/messages/fr.json index eee2b3cbd..38dc08eb9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7012,7 +7012,21 @@ "restoreMissingDocuments": "Documents", "restoreMissingWorkouts": "Séances", "restoreMissingMentalHealth": "Questionnaires de santé mentale", - "restoreMissingConsent": "Preuves de consentement" + "restoreMissingConsent": "Preuves de consentement", + "offhost": { + "title": "Copies hors hôte", + "description": "Quand cet hôte a déposé pour la dernière fois la copie chiffrée de chaque compte dans votre bucket.", + "loadError": "Impossible de charger l'état hors hôte.", + "notConfiguredTitle": "La sauvegarde hors hôte n'est pas configurée", + "notConfiguredDescription": "Aucun bucket ni clé ne sont définis, donc rien ne quitte cet hôte. Définissez BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY et BACKUP_ENCRYPTION_KEY pour activer l'envoi nocturne.", + "stateFresh": "Récente", + "stateDue": "Attendue", + "stateStale": "Périmée", + "stateNever": "Jamais", + "neverDetail": "Aucune copie n'est jamais arrivée dans le bucket pour ce compte.", + "age": "il y a {hours} h", + "scheduleNote": "L'envoi tourne toutes les {hours} h. Récente signifie dans un cycle, attendue qu'un cycle n'a rien produit, périmée que deux n'ont rien produit." + } }, "danger-zone": { "title": "Zone de danger", diff --git a/messages/it.json b/messages/it.json index f550c93a0..973619366 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7012,7 +7012,21 @@ "restoreMissingDocuments": "Documenti", "restoreMissingWorkouts": "Allenamenti", "restoreMissingMentalHealth": "Questionari sulla salute mentale", - "restoreMissingConsent": "Ricevute di consenso" + "restoreMissingConsent": "Ricevute di consenso", + "offhost": { + "title": "Copie fuori host", + "description": "Quando questo host ha messo per l'ultima volta la copia cifrata di ogni account nel tuo bucket.", + "loadError": "Impossibile caricare lo stato fuori host.", + "notConfiguredTitle": "Il backup fuori host non è configurato", + "notConfiguredDescription": "Non sono impostati né bucket né chiave, quindi nulla lascia questo host. Imposta BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY e BACKUP_ENCRYPTION_KEY per attivare il caricamento notturno.", + "stateFresh": "Recente", + "stateDue": "Attesa", + "stateStale": "Obsoleta", + "stateNever": "Mai", + "neverDetail": "Per questo account nessuna copia è mai arrivata nel bucket.", + "age": "{hours} h fa", + "scheduleNote": "Il caricamento gira ogni {hours} h. Recente significa entro un giro, attesa che un giro non ha prodotto nulla, obsoleta che non lo hanno fatto due." + } }, "danger-zone": { "title": "Zona pericolosa", diff --git a/messages/ko.json b/messages/ko.json index 641082b67..800afd943 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6992,7 +6992,21 @@ "restoreMissingDocuments": "문서", "restoreMissingWorkouts": "운동", "restoreMissingMentalHealth": "정신 건강 선별 검사", - "restoreMissingConsent": "동의 기록" + "restoreMissingConsent": "동의 기록", + "offhost": { + "title": "외부 보관 사본", + "description": "이 호스트가 각 계정의 암호화 사본을 버킷에 마지막으로 올린 시점입니다.", + "loadError": "외부 보관 상태를 불러오지 못했습니다.", + "notConfiguredTitle": "외부 보관 백업이 설정되지 않았습니다", + "notConfiguredDescription": "버킷과 키가 모두 설정되지 않아 이 호스트를 벗어나는 것이 없습니다. 야간 업로드를 켜려면 BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY, BACKUP_ENCRYPTION_KEY를 설정하세요.", + "stateFresh": "최신", + "stateDue": "지연", + "stateStale": "오래됨", + "stateNever": "없음", + "neverDetail": "이 계정의 사본이 버킷에 도착한 적이 없습니다.", + "age": "{hours}시간 전", + "scheduleNote": "업로드는 {hours}시간마다 실행됩니다. 최신은 한 번의 실행 이내, 지연은 한 번이 아무것도 만들지 못한 상태, 오래됨은 두 번이 그런 상태입니다." + } }, "danger-zone": { "title": "위험 구역", diff --git a/messages/pl.json b/messages/pl.json index 1caaad0a3..698a2e2c5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7012,7 +7012,21 @@ "restoreMissingDocuments": "Dokumenty", "restoreMissingWorkouts": "Treningi", "restoreMissingMentalHealth": "Kwestionariusze zdrowia psychicznego", - "restoreMissingConsent": "Potwierdzenia zgody" + "restoreMissingConsent": "Potwierdzenia zgody", + "offhost": { + "title": "Kopie poza hostem", + "description": "Kiedy ten host ostatnio umieścił zaszyfrowaną kopię każdego konta w Twoim buckecie.", + "loadError": "Nie udało się wczytać stanu poza hostem.", + "notConfiguredTitle": "Kopia poza hostem nie jest skonfigurowana", + "notConfiguredDescription": "Nie ustawiono ani bucketu, ani klucza, więc nic nie opuszcza tego hosta. Ustaw BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY i BACKUP_ENCRYPTION_KEY, aby włączyć nocne wysyłanie.", + "stateFresh": "Świeża", + "stateDue": "Zaległa", + "stateStale": "Przeterminowana", + "stateNever": "Nigdy", + "neverDetail": "Dla tego konta żadna kopia nigdy nie trafiła do bucketu.", + "age": "{hours} h temu", + "scheduleNote": "Wysyłka działa co {hours} h. Świeża znaczy w obrębie jednego przebiegu, zaległa że jeden nic nie dał, przeterminowana że dwa." + } }, "danger-zone": { "title": "Strefa zagrożenia", diff --git a/prisma/migrations/0336_offhost_backup_ledger/migration.sql b/prisma/migrations/0336_offhost_backup_ledger/migration.sql new file mode 100644 index 000000000..9239d5eaf --- /dev/null +++ b/prisma/migrations/0336_offhost_backup_ledger/migration.sql @@ -0,0 +1,16 @@ +-- One row per account, written by the nightly off-host backup job when an +-- object lands in the bucket. The admin console reads it to say, per account, +-- whether the newest off-host copy is as fresh as the schedule promises, +-- without the page ever talking to the bucket. +CREATE TABLE "offhost_backup_state" ( + "user_id" TEXT NOT NULL, + "last_success_at" TIMESTAMP(3) NOT NULL, + "size_bytes" INTEGER NOT NULL, + + CONSTRAINT "offhost_backup_state_pkey" PRIMARY KEY ("user_id") +); + +ALTER TABLE "offhost_backup_state" + ADD CONSTRAINT "offhost_backup_state_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0c6e0a00a..5d38625d1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -770,6 +770,7 @@ model User { notificationChannels NotificationChannel[] pushSubscriptions PushSubscription[] dataBackups DataBackup[] + offhostBackupState OffhostBackupState? achievements UserAchievement[] recommendationFeedback RecommendationFeedback[] idempotencyKeys IdempotencyKey[] @@ -4937,6 +4938,34 @@ model DataBackup { @@map("data_backups") } +// ─── Off-host backup ledger ────────────────────────────────── +// +// One row per account, written by the nightly off-host job the moment an +// object lands in the bucket. It exists because "the job ran" and "this +// account has a recent copy off-host" are different facts: the run reports +// counts, and a cohort of a hundred accounts where one has silently failed +// every night for a month reads exactly like a healthy one. +// +// The admin console's off-host card is the reader, and it is the only reader +// there will be: the page computes staleness from this timestamp rather than +// listing the bucket, so an operator who has revoked the worker's read grant +// still sees the truth. +model OffhostBackupState { + userId String @id @map("user_id") + + /// When the newest object for this account finished uploading. Consumer: + /// `classifyOffhostBackup()` behind `GET /api/admin/backups`. + lastSuccessAt DateTime @map("last_success_at") + + /// Size of that object in bytes — the same number the run reports as its + /// largest, per account. Consumer: the off-host card's size column. + sizeBytes Int @map("size_bytes") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("offhost_backup_state") +} + // ─── Apple Health export.zip import (v1.4.34) ─────────────── // // Mirrors a `pg-boss` `apple-health-import` job's lifecycle so the diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index b991b8cab..6df0bd37e 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -109,6 +109,10 @@ const PINNED_AFFORDANCES: Record< "src/components/admin/module-availability-section.tsx": { "toast.success": 1, }, + // Reads a ledger, writes nothing: the wash marks how fresh one account's + // off-host copy is, and the state it paints green came out of the database + // rather than out of a request this card just made. + "src/components/admin/offhost-backups-section.tsx": { "text-success": 1 }, "src/components/admin/recent-audit-preview.tsx": { CheckCircle2: 2, "text-success": 1, diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index f9548687e..24ac6042a 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -25,6 +25,7 @@ import { CoachFeedbackSection } from "@/components/admin/coach-feedback-section" import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-section"; import { AppLogPreviewSection } from "@/components/admin/app-log-preview-section"; import { BackupsSection } from "@/components/admin/backups-section"; +import { OffhostBackupsSection } from "@/components/admin/offhost-backups-section"; import { DangerZoneSection } from "@/components/admin/danger-zone-section"; import { EncryptionSection } from "@/components/admin/encryption-section"; import { GeneralSettingsSection } from "@/components/admin/general-settings-section"; @@ -144,9 +145,12 @@ export function AdminSectionRenderer({ ); case "backups": + // Two cards, two independent failure modes: the copies held in this + // database, then the copies that reached the operator's bucket. return ( + ); case "encryption": diff --git a/src/app/api/admin/backups/__tests__/route.test.ts b/src/app/api/admin/backups/__tests__/route.test.ts new file mode 100644 index 000000000..e96b759e4 --- /dev/null +++ b/src/app/api/admin/backups/__tests__/route.test.ts @@ -0,0 +1,146 @@ +/** + * `GET /api/admin/backups` — the off-host leg. + * + * The rows the weekly pass writes into this database were already covered by + * the page that lists them. What was not covered is the question this response + * now answers: per account, when did an encrypted copy last reach the + * operator's bucket, and is that inside what the nightly schedule promises. + * + * The bucket is never touched here, and that is the assertion that matters — + * the verdicts come from the ledger the worker writes, so an operator whose + * worker holds PutObject and nothing else still gets a true answer. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAdmin: vi.fn(async () => ({ user: { id: "admin-1" } })), +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +vi.mock("@/lib/jobs/job-failures", () => ({ + readLastQueueRun: vi.fn(async () => null), +})); + +const queryRawMock = vi.fn(); +const findManyMock = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { + $queryRaw: (...a: unknown[]) => queryRawMock(...a), + user: { findMany: (...a: unknown[]) => findManyMock(...a) }, + }, +})); + +const configuredMock = vi.fn(() => true); +vi.mock("@/lib/jobs/offhost-backup", () => ({ + offhostBackupConfigured: () => configuredMock(), +})); + +import { GET } from "../route"; +import type { BackupsList } from "@/types/backups"; + +const HOUR = 3_600_000; + +async function read(): Promise { + const res = await (GET as unknown as () => Promise)(); + expect(res.status).toBe(200); + return (await res.json()).data as BackupsList; +} + +beforeEach(() => { + vi.clearAllMocks(); + configuredMock.mockReturnValue(true); + queryRawMock.mockResolvedValue([]); + findManyMock.mockResolvedValue([]); +}); + +describe("GET /api/admin/backups — off-host freshness", () => { + it("rates every account against the nightly schedule without listing the bucket", async () => { + const now = Date.now(); + findManyMock.mockResolvedValue([ + { + id: "u1", + username: "account-one", + offhostBackupState: { + lastSuccessAt: new Date(now - 3 * HOUR), + sizeBytes: 4096, + }, + }, + { + id: "u2", + username: "account-two", + offhostBackupState: { + lastSuccessAt: new Date(now - 30 * HOUR), + sizeBytes: 8192, + }, + }, + { + id: "u3", + username: "account-three", + offhostBackupState: { + lastSuccessAt: new Date(now - 100 * HOUR), + sizeBytes: 128, + }, + }, + { id: "u4", username: "account-four", offhostBackupState: null }, + ]); + + const body = await read(); + + expect(body.offhost.configured).toBe(true); + expect(body.offhost.periodHours).toBe(24); + expect( + body.offhost.rows.map((row) => [row.username, row.freshness]), + ).toEqual([ + ["account-one", "fresh"], + ["account-two", "due"], + ["account-three", "stale"], + ["account-four", "never"], + ]); + expect(body.offhost.rows[0]?.sizeBytes).toBe(4096); + expect(body.offhost.rows[0]?.ageHours).toBe(3); + expect(body.offhost.rows[3]?.lastSuccessAt).toBeNull(); + expect(body.offhost.rows[3]?.sizeBytes).toBeNull(); + }); + + it("says so and reads no account when off-host backup is not configured", async () => { + configuredMock.mockReturnValue(false); + + const body = await read(); + + expect(body.offhost).toEqual({ + configured: false, + periodHours: 24, + rows: [], + }); + // Not merely empty output — the query never runs, so an unconfigured host + // does not pay for a table scan to be told nothing is set up. + expect(findManyMock).not.toHaveBeenCalled(); + }); + + it("never ships a credential or an object key with the verdicts", async () => { + findManyMock.mockResolvedValue([ + { + id: "u1", + username: "account-one", + offhostBackupState: { + lastSuccessAt: new Date(), + sizeBytes: 1, + }, + }, + ]); + + const body = await read(); + + expect(Object.keys(body.offhost.rows[0] ?? {}).sort()).toEqual([ + "ageHours", + "freshness", + "lastSuccessAt", + "sizeBytes", + "userId", + "username", + ]); + expect(JSON.stringify(body.offhost)).not.toMatch(/bucket|endpoint|secret/i); + }); +}); diff --git a/src/app/api/admin/backups/route.ts b/src/app/api/admin/backups/route.ts index 04a8c2139..b1885683f 100644 --- a/src/app/api/admin/backups/route.ts +++ b/src/app/api/admin/backups/route.ts @@ -13,6 +13,12 @@ * Without those two the page cannot tell a working backup from one that * stopped six weeks ago — every row it lists has a perfectly ordinary * timestamp either way. + * + * And alongside both, the off-host leg: one row per account saying when this + * host last put that account's encrypted copy in the operator's bucket and how + * stale that is against the nightly schedule. It comes from the ledger the + * worker writes, never from a bucket listing — the answer has to hold on a host + * whose worker grant is PutObject and nothing else. */ import { prisma } from "@/lib/db"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; @@ -21,10 +27,19 @@ import { annotate } from "@/lib/logging/context"; import { DATA_BACKUP_QUEUE } from "@/lib/jobs/data-backup-policy"; import { readLastQueueRun } from "@/lib/jobs/job-failures"; import { summariseBackupSchedule } from "@/lib/jobs/backup-schedule-status"; +import { offhostBackupConfigured } from "@/lib/jobs/offhost-backup"; +import { + classifyOffhostBackup, + OFFHOST_BACKUP_PERIOD_HOURS, +} from "@/lib/jobs/offhost-backup-freshness"; // v1.4.41 W-ORG — `BackupRow` / `BackupsList` moved to `src/types/backups.ts` // so callers (in particular `components/admin/backups-section.tsx`) don't // have to reach across the component → route-handler layer boundary. -import type { BackupRow, BackupsList } from "@/types/backups"; +import type { + BackupRow, + BackupsList, + OffhostAccountRow, +} from "@/types/backups"; export const dynamic = "force-dynamic"; @@ -67,6 +82,42 @@ export const GET = apiHandler(async () => { createdAt: row.created_at.toISOString(), })); + // The off-host leg, per account, read from the ledger the nightly worker + // writes rather than from the bucket. The page must be able to answer "which + // account has no recent copy off-host" on a host whose worker grant is + // PutObject and nothing else, and a listing call from a page render would + // put the operator's credentials on the request path of every page view. + const now = new Date(); + const configured = offhostBackupConfigured(); + const offhostAccounts = configured + ? await prisma.user.findMany({ + select: { + id: true, + username: true, + offhostBackupState: { + select: { lastSuccessAt: true, sizeBytes: true }, + }, + }, + orderBy: { username: "asc" }, + }) + : []; + + const offhostRows: OffhostAccountRow[] = offhostAccounts.map((account) => { + const state = account.offhostBackupState; + const verdict = classifyOffhostBackup({ + lastSuccessAt: state?.lastSuccessAt ?? null, + now, + }); + return { + userId: account.id, + username: account.username, + lastSuccessAt: state?.lastSuccessAt.toISOString() ?? null, + sizeBytes: state?.sizeBytes ?? null, + ageHours: verdict.ageHours, + freshness: verdict.freshness, + }; + }); + const payload: BackupsList = { rows: list, // Matches the retention window the backup-prune job enforces so the @@ -77,8 +128,13 @@ export const GET = apiHandler(async () => { .filter((row) => row.type === SCHEDULED_BACKUP_TYPE) .map((row) => row.created_at), lastRun: await readLastQueueRun(DATA_BACKUP_QUEUE), - now: new Date(), + now, }), + offhost: { + configured, + periodHours: OFFHOST_BACKUP_PERIOD_HOURS, + rows: offhostRows, + }, }; return apiSuccess(payload); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index f6f448a0a..d776da699 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -322,7 +322,14 @@ function RestoreRowDialog({ ); } -function formatBytes(bytes: number, fmt: ReturnType) { +/** + * Exported for the off-host card, which reads the same sizes out of the same + * response and must not spell "1.25 MB" a second way two cards apart. + */ +export function formatBytes( + bytes: number, + fmt: ReturnType, +) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) { return `${fmt.integer(Math.round(bytes / 1024))} KB`; diff --git a/src/components/admin/offhost-backups-section.tsx b/src/components/admin/offhost-backups-section.tsx new file mode 100644 index 000000000..280819a0f --- /dev/null +++ b/src/components/admin/offhost-backups-section.tsx @@ -0,0 +1,162 @@ +"use client"; + +/** + * `` — per-account freshness of the off-host copies. + * + * The card above it lists the copies that live in THIS database. This one + * answers the different question: for each account, how long ago did this host + * put an encrypted copy in the operator's bucket, and is that inside what the + * nightly schedule promises. The two fail independently — a database whose + * weekly rows are current tells an operator nothing about a bucket that has + * been refusing the worker's signature since March. + * + * Every verdict is computed server-side from the ledger the worker writes. The + * page never lists the bucket: the worker's grant is deliberately PutObject + + * GetObject, and putting a credentialed listing call on the render path of an + * admin page would be a poor trade for a number the database already holds. + */ + +import { useQuery } from "@tanstack/react-query"; +import { CloudUpload } from "lucide-react"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { Badge } from "@/components/ui/badge"; +import { EmptyState } from "@/components/ui/empty-state"; +import { ListRow } from "@/components/ui/list-row"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { Skeleton } from "@/components/ui/skeleton"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { OffhostBackupFreshness } from "@/lib/jobs/offhost-backup-freshness"; +import type { BackupsList, OffhostAccountRow } from "@/types/backups"; +import { formatBytes } from "./backups-section"; + +/** + * Status wash per verdict. `never` is deliberately neutral rather than + * alarming: a host that turned the off-host job on this afternoon has not + * failed at anything yet, and painting every row red on day one is how an + * operator learns to stop reading the colour. + */ +const FRESHNESS_STYLE: Record = { + fresh: "border-success/30 bg-success/10 text-success", + due: "border-warning/30 bg-warning/10 text-warning", + stale: "border-destructive/30 bg-destructive/10 text-destructive", + never: "border-border bg-muted text-muted-foreground", +}; + +function freshnessLabel( + freshness: OffhostBackupFreshness, + t: ReturnType["t"], +): string { + // Literal keys, one per arm: a template-built key resolves at runtime and is + // invisible to the call-site coverage guard. + switch (freshness) { + case "fresh": + return t("admin.section.backups.offhost.stateFresh"); + case "due": + return t("admin.section.backups.offhost.stateDue"); + case "stale": + return t("admin.section.backups.offhost.stateStale"); + case "never": + return t("admin.section.backups.offhost.stateNever"); + } +} + +function AccountRow({ row }: { row: OffhostAccountRow }) { + const { t } = useTranslations(); + const fmt = useFormatters(); + + return ( + +
  • +
    + + {row.username} + + + {freshnessLabel(row.freshness, t)} + +
    +

    + {row.lastSuccessAt === null || row.sizeBytes === null + ? t("admin.section.backups.offhost.neverDetail") + : `${fmt.dateTime(row.lastSuccessAt)} · ${formatBytes( + row.sizeBytes, + fmt, + )} · ${t("admin.section.backups.offhost.age", { + hours: row.ageHours ?? 0, + })}`} +

    +
  • +
    + ); +} + +export function OffhostBackupsSection() { + const { t } = useTranslations(); + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: queryKeys.adminBackups(), + queryFn: async () => apiGet("/api/admin/backups"), + }); + + const offhost = data?.offhost; + + return ( + + + {offhost.rows.length} + + ) : null + } + /> + + {isLoading ? ( +
    + + +
    + ) : isError ? ( + void refetch()} + /> + ) : offhost && !offhost.configured ? ( + } + title={t("admin.section.backups.offhost.notConfiguredTitle")} + description={t( + "admin.section.backups.offhost.notConfiguredDescription", + )} + /> + ) : offhost ? ( +
    +
      + {offhost.rows.map((row) => ( + + ))} +
    +

    + {t("admin.section.backups.offhost.scheduleNote", { + hours: offhost.periodHours, + })} +

    +
    + ) : null} +
    + ); +} diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 43fab3294..17b3e7dd0 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -210,6 +210,11 @@ export const WIPE_MODELS = [ // just returned. "IdempotencyKey", "DataBackup", + // What this host last put in the operator's bucket for this account. The + // objects themselves stay — the worker holds no DeleteObject grant and the + // bucket's lifecycle rule owns their retirement — but the row saying when + // they were written is about this account and goes with it. + "OffhostBackupState", "AuditLog", "ConsentReceipt", "UserKnownDevice", diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 5f47cadf2..8b216df9b 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -642,6 +642,8 @@ export const NOT_IN_BACKUP_MODELS: Readonly> = { "Replay protection for requests this host already answered. Restoring it would suppress a legitimate new request.", DataBackup: "The backup catalogue itself. A backup that contains the list of backups is a recursion with no reader.", + OffhostBackupState: + "When this host last put this account's copy in this operator's bucket, and how big it was. It describes one deployment's relationship with one bucket, so restoring it elsewhere would assert an off-host copy that host has never written.", ImportJob: "A job record pointing at an uploaded file that the backup does not carry, so restoring it would resurrect a task with nothing to work on.", InviteToken: diff --git a/src/lib/jobs/__tests__/offhost-backup-freshness.test.ts b/src/lib/jobs/__tests__/offhost-backup-freshness.test.ts new file mode 100644 index 000000000..f364d9e69 --- /dev/null +++ b/src/lib/jobs/__tests__/offhost-backup-freshness.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { + classifyOffhostBackup, + OFFHOST_BACKUP_PERIOD_HOURS, +} from "@/lib/jobs/offhost-backup-freshness"; + +const NOW = new Date("2026-09-10T09:00:00.000Z"); +const HOUR = 3_600_000; +const PERIOD = OFFHOST_BACKUP_PERIOD_HOURS * HOUR; + +/** `age` hours before NOW. */ +function ago(ms: number): Date { + return new Date(NOW.getTime() - ms); +} + +describe("classifyOffhostBackup", () => { + it("says never when the worker has put nothing there for this account", () => { + expect(classifyOffhostBackup({ lastSuccessAt: null, now: NOW })).toEqual({ + freshness: "never", + ageHours: null, + }); + }); + + it("is fresh right up to the one-period boundary and due one millisecond past it", () => { + expect( + classifyOffhostBackup({ lastSuccessAt: ago(PERIOD), now: NOW }).freshness, + ).toBe("fresh"); + expect( + classifyOffhostBackup({ lastSuccessAt: ago(PERIOD + 1), now: NOW }) + .freshness, + ).toBe("due"); + }); + + it("is due right up to the two-period boundary and stale one millisecond past it", () => { + expect( + classifyOffhostBackup({ lastSuccessAt: ago(PERIOD * 2), now: NOW }) + .freshness, + ).toBe("due"); + expect( + classifyOffhostBackup({ lastSuccessAt: ago(PERIOD * 2 + 1), now: NOW }) + .freshness, + ).toBe("stale"); + }); + + it("reports whole hours of age", () => { + expect( + classifyOffhostBackup({ lastSuccessAt: ago(90 * 60_000), now: NOW }), + ).toEqual({ freshness: "fresh", ageHours: 1 }); + }); + + it("honours a caller's own period rather than the nightly default", () => { + // A six-hour schedule: the same 12-hour-old object that is fresh under the + // nightly cron is two periods behind under this one. + const twelveHours = ago(12 * HOUR); + expect( + classifyOffhostBackup({ lastSuccessAt: twelveHours, now: NOW }).freshness, + ).toBe("fresh"); + expect( + classifyOffhostBackup({ + lastSuccessAt: twelveHours, + now: NOW, + periodHours: 6, + }).freshness, + ).toBe("due"); + expect( + classifyOffhostBackup({ + lastSuccessAt: twelveHours, + now: NOW, + periodHours: 5, + }).freshness, + ).toBe("stale"); + }); + + it("does not call a copy stale because the host clock ran backwards", () => { + expect( + classifyOffhostBackup({ + lastSuccessAt: new Date(NOW.getTime() + 2 * HOUR), + now: NOW, + }), + ).toEqual({ freshness: "fresh", ageHours: 0 }); + }); +}); diff --git a/src/lib/jobs/__tests__/offhost-backup.test.ts b/src/lib/jobs/__tests__/offhost-backup.test.ts index 5a630b088..3bd97c4df 100644 --- a/src/lib/jobs/__tests__/offhost-backup.test.ts +++ b/src/lib/jobs/__tests__/offhost-backup.test.ts @@ -184,6 +184,7 @@ describe("runOffhostBackup", () => { s3.store.set("2020-01-01/user-old.json.enc", Buffer.from([0])); const prisma = { + offhostBackupState: { upsert: vi.fn() }, user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }, { id: "u2" }]), }, @@ -229,11 +230,26 @@ describe("runOffhostBackup", () => { // Nothing went through the whole-buffer arm. expect(s3.putObject).not.toHaveBeenCalled(); expect(s3.putStream).toHaveBeenCalledTimes(2); + + // Every account that got an object gets a ledger row, because the run's + // own counts cannot say WHICH account has a copy off-host and which one + // has been failing quietly since March. + expect(prisma.offhostBackupState.upsert).toHaveBeenCalledTimes(2); + const ledgered = prisma.offhostBackupState.upsert.mock.calls.map( + (call) => (call[0] as { where: { userId: string } }).where.userId, + ); + expect(ledgered).toEqual(["u1", "u2"]); + const first = prisma.offhostBackupState.upsert.mock.calls[0]?.[0] as { + create: { sizeBytes: number; lastSuccessAt: Date }; + }; + expect(first.create.sizeBytes).toBe(ct.byteLength); + expect(first.create.lastSuccessAt).toBeInstanceOf(Date); }); it("uploads the canonical builder output without reshaping it", async () => { const s3 = makeS3Mock(); const prisma = { + offhostBackupState: { upsert: vi.fn() }, user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }]) }, }; const canonicalPayload = { @@ -291,6 +307,7 @@ describe("runOffhostBackup", () => { it("counts per-user failures without aborting the whole run", async () => { const s3 = makeS3Mock(); const prisma = { + offhostBackupState: { upsert: vi.fn() }, user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }, { id: "u2" }]), }, @@ -319,6 +336,7 @@ describe("runOffhostBackup", () => { it("refuses an account whose object outgrows one multipart upload", async () => { const s3 = makeS3Mock(); const prisma = { + offhostBackupState: { upsert: vi.fn() }, user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }]) }, }; mocks.buildFullBackupPayload.mockResolvedValue({ @@ -355,6 +373,7 @@ describe("runOffhostBackup", () => { it("reports the largest object it wrote", async () => { const s3 = makeS3Mock(); const prisma = { + offhostBackupState: { upsert: vi.fn() }, user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }]) }, }; const report = await runOffhostBackup( diff --git a/src/lib/jobs/offhost-backup-freshness.ts b/src/lib/jobs/offhost-backup-freshness.ts new file mode 100644 index 000000000..fe1fee86c --- /dev/null +++ b/src/lib/jobs/offhost-backup-freshness.ts @@ -0,0 +1,65 @@ +/** + * How fresh one account's newest off-host copy is. + * + * The nightly off-host job reports counts — uploaded, failed, total — and a + * count is exactly the shape that hides a single account. A cohort where one + * account's object has failed every night for a month reports "99 uploaded, 1 + * failed" every night, which reads like weather rather than like an account + * that has no disaster-recovery copy at all. The per-account ledger the worker + * writes (`OffhostBackupState`) turns that into a fact the console can state + * per row, and this function is the only place that fact is judged. + * + * Deliberately pure and deliberately not talking to the bucket: the admin page + * computes staleness from the timestamp the worker wrote, so the answer is the + * same whether or not the app process holds a read grant on the bucket, and a + * listing of somebody's backups never crosses the network to show a page. + * + * Four states, because the run is nightly and one missed night is not the same + * event as four: + * + * never — the worker has never put an object there for this account. + * fresh — inside one schedule period. This is what every account looks like + * on a host where the cron is doing its job. + * due — past one period, inside two. One night produced nothing: a retry, a + * long run, a restart during the window. Worth showing, not worth + * alarming about. + * stale — past two periods. Two consecutive nights produced nothing for this + * account, which no ordinary hiccup explains. + */ + +/** + * Hours between two scheduled off-host runs. Tracks `OFFHOST_BACKUP_CRON` + * (`30 2 * * *`) in `src/lib/jobs/reminder/register-maintenance.ts` — nightly. + */ +export const OFFHOST_BACKUP_PERIOD_HOURS = 24; + +export type OffhostBackupFreshness = "never" | "fresh" | "due" | "stale"; + +export interface OffhostBackupVerdict { + freshness: OffhostBackupFreshness; + /** Whole hours since the newest object landed; null when there is none. */ + ageHours: number | null; +} + +export function classifyOffhostBackup(args: { + lastSuccessAt: Date | null; + now: Date; + /** Defaults to the nightly schedule. Tests pass their own. */ + periodHours?: number; +}): OffhostBackupVerdict { + const { lastSuccessAt, now } = args; + if (lastSuccessAt === null) return { freshness: "never", ageHours: null }; + + const periodMs = + (args.periodHours ?? OFFHOST_BACKUP_PERIOD_HOURS) * 3_600_000; + const ageMs = now.getTime() - lastSuccessAt.getTime(); + // A clock that ran backwards between the upload and this read reports a + // negative age. That is a host-clock problem, not a backup problem, and + // calling the copy stale over it would point the operator at the wrong + // thing. + const ageHours = Math.max(0, Math.floor(ageMs / 3_600_000)); + + if (ageMs <= periodMs) return { freshness: "fresh", ageHours }; + if (ageMs <= periodMs * 2) return { freshness: "due", ageHours }; + return { freshness: "stale", ageHours }; +} diff --git a/src/lib/jobs/offhost-backup.ts b/src/lib/jobs/offhost-backup.ts index 0f55273c0..4f3a2cc62 100644 --- a/src/lib/jobs/offhost-backup.ts +++ b/src/lib/jobs/offhost-backup.ts @@ -61,6 +61,27 @@ function decodeBackupKey(raw: string): Buffer { ); } +/** + * Whether this host has the five variables the nightly job needs. + * + * Separate from `loadOffhostConfig()` because a surface that only wants to say + * "off-host backup is not set up here" must not decode the encryption key to + * find out — `decodeBackupKey` throws on a malformed one, and an operator + * whose key has a typo should see the admin page, not a 500. + */ +export function offhostBackupConfigured(): boolean { + return OFFHOST_ENV_VARS.every((name) => Boolean(process.env[name])); +} + +/** The five variables the nightly job needs, in one place. */ +const OFFHOST_ENV_VARS = [ + "BACKUP_S3_ENDPOINT", + "BACKUP_S3_BUCKET", + "BACKUP_S3_ACCESS_KEY", + "BACKUP_S3_SECRET_KEY", + "BACKUP_ENCRYPTION_KEY", +] as const; + export function loadOffhostConfig(): OffhostBackupConfig | null { const endpoint = process.env.BACKUP_S3_ENDPOINT; const bucket = process.env.BACKUP_S3_BUCKET; @@ -573,6 +594,21 @@ export async function runOffhostBackup( }), options, ); + // The per-account ledger, written the moment the object exists. The run + // itself only ever reported counts, and a count cannot say WHICH account + // has no copy — which is the one question an operator asks about a + // backup. `new Date()` rather than the run's `now`, because on a large + // cohort the two are hours apart and the row is meant to say when this + // account's object landed. + await prisma.offhostBackupState.upsert({ + where: { userId: user.id }, + update: { lastSuccessAt: new Date(), sizeBytes: objectBytes }, + create: { + userId: user.id, + lastSuccessAt: new Date(), + sizeBytes: objectBytes, + }, + }); largestObjectBytes = Math.max(largestObjectBytes, objectBytes); uploaded++; } catch (err) { diff --git a/src/types/backups.ts b/src/types/backups.ts index 1931628ea..8411f688c 100644 --- a/src/types/backups.ts +++ b/src/types/backups.ts @@ -10,6 +10,7 @@ * keeps owning the HTTP contract, the component keeps owning the UI. */ import type { BackupScheduleStatus } from "@/lib/jobs/backup-schedule-status"; +import type { OffhostBackupFreshness } from "@/lib/jobs/offhost-backup-freshness"; export interface BackupRow { id: string; @@ -24,6 +25,35 @@ export interface BackupRow { createdAt: string; } +/** One account's newest object in the operator's off-host bucket. */ +export interface OffhostAccountRow { + userId: string; + username: string; + /** ISO instant the newest object landed, or null when there is none. */ + lastSuccessAt: string | null; + /** Size of that object in bytes, or null when there is none. */ + sizeBytes: number | null; + /** Whole hours since it landed, or null when there is none. */ + ageHours: number | null; + freshness: OffhostBackupFreshness; +} + +/** + * The off-host picture, per account. + * + * Computed from the ledger the nightly worker writes, never from the bucket: + * the page must be able to answer "does this account have a recent copy + * off-host" on a host whose worker holds PutObject and nothing else. + */ +export interface OffhostBackupOverview { + /** Whether this host has the variables the nightly job needs. */ + configured: boolean; + /** Hours between two scheduled runs — the number behind the verdicts. */ + periodHours: number; + /** One row per account. Empty when off-host backup is not configured. */ + rows: OffhostAccountRow[]; +} + export interface BackupsList { rows: BackupRow[]; /** @@ -32,6 +62,12 @@ export interface BackupsList { * made six weeks ago and one made on Sunday look identical in a table. */ schedule: BackupScheduleStatus; + /** + * The off-host leg. A weekly row in this table says the copy that lives in + * this database is current; it says nothing about whether anything reached + * the operator's bucket, and those two fail independently. + */ + offhost: OffhostBackupOverview; /** * Soft retention hint — the worker is configured for weekly backups * (see `DATA_BACKUP_CRON` in `src/lib/jobs/reminder-worker.ts`), and From 532483e7827bb39a01d2b05b6937fde6e62ae8d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 09:28:12 +0200 Subject: [PATCH 11/56] fix(admin): say where error reports go, and stop calling a silent target configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There is no default error-reporting DSN in this tree and there should not be: a self-hoster's crash reports must not leave their host because nobody told them not to. That part was already right, so nothing here adds a default. What was wrong is what the console said about it. The GlitchTip card showed a green "Configured" badge as soon as a DSN existed, whether or not the switch that actually sends anything was on, and it never said where reports go. An operator reading that badge has been told their errors are being collected somewhere, which may be true of neither half. The badge now tracks reports actually leaving, and one line under the switch states the case: the host they go to, or that they stay here, or that tracking is on with no target. The host only — the public key in the DSN answers a different question. The deploy runbook says the same thing for a maintainer-operated instance. --- docs/ops/deploy.md | 12 ++++++++ messages/de.json | 3 ++ messages/en.json | 3 ++ messages/es.json | 3 ++ messages/fr.json | 3 ++ messages/it.json | 3 ++ messages/ko.json | 3 ++ messages/pl.json | 3 ++ src/components/admin/glitchtip-section.tsx | 34 ++++++++++++++++++++-- 9 files changed, 65 insertions(+), 2 deletions(-) diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index 6700f779e..68106fe17 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -143,6 +143,18 @@ itself still works: it walks the journey against an in-process instance once per leg with that leg broken, and fails if any of them comes back green. +## Error reporting + +HealthLog ships no default error-reporting target and never will: an +operator's crash reports must not leave their host because nobody told them +not to. Reporting starts only when someone types a DSN into **Admin → +Integrations → GlitchTip**, and that card states plainly whether reports are +going anywhere and which host they go to. + +For a maintainer-operated instance, set the DSN there after the first deploy — +it lives in the database, not in the environment, so it survives an image +upgrade and does not need a compose change. + ## Verify the image signature Every release image (the multi-arch manifest **index**, covering both diff --git a/messages/de.json b/messages/de.json index 84d2a27b9..d2b1c9cbb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6597,6 +6597,9 @@ "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "GlitchTip Environment", "glitchtipEnvironmentPlaceholder": "production", + "glitchtipTargetOn": "Fehlerberichte gehen an {host}.", + "glitchtipTargetOff": "Fehlerberichte bleiben auf diesem Host. Es wird nichts verschickt.", + "glitchtipTargetNoTarget": "Fehler-Tracking ist an, aber kein Ziel gesetzt, also wird nichts verschickt.", "webPushVapidDescription": "Konfiguriere VAPID-Schlüssel und Subject direkt hier, damit Browser-Push ohne ENV-Variablen funktioniert.", "webPushVapidPublicKey": "VAPID Public Key", "webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)", diff --git a/messages/en.json b/messages/en.json index dcd603d32..2f4a06eb6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6597,6 +6597,9 @@ "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "GlitchTip environment", "glitchtipEnvironmentPlaceholder": "production", + "glitchtipTargetOn": "Error reports go to {host}.", + "glitchtipTargetOff": "Error reports stay on this host. Nothing is sent anywhere.", + "glitchtipTargetNoTarget": "Error tracking is on, but no target is set, so nothing is sent.", "webPushVapidDescription": "Configure VAPID keys and subject here so browser push works without environment variables.", "webPushVapidPublicKey": "VAPID public key", "webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)", diff --git a/messages/es.json b/messages/es.json index 0188dd089..28aab6313 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6597,6 +6597,9 @@ "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "Entorno de GlitchTip", "glitchtipEnvironmentPlaceholder": "production", + "glitchtipTargetOn": "Los informes de errores van a {host}.", + "glitchtipTargetOff": "Los informes de errores se quedan en este host. No se envía nada.", + "glitchtipTargetNoTarget": "El seguimiento de errores está activado, pero no hay destino, así que no se envía nada.", "webPushVapidDescription": "Configura aquí las claves VAPID y el subject para que el push del navegador funcione sin variables de entorno.", "webPushVapidPublicKey": "Clave pública VAPID", "webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)", diff --git a/messages/fr.json b/messages/fr.json index 38dc08eb9..2e40628d5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6597,6 +6597,9 @@ "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "Environnement GlitchTip", "glitchtipEnvironmentPlaceholder": "production", + "glitchtipTargetOn": "Les rapports d'erreur vont vers {host}.", + "glitchtipTargetOff": "Les rapports d'erreur restent sur cet hôte. Rien n'est envoyé.", + "glitchtipTargetNoTarget": "Le suivi des erreurs est activé, mais aucune cible n'est définie, donc rien n'est envoyé.", "webPushVapidDescription": "Configurez ici les clés VAPID et le subject pour que le push du navigateur fonctionne sans variables d'environnement.", "webPushVapidPublicKey": "Clé publique VAPID", "webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)", diff --git a/messages/it.json b/messages/it.json index 973619366..d883b2f5d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6597,6 +6597,9 @@ "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "Ambiente GlitchTip", "glitchtipEnvironmentPlaceholder": "production", + "glitchtipTargetOn": "Le segnalazioni di errore vanno a {host}.", + "glitchtipTargetOff": "Le segnalazioni di errore restano su questo host. Non viene inviato nulla.", + "glitchtipTargetNoTarget": "Il tracciamento degli errori è attivo, ma non c'è una destinazione, quindi non viene inviato nulla.", "webPushVapidDescription": "Configura qui le chiavi VAPID e il subject affinché il push del browser funzioni senza variabili d'ambiente.", "webPushVapidPublicKey": "Chiave pubblica VAPID", "webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)", diff --git a/messages/ko.json b/messages/ko.json index 800afd943..549935a1a 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6577,6 +6577,9 @@ "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "GlitchTip 환경", "glitchtipEnvironmentPlaceholder": "production", + "glitchtipTargetOn": "오류 보고서는 {host}(으)로 전송됩니다.", + "glitchtipTargetOff": "오류 보고서는 이 호스트에 남습니다. 아무것도 전송되지 않습니다.", + "glitchtipTargetNoTarget": "오류 추적은 켜져 있지만 대상이 없어 아무것도 전송되지 않습니다.", "webPushVapidDescription": "환경 변수 없이 브라우저 푸시가 동작하도록 VAPID 키와 subject를 여기서 설정하세요.", "webPushVapidPublicKey": "VAPID 공개 키", "webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)", diff --git a/messages/pl.json b/messages/pl.json index 698a2e2c5..6bd2d6b15 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6597,6 +6597,9 @@ "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "Środowisko GlitchTip", "glitchtipEnvironmentPlaceholder": "production", + "glitchtipTargetOn": "Zgłoszenia błędów trafiają do {host}.", + "glitchtipTargetOff": "Zgłoszenia błędów zostają na tym hoście. Nic nie jest wysyłane.", + "glitchtipTargetNoTarget": "Śledzenie błędów jest włączone, ale nie ustawiono celu, więc nic nie jest wysyłane.", "webPushVapidDescription": "Skonfiguruj tutaj klucze VAPID i subject, aby push przeglądarki działał bez zmiennych środowiskowych.", "webPushVapidPublicKey": "Klucz publiczny VAPID", "webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)", diff --git a/src/components/admin/glitchtip-section.tsx b/src/components/admin/glitchtip-section.tsx index 746c8059e..1a7bdfc93 100644 --- a/src/components/admin/glitchtip-section.tsx +++ b/src/components/admin/glitchtip-section.tsx @@ -34,7 +34,30 @@ export function GlitchtipSection() { const glitchtipEnvironmentValue = glitchtipEnvironmentDraft ?? settings?.glitchtipEnvironment ?? "production"; - const configured = Boolean(settings?.glitchtipDsn); + /** + * The host the DSN addresses, and nothing else from it. An operator asking + * "where do my errors go" is asking for a hostname; the public key in the + * DSN answers a different question and does not belong in a status line. + * + * There is deliberately no default here and none anywhere else in the tree: + * a self-hoster's crash reports must not leave their host because nobody + * told them not to. Reporting starts when this operator types a target. + */ + const targetHost = ((): string | null => { + const dsn = settings?.glitchtipDsn; + if (!dsn) return null; + try { + return new URL(dsn).host || null; + } catch { + return null; + } + })(); + + // "Configured" has to mean reports are actually going somewhere. A DSN with + // the switch off sends nothing, and a green badge over that is the kind of + // reassurance an operator only discovers was wrong when they go looking for + // a crash that was never reported. + const reporting = Boolean(settings?.glitchtipEnabled && targetHost); const testGlitchtip = useMutation({ mutationFn: async () => { @@ -76,7 +99,7 @@ export function GlitchtipSection() { icon={AlertTriangle} title={t("admin.glitchtipTitle")} description={t("admin.glitchtipDescription")} - status={configured ? : null} + status={reporting ? : null} />
    @@ -89,6 +112,13 @@ export function GlitchtipSection() { } disabled={updateSettings.isPending} /> +

    + {reporting && targetHost + ? t("admin.glitchtipTargetOn", { host: targetHost }) + : settings?.glitchtipEnabled + ? t("admin.glitchtipTargetNoTarget") + : t("admin.glitchtipTargetOff")} +