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 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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, },