From c272324c1e26ff8b8eaf5a5078415cfa0ee818cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 07:07:19 +0200 Subject: [PATCH] fix(api): declare the auth gate's 403 on every operation behind it requireAuth answers 403 auth.scope.insufficient to a narrow Bearer token on a route that names no scope it holds, and requireAdmin answers 403 auth.admin.required to a session that is not an administrator's. Both run before any route body, and the contract only said so in the prose of the shared 401, so a client generated from it had no typed branch for either. The route kit accepted the scope refusal on any operation that declared a 401 for the same reason. The 403 is added to the assembled path table rather than to stdResponses. That set is spread into most operations, about three hundred of them carry a 403 of their own, and most spread the set after it, so a shared 403 there would have overwritten every module-off and sharing refusal in the document. withScopeRefusal adds the shared response where an operation has none and prefixes an operation's own 403 with the gate's code, keeping the sharing sentence last. Operations that take no credential, and the three surfaces that authenticate by a credential of their own, are left as they were. The kit no longer tolerates the refusal on an operation's behalf, and a guard holds every operation behind the gate to declaring it. --- docs/api/openapi.yaml | 1751 ++++++++++------- docs/contributing/testing.md | 10 +- .../gate-refusal-publication.test.ts | 134 ++ src/lib/openapi/routes/index.ts | 9 +- src/lib/openapi/routes/shared.ts | 122 ++ tests/integration/routes/kit-contract.test.ts | 10 +- tests/kit/call-route.ts | 24 +- 7 files changed, 1371 insertions(+), 689 deletions(-) create mode 100644 src/lib/openapi/__tests__/gate-refusal-publication.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6cc0471ec..2ffb877dd 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -90,8 +90,10 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "403": &a2 description: >- - Cycle tracking is not enabled for this account (errorCode `cycle.disabled`). Resolved against the RECORD - being read, not the caller: a delegate inside somebody else's record sees the owner's setting. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Cycle tracking is not enabled for + this account (errorCode `cycle.disabled`). Resolved against the RECORD being read, not the caller: a + delegate inside somebody else's record sees the owner's setting. Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). @@ -259,8 +261,10 @@ paths: $ref: "#/components/schemas/CycleCustomSymptomUpdatedEnvelope" "401": *a1 "403": &a3 - description: "Cycle tracking is not enabled for this account (errorCode `cycle.disabled`). Resolved against the RECORD - being read, not the caller: a delegate inside somebody else's record sees the owner's setting." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Cycle tracking is not enabled for + this account (errorCode `cycle.disabled`). Resolved against the RECORD being read, not the caller: a + delegate inside somebody else's record sees the owner's setting." content: application/json: schema: @@ -395,7 +399,7 @@ paths: "403": *a2 "409": *a7 "422": *a4 - "429": &a12 + "429": &a13 description: "Rate limit exceeded. This route shares one per-account bucket with every other authenticated write — `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. Only the routes that meter themselves with a dedicated @@ -736,6 +740,15 @@ paths: schema: $ref: "#/components/schemas/CyclePrefsGetEnvelope" "401": *a1 + "403": &a9 + description: "The credential is valid but its scope does not reach this operation. `meta.errorCode` = + `auth.scope.insufficient`: a narrow Bearer token on a route that names no scope it holds, or a + connector-bound token on a write. Retrying with the same token will not help; use a session, or a token + minted with the scope the route names." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 patch: @@ -757,7 +770,7 @@ paths: application/json: schema: $ref: "#/components/schemas/CyclePrefsPatchEnvelope" - "400": &a14 + "400": &a15 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. @@ -766,6 +779,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/measurement-categories: @@ -785,6 +799,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementCategoriesEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/meta/capabilities: @@ -805,6 +820,7 @@ paths: schema: $ref: "#/components/schemas/CapabilitiesEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/version: @@ -877,7 +893,9 @@ paths: $ref: "#/components/schemas/AssistantFlagsEnvelope" "401": *a1 "403": - description: A selector header was attached to an actor surface (`meta.errorCode` = `sharing.not_permitted`). + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: A selector header was attached to + an actor surface (`meta.errorCode` = `sharing.not_permitted`)." content: application/json: schema: @@ -916,6 +934,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCheckEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 "502": @@ -964,16 +983,17 @@ paths: type: string format: binary "401": *a1 - "403": &a11 - 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 - response carries no reason, so it is not an account-enumeration oracle. The reason reaches the record - owner's activity feed and the operator's audit trail instead. On the cookie transport the same code answers - one further case: a session that has entered a shared record and sends a request without its record-context - assertion, which is a client older than the fence — leave the record and reload. A request that names no - record but the caller's own never reaches this response, so a client that never switches and never sends the - per-request account selector will not see it." + "403": &a12 + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: 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 response carries no reason, so it is not an account-enumeration + oracle. The reason reaches the record owner's activity feed and the operator's audit trail instead. On the + cookie transport the same code answers one further case: a session that has entered a shared record and + sends a request without its record-context assertion, which is a client older than the fence — leave the + record and reload. A request that names no record but the caller's own never reaches this response, so a + client that never switches and never sends the per-request account selector will not see it." content: application/json: schema: @@ -1011,6 +1031,7 @@ paths: schema: $ref: "#/components/schemas/ShareLinkCreated" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 get: @@ -1027,6 +1048,7 @@ paths: schema: $ref: "#/components/schemas/ShareLinkList" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/share-links/{id}: @@ -1050,6 +1072,7 @@ paths: schema: $ref: "#/components/schemas/ShareLinkRevoked" "401": *a1 + "403": *a9 "404": description: Link not found (or owned by another user). content: @@ -1257,6 +1280,7 @@ paths: type: string format: binary "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/fhir/Patient: @@ -1286,6 +1310,7 @@ paths: type: string format: binary "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/fhir/Observation: @@ -1332,6 +1357,7 @@ paths: type: string format: binary "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/fhir/MedicationStatement: @@ -1361,6 +1387,7 @@ paths: type: string format: binary "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/fhir/MedicationAdministration: @@ -1390,6 +1417,7 @@ paths: type: string format: binary "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/fhir/Coverage: @@ -1420,6 +1448,7 @@ paths: type: string format: binary "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/fhir/Patient/{id}: @@ -1445,6 +1474,7 @@ paths: type: string format: binary "401": *a1 + "403": *a9 "404": description: OperationOutcome (application/fhir+json). content: @@ -1477,6 +1507,7 @@ paths: type: string format: binary "401": *a1 + "403": *a9 "404": description: OperationOutcome (application/fhir+json). content: @@ -1510,6 +1541,7 @@ paths: type: string format: binary "401": *a1 + "403": *a9 "404": description: OperationOutcome (application/fhir+json). content: @@ -1542,6 +1574,7 @@ paths: type: string format: binary "401": *a1 + "403": *a9 "404": description: OperationOutcome (application/fhir+json). content: @@ -1574,6 +1607,7 @@ paths: type: string format: binary "401": *a1 + "403": *a9 "404": description: OperationOutcome (application/fhir+json). content: @@ -1617,6 +1651,7 @@ paths: type: string format: binary "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/export: @@ -1676,6 +1711,7 @@ paths: schema: type: string "401": *a1 + "403": *a9 "422": description: "`format` or `type` carried a value outside its enum. The message names the accepted set; nothing is exported. Note that this route validates by hand rather than through Zod, so the response is the @@ -1719,6 +1755,7 @@ paths: schema: $ref: "#/components/schemas/FullBackupPayload" "401": *a1 + "403": *a9 "429": description: More than 10 exports in the trailing hour for this account (`export:`, shared across the export family). @@ -1779,6 +1816,7 @@ paths: schema: type: string "401": *a1 + "403": *a9 "429": description: More than 10 exports in the trailing hour for this account. content: @@ -1845,6 +1883,7 @@ paths: schema: type: string "401": *a1 + "403": *a9 "404": description: "`medicationId` named a medication this account does not hold. An unknown id and another account's id are indistinguishable." @@ -1900,6 +1939,7 @@ paths: schema: type: string "401": *a1 + "403": *a9 "429": description: More than 10 exports in the trailing hour for this account. content: @@ -1938,8 +1978,10 @@ paths: "401": *a1 "403": description: >- - The Bearer token carries a narrow scope. The encrypted archive needs a cookie session or a full-access - token; the refusal happens before any second-factor proof is looked at. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The Bearer token carries a narrow + scope. The encrypted archive needs a cookie session or a full-access token; the refusal happens before any + second-factor proof is looked at. Refused: the request was made while the session is acting on another account (`meta.errorCode` = @@ -2135,7 +2177,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "409": &a9 + "409": &a10 description: 'Refused before anything was checked or spent: the request stated `clientTransport.protocol: "http"` on a non-loopback host while this server issues `Secure` session cookies, so the browser would drop the session. `meta.errorCode` = `auth.session.insecure_transport`, `meta.sessionCookieSecure` = `true`. No password was @@ -2170,7 +2212,8 @@ paths: schema: $ref: "#/components/schemas/MfaVerifyResponse" "401": *a1 - "409": *a9 + "403": *a9 + "409": *a10 "422": *a4 "429": *a5 /api/auth/oidc/native/token: @@ -2203,6 +2246,7 @@ paths: schema: $ref: "#/components/schemas/OidcNativeTokenResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/native/login: @@ -2276,6 +2320,7 @@ paths: schema: $ref: "#/components/schemas/NativeHandoffTokenResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/step-up/options: @@ -2302,6 +2347,7 @@ paths: schema: $ref: "#/components/schemas/StepUpOptionsEnvelope" "401": *a1 + "403": *a9 "409": description: No passkey registered on this account. content: @@ -2370,6 +2416,7 @@ paths: schema: $ref: "#/components/schemas/StepUpMintEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/reproof: @@ -2414,6 +2461,7 @@ paths: schema: $ref: "#/components/schemas/ReproofEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 get: @@ -2432,6 +2480,7 @@ paths: schema: $ref: "#/components/schemas/ReproofStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/mfa/totp/setup: @@ -2471,13 +2520,14 @@ paths: application/json: schema: $ref: "#/components/schemas/TotpSetupEnvelope" - "401": &a10 + "401": &a11 description: No session or elevation; or, on the cookie path, `auth.reproof.required` (no recent sign-in and no proof in the body) or `auth.reproof.failed` (the proof did not verify). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "409": description: A second factor is already active on this account. content: @@ -2514,6 +2564,7 @@ paths: schema: $ref: "#/components/schemas/TotpConfirmEnvelope" "401": *a1 + "403": *a9 "409": description: A second factor is already active, or enrollment was never started (no pending secret to confirm). content: @@ -2544,6 +2595,7 @@ paths: schema: $ref: "#/components/schemas/MfaDisableEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/mfa/recovery-codes/regenerate: @@ -2562,6 +2614,7 @@ paths: schema: $ref: "#/components/schemas/MfaRecoveryRegenEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/mfa: @@ -2581,6 +2634,7 @@ paths: schema: $ref: "#/components/schemas/MfaStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/mfa/webauthn/register/options: @@ -2619,7 +2673,8 @@ paths: application/json: schema: $ref: "#/components/schemas/MfaWebauthnRegisterOptionsEnvelope" - "401": *a10 + "401": *a11 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/mfa/webauthn/register/verify: @@ -2650,6 +2705,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/mfa/webauthn/{id}: @@ -2680,6 +2736,7 @@ paths: schema: $ref: "#/components/schemas/MfaWebauthnRenameEnvelope" "401": *a1 + "403": *a9 "404": description: No such security key for this account. content: @@ -2708,6 +2765,7 @@ paths: schema: $ref: "#/components/schemas/MfaWebauthnRemoveEnvelope" "401": *a1 + "403": *a9 "404": description: No such security key for this account. content: @@ -2737,6 +2795,7 @@ paths: schema: $ref: "#/components/schemas/MfaWebauthnLoginOptionsEnvelope" "401": *a1 + "403": *a9 "409": description: No security key is registered on the account — fall back to the TOTP / recovery-code challenge. content: @@ -2767,7 +2826,8 @@ paths: schema: $ref: "#/components/schemas/MfaWebauthnVerifyResponse" "401": *a1 - "409": *a9 + "403": *a9 + "409": *a10 "422": *a4 "429": *a5 /api/auth/passkey/login-verify: @@ -2852,6 +2912,7 @@ paths: - $ref: "#/components/schemas/RefreshResponse" - $ref: "#/components/schemas/RefreshRevokeResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/passkeys: @@ -2870,6 +2931,7 @@ paths: schema: $ref: "#/components/schemas/PasskeyListEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/sessions: @@ -2888,6 +2950,7 @@ paths: schema: $ref: "#/components/schemas/SessionListEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 delete: @@ -2920,6 +2983,7 @@ paths: schema: $ref: "#/components/schemas/SignOutEverywhereEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/sessions/{id}: @@ -2943,6 +3007,7 @@ paths: schema: $ref: "#/components/schemas/SessionRevokeEnvelope" "401": *a1 + "403": *a9 "404": description: Session not found or not owned by the caller. content: @@ -2966,6 +3031,7 @@ paths: schema: $ref: "#/components/schemas/TrustedDeviceListEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 delete: @@ -2981,6 +3047,7 @@ paths: schema: $ref: "#/components/schemas/TrustedDeviceRevokeAllEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/trusted-devices/{id}: @@ -3003,6 +3070,7 @@ paths: schema: $ref: "#/components/schemas/TrustedDeviceRevokeEnvelope" "401": *a1 + "403": *a9 "404": description: Trusted device not found or not owned by the caller. content: @@ -3036,6 +3104,7 @@ paths: schema: $ref: "#/components/schemas/SecurityActivityEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/logout: @@ -3124,7 +3193,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "409": *a9 + "409": *a10 "429": *a5 /api/auth/passkeys/{id}: patch: @@ -3166,6 +3235,7 @@ paths: schema: $ref: "#/components/schemas/PasskeyRenameEnvelope" "401": *a1 + "403": *a9 "404": description: No such passkey for this account. content: @@ -3228,6 +3298,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "404": description: No such passkey for this account. content: @@ -3264,6 +3335,7 @@ paths: schema: $ref: "#/components/schemas/ApiTokenListEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/tokens/measurements: @@ -3322,8 +3394,10 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": - description: The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). Nothing was minted. A Bearer - caller does not reach this arm — it is refused 401 before the switch is read. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator has switched the API + off instance-wide (`AppSettings.apiGlobal`). Nothing was minted. A Bearer caller does not reach this arm — + it is refused 401 before the switch is read." content: application/json: schema: @@ -3412,8 +3486,9 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": - description: The operator has switched the API off instance-wide. Nothing was minted. A Bearer caller is refused 401 - before the switch is read. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator has switched the API + off instance-wide. Nothing was minted. A Bearer caller is refused 401 before the switch is read." content: application/json: schema: @@ -3499,8 +3574,9 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": - description: The operator has switched the API off instance-wide. Nothing was minted. A Bearer caller is refused 401 - before the switch is read. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator has switched the API + off instance-wide. Nothing was minted. A Bearer caller is refused 401 before the switch is read." content: application/json: schema: @@ -3564,10 +3640,11 @@ paths: $ref: "#/components/schemas/ApiTokenRevokeEnvelope" "401": *a1 "403": - description: The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). `meta.errorCode` is the - status default `http.forbidden`; nothing was revoked. The sibling list is deliberately not gated this way — - a token stays live while the switch is off, so its owner can still see it here even though this call will - not kill it. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator has switched the API + off instance-wide (`AppSettings.apiGlobal`). `meta.errorCode` is the status default `http.forbidden`; + nothing was revoked. The sibling list is deliberately not gated this way — a token stays live while the + switch is off, so its owner can still see it here even though this call will not kill it." content: application/json: schema: @@ -3685,6 +3762,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "422": *a4 "429": *a5 /api/auth/profile: @@ -3721,6 +3799,7 @@ paths: schema: $ref: "#/components/schemas/AuthProfileUpdateEnvelope" "401": *a1 + "403": *a9 "409": description: The requested email belongs to another account (`meta.errorCode` = `profile.update.emailInUse`). Nothing was written. @@ -3736,7 +3815,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a16 + "429": &a17 description: "Too many email-address changes, and the request asked for nothing else. The address change shares one per-account bucket across both profile routes — `profile-email:`, 10 requests per 3600 seconds — keyed on the ACTING account, so an account cannot collect a fresh allowance by probing a different address. @@ -3933,6 +4012,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "409": description: The re-proof arm asked for a method the account has no credential for — fall back to another factor. content: @@ -3993,6 +4073,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "422": *a4 "429": *a5 /api/auth/codex/device-start: @@ -4020,6 +4101,7 @@ paths: schema: $ref: "#/components/schemas/CodexDeviceStartEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/codex/device-poll: @@ -4050,6 +4132,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 "502": @@ -4080,6 +4163,7 @@ paths: schema: $ref: "#/components/schemas/CodexDisconnectEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/sync/state: @@ -4098,6 +4182,7 @@ paths: schema: $ref: "#/components/schemas/SyncStateEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/sync/changes: @@ -4137,6 +4222,7 @@ paths: schema: $ref: "#/components/schemas/SyncChangesEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/measurements/series-batch: @@ -4186,7 +4272,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesBatchResponse" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/measurements/series: @@ -4241,7 +4327,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesResponse" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/measurements: @@ -4462,7 +4548,7 @@ paths: schema: $ref: "#/components/schemas/ListMeasurementsResponse" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -4518,7 +4604,7 @@ paths: schema: $ref: "#/components/schemas/CreateMeasurementResponse" "401": *a1 - "403": *a11 + "403": *a12 "409": description: A measurement with this data already exists — the `(userId, type, source, externalId)` dedup index rejected the write. Never for a named sleep-stage body, which answers a re-post with 200 instead. @@ -4534,7 +4620,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a12 + "429": *a13 /api/measurements/{id}: get: tags: @@ -4555,7 +4641,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Measurement not found (or owned by another user). content: @@ -4603,7 +4689,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMeasurementResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Measurement not found (or owned by another user). content: @@ -4639,7 +4725,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMeasurementResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Measurement not found (or owned by another user). content: @@ -4683,6 +4769,7 @@ paths: schema: $ref: "#/components/schemas/BatchMeasurementsResponse" "401": *a1 + "403": *a9 "409": *a7 "422": description: "The batch exceeded the 500-entry limit (`measurement.batch.too_large`), failed validation @@ -4723,6 +4810,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsDeleteByExternalIdsResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/measurements/bulk-delete: @@ -4750,7 +4838,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsBulkDeleteResponse" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -4779,7 +4867,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsRestoreResponse" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -4812,11 +4900,13 @@ paths: schema: $ref: "#/components/schemas/SleepNightResponse" "401": *a1 - "403": &a13 + "403": &a14 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. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: 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. Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). @@ -4853,7 +4943,7 @@ paths: schema: $ref: "#/components/schemas/SleepRhythmResponse" "401": *a1 - "403": *a13 + "403": *a14 "422": *a4 "429": *a5 /api/workouts: @@ -4902,7 +4992,7 @@ paths: schema: $ref: "#/components/schemas/ListWorkoutsResponse" "401": *a1 - "403": *a13 + "403": *a14 "422": *a4 "429": *a5 /api/workouts/{id}: @@ -4938,10 +5028,12 @@ paths: schema: $ref: "#/components/schemas/GetWorkoutResponse" "401": *a1 - "403": &a19 - 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.' + "403": &a21 + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: 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." content: application/json: schema: @@ -4976,6 +5068,7 @@ paths: schema: $ref: "#/components/schemas/DeleteWorkoutResponse" "401": *a1 + "403": *a9 "404": description: Workout not found (or owned by another user). content: @@ -5038,6 +5131,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "409": *a7 "413": description: Request body exceeds the 5 MB ceiling. @@ -5095,7 +5189,7 @@ paths: schema: $ref: "#/components/schemas/ActivityCalendarEnvelope" "401": *a1 - "403": *a13 + "403": *a14 "422": *a4 "429": *a5 /api/activity/goals: @@ -5113,6 +5207,7 @@ paths: schema: $ref: "#/components/schemas/ActivityGoalsEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -5135,8 +5230,9 @@ paths: schema: $ref: "#/components/schemas/ActivityGoalsPatchEnvelope" "401": *a1 + "403": *a9 "422": *a4 - "429": *a12 + "429": *a13 /api/measurements/provenance: get: tags: @@ -5261,7 +5357,7 @@ paths: schema: $ref: "#/components/schemas/DayProvenanceEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/measurements/provenance/compare: @@ -5439,7 +5535,7 @@ paths: schema: $ref: "#/components/schemas/SourceComparisonEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/devices: @@ -5463,6 +5559,7 @@ paths: schema: $ref: "#/components/schemas/DeviceRegisterResponse" "401": *a1 + "403": *a9 "409": description: Device or APNs token already registered to another user. content: @@ -5497,6 +5594,7 @@ paths: schema: $ref: "#/components/schemas/DeviceRevokeEnvelope" "401": *a1 + "403": *a9 "404": description: No such device for this caller — also the answer for one already revoked, so a retry after a successful revoke is a 404 rather than an idempotent 200. @@ -5549,7 +5647,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeAggregateEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "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 @@ -5639,9 +5737,11 @@ paths: "401": *a1 "403": description: >- - Refused: a delegate tried to snooze, or to change a dose the owner had already recorded as taken or skipped - (`meta.errorCode` = `sharing.not_permitted`). Both stay open to the owner, and the attempt is filed on the - owner's activity trail. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Refused: a delegate tried to + snooze, or to change a dose the owner had already recorded as taken or skipped (`meta.errorCode` = + `sharing.not_permitted`). Both stay open to the owner, and the attempt is filed on the owner's activity + trail. Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). @@ -5677,7 +5777,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a12 + "429": *a13 /api/medications/{id}/glp1: get: tags: @@ -5702,7 +5802,7 @@ paths: schema: $ref: "#/components/schemas/Glp1DetailResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another account). content: @@ -5786,7 +5886,7 @@ paths: schema: $ref: "#/components/schemas/Glp1PostResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another account). content: @@ -5821,7 +5921,9 @@ paths: $ref: "#/components/schemas/MedicationApiEndpointStateEnvelope" "401": *a1 "403": - description: The operator has disabled the API globally. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator has disabled the API + globally." content: application/json: schema: @@ -5875,7 +5977,9 @@ paths: $ref: "#/components/schemas/MedicationApiEndpointCreatedEnvelope" "401": *a1 "403": - description: The operator has disabled the API globally. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator has disabled the API + globally." content: application/json: schema: @@ -5921,7 +6025,7 @@ paths: schema: $ref: "#/components/schemas/ReminderPhaseConfigEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another account). content: @@ -5959,6 +6063,7 @@ paths: schema: $ref: "#/components/schemas/ReminderPhaseConfigSavedEnvelope" "401": *a1 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -5987,6 +6092,7 @@ paths: schema: $ref: "#/components/schemas/ReminderPhaseConfigResetEnvelope" "401": *a1 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -6037,7 +6143,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationSideEffectsEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another account). content: @@ -6076,7 +6182,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationSideEffectEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another account). content: @@ -6121,7 +6227,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationSideEffectEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "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. @@ -6169,7 +6275,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationIntakeEventEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: No live event with that id under this medication and record. An unknown id and an already-tombstoned one are indistinguishable. @@ -6217,7 +6323,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationIntakeEventEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: No event with that id under this medication and record. content: @@ -6260,7 +6366,7 @@ paths: schema: $ref: "#/components/schemas/BulkDeleteMedicationIntakeEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another account). content: @@ -6302,7 +6408,7 @@ paths: schema: $ref: "#/components/schemas/PurgeMedicationIntakeEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another account). content: @@ -6347,7 +6453,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportAcceptedEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another account). content: @@ -6407,6 +6513,7 @@ paths: schema: $ref: "#/components/schemas/BulkMedicationIntakeResponseEnvelope" "401": *a1 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -6427,7 +6534,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationsResponse" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -6459,7 +6566,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationResponse" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -6479,7 +6586,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationCategoriesResponse" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -6506,7 +6613,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationCategoryResponse" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -6539,6 +6646,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationCategoryResponse" "401": *a1 + "403": *a9 "404": description: Not one of the record's own categories. content: @@ -6570,6 +6678,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationCategoryResponse" "401": *a1 + "403": *a9 "404": description: Not one of the record's own categories. content: @@ -6598,6 +6707,7 @@ paths: schema: $ref: "#/components/schemas/MedicationCoursesResponse" "401": *a1 + "403": *a9 "404": description: Medication not found (or another user's). content: @@ -6635,6 +6745,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationCourseResponse" "401": *a1 + "403": *a9 "404": description: Medication not found (or another user's). content: @@ -6676,6 +6787,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationCourseResponse" "401": *a1 + "403": *a9 "404": description: Course not found (`medications.course.notFound`), or another user's. content: @@ -6709,6 +6821,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationCourseResponse" "401": *a1 + "403": *a9 "404": description: Course not found (`medications.course.notFound`), or another user's. content: @@ -6733,7 +6846,7 @@ paths: schema: $ref: "#/components/schemas/MedicationListLayoutResponse" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 put: @@ -6759,6 +6872,7 @@ paths: schema: $ref: "#/components/schemas/MedicationListLayoutSaved" "401": *a1 + "403": *a9 "409": description: Medications layout 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. @@ -6767,7 +6881,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a15 + "422": &a16 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. @@ -6789,6 +6903,7 @@ paths: schema: $ref: "#/components/schemas/MedicationListLayoutReset" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/medications/{id}: @@ -6812,7 +6927,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another user). content: @@ -6853,7 +6968,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another user). content: @@ -6882,6 +6997,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationResponse" "401": *a1 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -6926,7 +7042,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationIntakeResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another user). content: @@ -6944,7 +7060,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a12 + "429": *a13 get: tags: - Medications @@ -7009,7 +7125,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationIntakeEventsResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another user). content: @@ -7042,7 +7158,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationInventoryResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another user). content: @@ -7078,7 +7194,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationInventoryItemResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another user). content: @@ -7122,7 +7238,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationInventoryItemResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -7157,7 +7273,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationInventoryItemResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -7193,14 +7309,16 @@ paths: $ref: "#/components/schemas/MedicationExtractResponse" "401": *a1 "403": - description: "AI refusal: the `medicationExtract` capability is unavailable for this record. The envelope carries - `meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). - `assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master - switch). `ai.record.notPermitted` — AI work is not admitted inside this record (a delegate acting in - somebody else's record). `consent.ai.required` — the provider this request would reach leaves the machine - and the record holds no active `ai_extraction` or `ai_full` receipt; only a self-hosted model is exempt. The - question is answered for the provider actually picked, so it can differ from the published capability when a - text-mode read takes a different provider than a vision read would." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: AI refusal: the + `medicationExtract` capability is unavailable for this record. The envelope carries `meta.errorCode`, + `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). `assistant.disabled.documentAi` — the + operator turned off reading documents, or every AI feature (the master switch). `ai.record.notPermitted` — + AI work is not admitted inside this record (a delegate acting in somebody else's record). + `consent.ai.required` — the provider this request would reach leaves the machine and the record holds no + active `ai_extraction` or `ai_full` receipt; only a self-hosted model is exempt. The question is answered + for the provider actually picked, so it can differ from the published capability when a text-mode read takes + a different provider than a vision read would." content: application/json: schema: @@ -7253,7 +7371,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationCadenceResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another user). content: @@ -7302,7 +7420,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationComplianceResponse" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/medications/{id}/compliance: @@ -7329,6 +7447,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationComplianceResponse" "401": *a1 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -7361,6 +7480,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationEfficacyResponse" "401": *a1 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -7397,6 +7517,7 @@ paths: schema: $ref: "#/components/schemas/SetMedicationEfficacyTargetResponse" "401": *a1 + "403": *a9 "404": description: Medication or biomarker not found. content: @@ -7453,6 +7574,7 @@ paths: schema: $ref: "#/components/schemas/GetDoseHistoryResponse" "401": *a1 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -7483,7 +7605,7 @@ paths: schema: $ref: "#/components/schemas/ListScheduleRevisionsResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another user). content: @@ -7521,7 +7643,7 @@ paths: schema: $ref: "#/components/schemas/CreateScheduleRevisionResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another user). content: @@ -7565,7 +7687,7 @@ paths: schema: $ref: "#/components/schemas/UpdateScheduleRevisionResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication or revision not found (or owned by another user). content: @@ -7606,7 +7728,7 @@ paths: schema: $ref: "#/components/schemas/DeleteScheduleRevisionResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication or revision not found (or owned by another user). content: @@ -7669,7 +7791,7 @@ paths: schema: $ref: "#/components/schemas/MedicationDoseHistoryImportQueuedEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "409": description: A dose-history import is already in progress. content: @@ -7731,7 +7853,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: No such job for this medication (or owned by another user). content: @@ -7765,7 +7887,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: No such job for this account (or owned by another user). content: @@ -7795,7 +7917,7 @@ paths: schema: $ref: "#/components/schemas/ProfileSummaryEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/ai/test: @@ -7844,8 +7966,10 @@ paths: $ref: "#/components/schemas/AiTestEnvelope" "401": *a1 "403": - description: The operator's master AI switch is off (`meta.errorCode = "assistant.disabled.enabled"`), or the switches - could not be read (`ai.unavailable`). Nothing was sent. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator's master AI switch + is off (`meta.errorCode = \"assistant.disabled.enabled\"`), or the switches could not be read + (`ai.unavailable`). Nothing was sent." content: application/json: schema: @@ -7896,6 +8020,7 @@ paths: schema: $ref: "#/components/schemas/GetAiProviderResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -7939,6 +8064,7 @@ paths: schema: $ref: "#/components/schemas/PatchAiProviderResponse" "401": *a1 + "403": *a9 "413": description: Body exceeds 64 KiB. content: @@ -7978,6 +8104,7 @@ paths: schema: $ref: "#/components/schemas/GetCoachPrefsResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -8000,8 +8127,9 @@ paths: application/json: schema: $ref: "#/components/schemas/PutCoachPrefsResponse" - "400": *a14 + "400": *a15 "401": *a1 + "403": *a9 "409": description: Coach preferences 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. @@ -8010,7 +8138,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a15 + "422": *a16 "429": *a5 /api/auth/me/disable-coach: get: @@ -8027,6 +8155,7 @@ paths: schema: $ref: "#/components/schemas/GetDisableCoachResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -8048,8 +8177,9 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchDisableCoachResponse" - "400": *a14 + "400": *a15 "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/diabetes: @@ -8069,6 +8199,7 @@ paths: schema: $ref: "#/components/schemas/GetDiabetesResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -8092,6 +8223,7 @@ paths: schema: $ref: "#/components/schemas/PatchDiabetesResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/health-score-config: @@ -8111,6 +8243,7 @@ paths: schema: $ref: "#/components/schemas/HealthScoreConfigEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -8139,6 +8272,7 @@ paths: schema: $ref: "#/components/schemas/PatchHealthScoreConfigEnvelope" "401": *a1 + "403": *a9 "409": description: Score selection 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. @@ -8179,6 +8313,7 @@ paths: schema: $ref: "#/components/schemas/GetModulesResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -8203,8 +8338,9 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchModulesResponse" - "400": *a14 + "400": *a15 "401": *a1 + "403": *a9 "409": description: Module preferences 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. @@ -8213,7 +8349,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a15 + "422": *a16 "429": *a5 /api/auth/me/source-priority: get: @@ -8230,6 +8366,7 @@ paths: schema: $ref: "#/components/schemas/GetSourcePriorityResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -9164,8 +9301,9 @@ paths: application/json: schema: $ref: "#/components/schemas/PutSourcePriorityResponse" - "400": *a14 + "400": *a15 "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/user/avatar: @@ -9198,6 +9336,7 @@ paths: schema: $ref: "#/components/schemas/UploadAvatarResponse" "401": *a1 + "403": *a9 "413": description: Upload exceeds the 2 MiB byte limit or the 2048×2048 dimension limit. content: @@ -9222,6 +9361,7 @@ paths: "204": description: Avatar cleared (or already empty). "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/user/avatar/{id}: @@ -9258,7 +9398,9 @@ paths: description: Raw WebP bytes. "401": *a1 "403": - description: Caller is not the avatar's owner. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Caller is not the avatar's + owner." content: application/json: schema: @@ -9286,6 +9428,7 @@ paths: schema: $ref: "#/components/schemas/GetProfileResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9309,6 +9452,7 @@ paths: schema: $ref: "#/components/schemas/PatchProfileResponse" "401": *a1 + "403": *a9 "409": description: The requested email belongs to another account (`meta.errorCode` = `profile.update.emailInUse`). Nothing was written. Shared with PUT /api/auth/profile, which runs the same handler. @@ -9317,7 +9461,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a16 + "429": *a17 /api/auth/me/notification-prefs: get: tags: @@ -9334,6 +9478,7 @@ paths: schema: $ref: "#/components/schemas/GetNotificationPrefsResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9359,8 +9504,9 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchNotificationPrefsResponse" - "400": *a14 + "400": *a15 "401": *a1 + "403": *a9 "409": description: Notification preferences 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. @@ -9369,7 +9515,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a15 + "422": *a16 "429": *a5 /api/auth/me/report-selection: get: @@ -9387,6 +9533,7 @@ paths: schema: $ref: "#/components/schemas/GetReportSelectionResponseEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -9411,8 +9558,9 @@ paths: application/json: schema: $ref: "#/components/schemas/PutReportSelectionResponseEnvelope" - "400": *a14 + "400": *a15 "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/unit-preference: @@ -9432,6 +9580,7 @@ paths: schema: $ref: "#/components/schemas/GetUnitPreferenceEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9459,6 +9608,7 @@ paths: schema: $ref: "#/components/schemas/PatchUnitPreferenceEnvelope" "401": *a1 + "403": *a9 "413": description: Request body exceeds 1024 bytes. Nothing was written. content: @@ -9484,6 +9634,7 @@ paths: schema: $ref: "#/components/schemas/GetGlucoseUnitEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9515,6 +9666,7 @@ paths: schema: $ref: "#/components/schemas/PatchGlucoseUnitEnvelope" "401": *a1 + "403": *a9 "413": description: Request body exceeds 1024 bytes. Nothing was written. content: @@ -9539,6 +9691,7 @@ paths: schema: $ref: "#/components/schemas/GetDocumentsAutoAiReadEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9576,6 +9729,7 @@ paths: schema: $ref: "#/components/schemas/PatchDocumentsAutoAiReadEnvelope" "401": *a1 + "403": *a9 "413": description: Request body exceeds 1024 bytes. Nothing was written. content: @@ -9600,6 +9754,7 @@ paths: schema: $ref: "#/components/schemas/GetInjectionSitePrefsEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9628,6 +9783,7 @@ paths: schema: $ref: "#/components/schemas/PatchInjectionSitePrefsEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/user/thresholds: @@ -9652,6 +9808,7 @@ paths: schema: $ref: "#/components/schemas/GetThresholdsEnvelope" "401": *a1 + "403": *a9 "404": description: The authenticated user row could not be loaded. In practice unreachable — the session that resolved the caller was validated against that row. @@ -9689,6 +9846,7 @@ paths: schema: $ref: "#/components/schemas/PutThresholdsEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 delete: @@ -9732,6 +9890,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "422": description: '`metric` was omitted and the body did not carry `{ "confirm": "RESET_THRESHOLDS" }`. Nothing was reset.' content: @@ -9760,6 +9919,7 @@ paths: schema: $ref: "#/components/schemas/DeviceListEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/devices/{id}: @@ -9790,6 +9950,7 @@ paths: schema: $ref: "#/components/schemas/DevicePatchEnvelope" "401": *a1 + "403": *a9 "404": description: No such device for this account. content: @@ -9828,6 +9989,7 @@ paths: schema: $ref: "#/components/schemas/AccountDeviceRevokeEnvelope" "401": *a1 + "403": *a9 "404": description: No such device for this account. content: @@ -9854,6 +10016,7 @@ paths: schema: $ref: "#/components/schemas/GetLabsLocalOcrEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9875,6 +10038,7 @@ paths: schema: $ref: "#/components/schemas/PatchLabsLocalOcrEnvelope" "401": *a1 + "403": *a9 "413": description: Request body exceeds 1024 bytes. Nothing was written. content: @@ -9899,6 +10063,7 @@ paths: schema: $ref: "#/components/schemas/GetWorkoutRoutesEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9921,6 +10086,7 @@ paths: schema: $ref: "#/components/schemas/PatchWorkoutRoutesEnvelope" "401": *a1 + "403": *a9 "413": description: Request body exceeds 1024 bytes. Nothing was written. content: @@ -9944,6 +10110,7 @@ paths: schema: $ref: "#/components/schemas/GetUseCentralCodexEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9972,6 +10139,7 @@ paths: schema: $ref: "#/components/schemas/PatchUseCentralCodexEnvelope" "401": *a1 + "403": *a9 "413": description: Request body exceeds 1024 bytes. Nothing was written. content: @@ -10017,7 +10185,9 @@ paths: $ref: "#/components/schemas/LocalePutEnvelope" "401": *a1 "403": - description: A selector header was attached to an actor surface (`meta.errorCode` = `sharing.not_permitted`). + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: A selector header was attached to + an actor surface (`meta.errorCode` = `sharing.not_permitted`)." content: application/json: schema: @@ -10053,6 +10223,7 @@ paths: schema: $ref: "#/components/schemas/TimezonePutEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/passkey-nudge/dismiss: @@ -10070,6 +10241,7 @@ paths: schema: $ref: "#/components/schemas/PasskeyNudgeDismissEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/insights/chat: @@ -10116,6 +10288,7 @@ paths: schema: $ref: "#/components/schemas/CoachConversationsPageResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 post: @@ -10160,11 +10333,13 @@ paths: $ref: "#/components/schemas/CoachStreamEvent" "401": *a1 "403": - description: "The `coach` AI capability is unavailable for this record. `meta.capability` = `coach` and `meta.reason` - says which layer refused: the operator's switch (`assistant.disabled.coach`, or `assistant.disabled.enabled` - for the master switch), an owning module or the person's own AI opt-out (`module.disabled` with - `meta.module`), a delegate inside somebody else's record (`ai.record.notPermitted`), or a missing consent - receipt (`consent.ai.required`). Nothing was generated, read from a stored model answer, or enqueued." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The `coach` AI capability is + unavailable for this record. `meta.capability` = `coach` and `meta.reason` says which layer refused: the + operator's switch (`assistant.disabled.coach`, or `assistant.disabled.enabled` for the master switch), an + owning module or the person's own AI opt-out (`module.disabled` with `meta.module`), a delegate inside + somebody else's record (`ai.record.notPermitted`), or a missing consent receipt (`consent.ai.required`). + Nothing was generated, read from a stored model answer, or enqueued." content: application/json: schema: @@ -10177,7 +10352,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "503": &a18 + "503": &a20 description: "The AI capability could not be resolved (its inputs could not be read) and the answer failed closed: `meta.errorCode` = `ai.unavailable`, `meta.reason` = `check_failed`. Retry later; no data depends on it." content: @@ -10217,6 +10392,7 @@ paths: schema: $ref: "#/components/schemas/CoachMessageResultsResponse" "401": *a1 + "403": *a9 "404": description: Conversation or message not found, or not owned by the caller. content: @@ -10260,6 +10436,7 @@ paths: schema: $ref: "#/components/schemas/CoachMessageTrailResponse" "401": *a1 + "403": *a9 "404": description: Conversation or message not found, or not owned by the caller. content: @@ -10292,6 +10469,7 @@ paths: schema: $ref: "#/components/schemas/CoachConversationDetailResponse" "401": *a1 + "403": *a9 "404": description: Conversation not found or not owned by the caller. content: @@ -10331,6 +10509,7 @@ paths: schema: $ref: "#/components/schemas/CoachConversationRenamed" "401": *a1 + "403": *a9 "404": description: Conversation not found or not owned by the caller. content: @@ -10366,6 +10545,7 @@ paths: schema: $ref: "#/components/schemas/CoachConversationDeleted" "401": *a1 + "403": *a9 "404": description: Conversation not found or not owned by the caller. content: @@ -10408,20 +10588,21 @@ paths: description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." "401": *a1 "403": - description: "AI refusal: the `documentAi` capability is unavailable for this record. The envelope carries - `meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). - `assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master - switch). `module.disabled` with `meta.module = \"inboundDocuments\"` — the module is off for this record. - `ai.record.notPermitted` — AI work is not admitted inside this record (a delegate acting in somebody else's - record). `consent.ai.required` — the provider this request would reach leaves the machine and the record - holds no active `ai_extraction` or `ai_full` receipt; only a self-hosted model is exempt. The question is - answered for the provider actually picked, so it can differ from the published capability when a text-mode - read takes a different provider than a vision read would. The route also answers under `coach`, with the - same envelope and that capability's own codes (`assistant.disabled.coach`, `module.disabled` with - `meta.module = \"coach\"` when the Coach is off for the record). A 422 (`stdResponses`) covers an un-indexed - / unavailable attachment (`coach.fenced.attachmentUnavailable`), the attachment cap - (`coach.fenced.attachmentLimit`), or `attachmentIds` sent with a `conversationId` - (`coach.fenced.attachmentConflict`)." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: AI refusal: the `documentAi` + capability is unavailable for this record. The envelope carries `meta.errorCode`, `meta.capability` and + `meta.reason` (one of `AiUnavailableReason`). `assistant.disabled.documentAi` — the operator turned off + reading documents, or every AI feature (the master switch). `module.disabled` with `meta.module = + \"inboundDocuments\"` — the module is off for this record. `ai.record.notPermitted` — AI work is not + admitted inside this record (a delegate acting in somebody else's record). `consent.ai.required` — the + provider this request would reach leaves the machine and the record holds no active `ai_extraction` or + `ai_full` receipt; only a self-hosted model is exempt. The question is answered for the provider actually + picked, so it can differ from the published capability when a text-mode read takes a different provider than + a vision read would. The route also answers under `coach`, with the same envelope and that capability's own + codes (`assistant.disabled.coach`, `module.disabled` with `meta.module = \"coach\"` when the Coach is off + for the record). A 422 (`stdResponses`) covers an un-indexed / unavailable attachment + (`coach.fenced.attachmentUnavailable`), the attachment cap (`coach.fenced.attachmentLimit`), or + `attachmentIds` sent with a `conversationId` (`coach.fenced.attachmentConflict`)." content: application/json: schema: @@ -10469,17 +10650,19 @@ paths: $ref: "#/components/schemas/CoachAttachmentsResponse" "401": *a1 "403": - description: "AI refusal: the `documentAi` capability is unavailable for this record. The envelope carries - `meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). - `assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master - switch). `module.disabled` with `meta.module = \"inboundDocuments\"` — the module is off for this record. - `ai.record.notPermitted` — AI work is not admitted inside this record (a delegate acting in somebody else's - record). `consent.ai.required` — the provider this request would reach leaves the machine and the record - holds no active `ai_extraction` or `ai_full` receipt; only a self-hosted model is exempt. The question is - answered for the provider actually picked, so it can differ from the published capability when a text-mode - read takes a different provider than a vision read would. The route also answers under `coach`, with the - same envelope and that capability's own codes (`assistant.disabled.coach`, `module.disabled` with - `meta.module = \"coach\"` when the Coach is off for the record)." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: AI refusal: the `documentAi` + capability is unavailable for this record. The envelope carries `meta.errorCode`, `meta.capability` and + `meta.reason` (one of `AiUnavailableReason`). `assistant.disabled.documentAi` — the operator turned off + reading documents, or every AI feature (the master switch). `module.disabled` with `meta.module = + \"inboundDocuments\"` — the module is off for this record. `ai.record.notPermitted` — AI work is not + admitted inside this record (a delegate acting in somebody else's record). `consent.ai.required` — the + provider this request would reach leaves the machine and the record holds no active `ai_extraction` or + `ai_full` receipt; only a self-hosted model is exempt. The question is answered for the provider actually + picked, so it can differ from the published capability when a text-mode read takes a different provider than + a vision read would. The route also answers under `coach`, with the same envelope and that capability's own + codes (`assistant.disabled.coach`, `module.disabled` with `meta.module = \"coach\"` when the Coach is off + for the record)." content: application/json: schema: @@ -10529,6 +10712,7 @@ paths: schema: $ref: "#/components/schemas/CoachAttachmentsResponseDetach" "401": *a1 + "403": *a9 "404": description: Attachment or conversation not found / not owned. content: @@ -10553,6 +10737,7 @@ paths: schema: $ref: "#/components/schemas/InsightsLayout" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -10577,6 +10762,7 @@ paths: schema: $ref: "#/components/schemas/InsightsLayoutSaved" "401": *a1 + "403": *a9 "409": description: Insights layout 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. @@ -10585,7 +10771,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a15 + "422": *a16 "429": *a5 delete: tags: @@ -10600,6 +10786,7 @@ paths: schema: $ref: "#/components/schemas/InsightsLayoutReset" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/insights/coach/facts: @@ -10621,6 +10808,7 @@ paths: schema: $ref: "#/components/schemas/CoachFactsList" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 post: @@ -10653,9 +10841,11 @@ paths: $ref: "#/components/schemas/CoachFactCreated" "401": *a1 "403": - 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.' + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: 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." content: application/json: schema: @@ -10682,6 +10872,7 @@ paths: schema: $ref: "#/components/schemas/CoachFactsCleared" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/insights/coach/facts/{id}: @@ -10715,6 +10906,7 @@ paths: schema: $ref: "#/components/schemas/CoachFactUpdated" "401": *a1 + "403": *a9 "404": description: Fact not found or not owned by the caller. content: @@ -10738,6 +10930,7 @@ paths: schema: $ref: "#/components/schemas/CoachFactDeleted" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/insights/coach/nudge-status: @@ -10760,7 +10953,7 @@ paths: schema: $ref: "#/components/schemas/CoachNudgeStatus" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/coach/seen: @@ -10781,6 +10974,7 @@ paths: schema: $ref: "#/components/schemas/CoachSeenResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/coach/about-me: @@ -10803,6 +10997,7 @@ paths: schema: $ref: "#/components/schemas/GetCoachAboutMeResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -10870,6 +11065,7 @@ paths: schema: $ref: "#/components/schemas/PutCoachAboutMeResponse" "401": *a1 + "403": *a9 "409": description: Self-context 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. @@ -10878,7 +11074,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a15 + "422": *a16 "429": *a5 /api/anamnesis/facts: get: @@ -10897,7 +11093,7 @@ paths: schema: $ref: "#/components/schemas/GetAnamnesisFactsResponse" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -11315,6 +11511,7 @@ paths: schema: $ref: "#/components/schemas/CreateAnamnesisFactResponse" "401": *a1 + "403": *a9 "409": description: A current revision already exists for this fact kind. `meta.errorCode` = `anamnesis.fact.currentExists`. content: @@ -11370,7 +11567,8 @@ paths: schema: $ref: "#/components/schemas/CorrectAnamnesisFactResponse" "401": *a1 - "404": &a17 + "403": *a9 + "404": &a18 description: No current revision with this id exists for the authenticated account. content: application/json: @@ -11406,7 +11604,8 @@ paths: schema: $ref: "#/components/schemas/RemoveAnamnesisFactResponse" "401": *a1 - "404": *a17 + "403": *a9 + "404": *a18 "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. @@ -11433,6 +11632,7 @@ paths: schema: $ref: "#/components/schemas/GetEmergencyProfileResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -11455,6 +11655,7 @@ paths: schema: $ref: "#/components/schemas/UpdateEmergencyProfileResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/coach/about-me/questions: @@ -11473,6 +11674,7 @@ paths: schema: $ref: "#/components/schemas/GetCoachAboutMeQuestionsResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 delete: @@ -11499,6 +11701,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCoachAboutMeQuestionsResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/coach/about-me/adopt: @@ -11548,9 +11751,11 @@ paths: $ref: "#/components/schemas/CoachAboutMeAdoptResponse" "401": *a1 "403": - 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.' + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: 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." content: application/json: schema: @@ -11623,6 +11828,7 @@ paths: schema: $ref: "#/components/schemas/CoachPlansList" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/coach/plans/{id}: @@ -11657,7 +11863,8 @@ paths: $ref: "#/components/schemas/CoachPlanUpdated" "401": *a1 "403": - description: Coach surface disabled. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Coach surface disabled." content: application/json: schema: @@ -11694,7 +11901,8 @@ paths: $ref: "#/components/schemas/CoachPlanDeleted" "401": *a1 "403": - description: Coach surface disabled. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Coach surface disabled." content: application/json: schema: @@ -11733,7 +11941,9 @@ paths: $ref: "#/components/schemas/CoachReminderSuggestionResultCreated" "401": *a1 "403": - description: Coach surface (or the cadence's module) disabled. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Coach surface (or the cadence's + module) disabled." content: application/json: schema: @@ -11766,7 +11976,7 @@ paths: schema: $ref: "#/components/schemas/CoachRemindersList" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -11792,7 +12002,8 @@ paths: $ref: "#/components/schemas/CoachReminderCreated" "401": *a1 "403": - description: Coach surface disabled. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Coach surface disabled." content: application/json: schema: @@ -11836,7 +12047,8 @@ paths: $ref: "#/components/schemas/CoachReminderUpdated" "401": *a1 "403": - description: Coach surface disabled. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Coach surface disabled." content: application/json: schema: @@ -11872,6 +12084,7 @@ paths: schema: $ref: "#/components/schemas/CoachReminderDeleted" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/coach/suggested-actions: @@ -11900,7 +12113,8 @@ paths: $ref: "#/components/schemas/CoachSuggestedActionResultCreated" "401": *a1 "403": - description: Coach surface disabled. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Coach surface disabled." content: application/json: schema: @@ -11930,6 +12144,12 @@ paths: schema: $ref: "#/components/schemas/AdminInviteList" "401": *a1 + "403": &a19 + description: The session is valid but its account is not an administrator. `meta.errorCode` = `auth.admin.required`. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 post: @@ -11965,6 +12185,7 @@ paths: schema: $ref: "#/components/schemas/AdminInviteCreated" "401": *a1 + "403": *a19 "422": *a4 "429": *a5 /api/admin/invites/{id}: @@ -11989,6 +12210,7 @@ paths: schema: $ref: "#/components/schemas/AdminInviteRevoked" "401": *a1 + "403": *a19 "422": *a4 "429": *a5 /api/insights/chat/messages/{id}/feedback: @@ -12013,6 +12235,7 @@ paths: schema: $ref: "#/components/schemas/CoachMessageFeedbackResponse" "401": *a1 + "403": *a9 "404": description: Message not found or not owned by the caller. content: @@ -12043,6 +12266,7 @@ paths: schema: $ref: "#/components/schemas/AdminMeasurementMaintenanceStatusResponse" "401": *a1 + "403": *a19 "422": *a4 "429": *a5 post: @@ -12068,7 +12292,8 @@ paths: $ref: "#/components/schemas/AdminMeasurementMaintenanceResponse" "401": *a1 "403": - description: Caller is not an admin. + description: "`meta.errorCode` = `auth.admin.required` when the session's account is not an administrator; the admin + gate answers it before the route's own checks run. Otherwise: Caller is not an admin." content: application/json: schema: @@ -12118,7 +12343,8 @@ paths: $ref: "#/components/schemas/AdminReminderCheckResponse" "401": *a1 "403": - description: Caller is not an admin. + description: "`meta.errorCode` = `auth.admin.required` when the session's account is not an administrator; the admin + gate answers it before the route's own checks run. Otherwise: Caller is not an admin." content: application/json: schema: @@ -12142,7 +12368,8 @@ paths: $ref: "#/components/schemas/AdminDiagnosticResponse" "401": *a1 "403": - description: Caller is not an admin. + description: "`meta.errorCode` = `auth.admin.required` when the session's account is not an administrator; the admin + gate answers it before the route's own checks run. Otherwise: Caller is not an admin." content: application/json: schema: @@ -12169,7 +12396,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSnapshotResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/comprehensive: @@ -12191,7 +12418,7 @@ paths: schema: $ref: "#/components/schemas/InsightsComprehensiveResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/cards: @@ -12212,7 +12439,7 @@ paths: schema: $ref: "#/components/schemas/InsightsCardsEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/pregenerate: @@ -12244,6 +12471,7 @@ paths: schema: $ref: "#/components/schemas/InsightsPregenerateResponseEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/analytics: @@ -12294,6 +12522,7 @@ paths: schema: $ref: "#/components/schemas/AnalyticsEnvelope" "401": *a1 + "403": *a9 "422": description: "`slice` carried a value outside the closed set. `meta.errorCode` = `analytics.invalid_query`, with the offending path under `details.issues`." @@ -12329,12 +12558,13 @@ paths: $ref: "#/components/schemas/InsightsGenerateReadEnvelope" "401": *a1 "403": - description: "The `briefing` AI capability is unavailable for this record. `meta.capability` = `briefing` and - `meta.reason` says which layer refused: the operator's switch (`assistant.disabled.briefing`, or - `assistant.disabled.enabled` for the master switch), an owning module or the person's own AI opt-out - (`module.disabled` with `meta.module`), a delegate inside somebody else's record (`ai.record.notPermitted`), - or a missing consent receipt (`consent.ai.required`). Nothing was generated, read from a stored model - answer, or enqueued." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The `briefing` AI capability is + unavailable for this record. `meta.capability` = `briefing` and `meta.reason` says which layer refused: the + operator's switch (`assistant.disabled.briefing`, or `assistant.disabled.enabled` for the master switch), an + owning module or the person's own AI opt-out (`module.disabled` with `meta.module`), a delegate inside + somebody else's record (`ai.record.notPermitted`), or a missing consent receipt (`consent.ai.required`). + Nothing was generated, read from a stored model answer, or enqueued." content: application/json: schema: @@ -12347,7 +12577,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "503": *a18 + "503": *a20 post: tags: - Insights @@ -12382,12 +12612,13 @@ paths: $ref: "#/components/schemas/InsightsGeneratePostEnvelope" "401": *a1 "403": - description: "The `briefing` AI capability is unavailable for this record. `meta.capability` = `briefing` and - `meta.reason` says which layer refused: the operator's switch (`assistant.disabled.briefing`, or - `assistant.disabled.enabled` for the master switch), an owning module or the person's own AI opt-out - (`module.disabled` with `meta.module`), a delegate inside somebody else's record (`ai.record.notPermitted`), - or a missing consent receipt (`consent.ai.required`). Nothing was generated, read from a stored model - answer, or enqueued." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The `briefing` AI capability is + unavailable for this record. `meta.capability` = `briefing` and `meta.reason` says which layer refused: the + operator's switch (`assistant.disabled.briefing`, or `assistant.disabled.enabled` for the master switch), an + owning module or the person's own AI opt-out (`module.disabled` with `meta.module`), a delegate inside + somebody else's record (`ai.record.notPermitted`), or a missing consent receipt (`consent.ai.required`). + Nothing was generated, read from a stored model answer, or enqueued." content: application/json: schema: @@ -12445,6 +12676,7 @@ paths: schema: $ref: "#/components/schemas/ProviderChainEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -12471,6 +12703,7 @@ paths: schema: $ref: "#/components/schemas/ProviderChainSavedEnvelope" "401": *a1 + "403": *a9 "422": description: The body was not a valid chain — an empty array, more entries than there are provider types, an unknown provider type, or the same type twice. A single-message envelope with no issue list. @@ -12506,6 +12739,7 @@ paths: schema: $ref: "#/components/schemas/InsightsFeedbackCreatedEnvelope" "401": *a1 + "403": *a9 "409": *a7 "422": description: The body failed validation. A single-message envelope (`Invalid feedback payload`) with no issue list — @@ -12549,7 +12783,7 @@ paths: schema: $ref: "#/components/schemas/CoachReadStripEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/narrative: @@ -12598,7 +12832,7 @@ paths: schema: $ref: "#/components/schemas/NarrativeEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/glp1-timeline: @@ -12635,7 +12869,7 @@ paths: schema: $ref: "#/components/schemas/Glp1TimelineEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/pulse/intraday: @@ -12671,7 +12905,7 @@ paths: schema: $ref: "#/components/schemas/IntradayPulseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/settings: @@ -12690,6 +12924,7 @@ paths: schema: $ref: "#/components/schemas/InsightsSettingsEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -12715,6 +12950,7 @@ paths: schema: $ref: "#/components/schemas/InsightsSettingsUpdatedEnvelope" "401": *a1 + "403": *a9 "422": description: "`Invalid privacy mode` when `privacyMode` was a string outside the two accepted values, or `No changes` when the body carried nothing this endpoint writes. Single-message envelope with no issue list — this route @@ -12745,7 +12981,7 @@ paths: schema: $ref: "#/components/schemas/TargetsEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/analytics/range: @@ -12787,6 +13023,7 @@ paths: schema: $ref: "#/components/schemas/AnalyticsRangeResponseEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/insights/blood-pressure-status: @@ -12818,7 +13055,7 @@ paths: schema: $ref: "#/components/schemas/BloodPressureStatusResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/pulse-status: @@ -12850,7 +13087,7 @@ paths: schema: $ref: "#/components/schemas/PulseStatusResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/weight-status: @@ -12882,7 +13119,7 @@ paths: schema: $ref: "#/components/schemas/WeightStatusResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/bmi-status: @@ -12914,7 +13151,7 @@ paths: schema: $ref: "#/components/schemas/BmiStatusResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/mood-status: @@ -12946,7 +13183,7 @@ paths: schema: $ref: "#/components/schemas/MoodStatusResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/medication-compliance-status: @@ -12979,7 +13216,7 @@ paths: schema: $ref: "#/components/schemas/MedicationComplianceStatusResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/metric-status: @@ -13074,7 +13311,7 @@ paths: schema: $ref: "#/components/schemas/MetricStatusResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/biomarker-assessment: @@ -13119,7 +13356,7 @@ paths: schema: $ref: "#/components/schemas/BiomarkerAssessmentResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/derived: @@ -13231,7 +13468,7 @@ paths: schema: $ref: "#/components/schemas/DerivedMetricResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/derived/batch: @@ -13271,7 +13508,7 @@ paths: schema: $ref: "#/components/schemas/DerivedBatchResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/score-history: @@ -13321,7 +13558,7 @@ paths: schema: $ref: "#/components/schemas/ScoreHistoryResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/glp1-plateau: @@ -13341,7 +13578,7 @@ paths: schema: $ref: "#/components/schemas/InsightsGlp1PlateauResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/ecg: @@ -13363,7 +13600,7 @@ paths: schema: $ref: "#/components/schemas/EcgListResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -13401,6 +13638,7 @@ paths: schema: $ref: "#/components/schemas/EcgIngestCreatedEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/insights/ecg/{id}: @@ -13440,7 +13678,7 @@ paths: schema: $ref: "#/components/schemas/EcgDetailResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: No ECG recording with that id for the authenticated user (existence sealed — a foreign id is indistinguishable from a missing one). @@ -13472,7 +13710,7 @@ paths: schema: $ref: "#/components/schemas/RhythmEventsResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/correlations: @@ -13494,7 +13732,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationDiscoveryResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/patterns: @@ -13512,7 +13750,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationPatternListResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/patterns/{id}: @@ -13542,7 +13780,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCorrelationPatternResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: No current pattern with that id for this account. content: @@ -13567,7 +13805,7 @@ paths: schema: $ref: "#/components/schemas/InsightsHealthStatusEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/breathing-screening: @@ -13586,7 +13824,7 @@ paths: schema: $ref: "#/components/schemas/InsightsBreathingScreeningEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/labs-changes: @@ -13605,7 +13843,7 @@ paths: schema: $ref: "#/components/schemas/InsightsLabsChangesEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/mood/analytics: @@ -13628,7 +13866,7 @@ paths: schema: $ref: "#/components/schemas/MoodDailySeriesEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "422": *a4 "429": *a5 /api/mood/insights: @@ -13663,7 +13901,7 @@ paths: schema: $ref: "#/components/schemas/MoodAggregatesEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "422": *a4 "429": *a5 /api/mood/prognosis: @@ -13686,7 +13924,7 @@ paths: schema: $ref: "#/components/schemas/MoodPrognosisEnvelope" "401": *a1 - "403": *a13 + "403": *a14 "422": *a4 "429": *a5 /api/mood-entries/bulk: @@ -13715,6 +13953,7 @@ paths: schema: $ref: "#/components/schemas/BulkMoodResponseEnvelope" "401": *a1 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -13743,6 +13982,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagTreeEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/mood/tags/custom: @@ -13767,6 +14007,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagCreatedEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/mood/tags/custom/{key}: @@ -13796,7 +14037,8 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" "401": *a1 - "404": &a20 + "403": *a9 + "404": &a22 description: Not found / not owned by the caller. content: application/json: @@ -13831,7 +14073,8 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" "401": *a1 - "404": *a20 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/mood/tags/{key}/hidden: @@ -13867,7 +14110,8 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "404": *a20 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/mood/tags/groups: @@ -13891,6 +14135,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupCreatedEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/mood/tags/groups/{key}: @@ -13920,7 +14165,8 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" "401": *a1 - "404": *a20 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 delete: @@ -13951,7 +14197,8 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" "401": *a1 - "404": *a20 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/mood/tags/layout: @@ -13969,6 +14216,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagLayoutGetEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -13991,6 +14239,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagLayoutPutEnvelope" "401": *a1 + "403": *a9 "409": description: Mood-tag layout 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. @@ -13999,7 +14248,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a15 + "422": *a16 "429": *a5 /api/mood-entries: get: @@ -14083,7 +14332,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntryListEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -14116,8 +14365,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a11 - "409": &a21 + "403": *a12 + "409": &a23 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. @@ -14126,7 +14375,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a12 + "429": *a13 /api/mood-entries/{id}: get: tags: @@ -14148,8 +14397,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a11 - "404": *a20 + "403": *a12 + "404": *a22 "422": *a4 "429": *a5 put: @@ -14183,9 +14432,9 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a11 - "404": *a20 - "409": *a21 + "403": *a12 + "404": *a22 + "409": *a23 "422": *a4 "429": *a5 delete: @@ -14210,8 +14459,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryDeleteEnvelope" "401": *a1 - "403": *a11 - "404": *a20 + "403": *a12 + "404": *a22 "422": *a4 "429": *a5 /api/mood-entries/restore: @@ -14240,7 +14489,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesRestoreEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -14269,7 +14518,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesBulkDeleteEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -14313,7 +14562,7 @@ paths: schema: $ref: "#/components/schemas/MoodLinkedContextEnvelope" "401": *a1 - "403": *a13 + "403": *a14 "422": *a4 "429": *a5 /api/settings/ntfy: @@ -14332,6 +14581,7 @@ paths: schema: $ref: "#/components/schemas/GetNtfySettings" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -14361,7 +14611,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutNtfySettingsResponse" - "400": &a22 + "400": &a24 description: Body is not parseable JSON. content: application/json: @@ -14369,18 +14619,20 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": - description: The operator switched ntfy off for this instance and the body asked to enable it. Nothing was stored. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator switched ntfy off + for this instance and the body asked to enable it. Nothing was stored." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": &a23 + "413": &a25 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a24 + "415": &a26 description: Content-Type is not `application/json`. content: application/json: @@ -14411,6 +14663,7 @@ paths: schema: $ref: "#/components/schemas/GetTelegramSettings" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -14440,11 +14693,12 @@ paths: application/json: schema: $ref: "#/components/schemas/PutTelegramSettingsResponse" - "400": *a22 + "400": *a24 "401": *a1 "403": - description: The operator switched Telegram off for this instance and the body asked to enable it. No webhook was - registered and nothing was stored. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator switched Telegram + off for this instance and the body asked to enable it. No webhook was registered and nothing was stored." content: application/json: schema: @@ -14455,8 +14709,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": *a23 - "415": *a24 + "413": *a25 + "415": *a26 "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 @@ -14489,6 +14743,7 @@ paths: schema: $ref: "#/components/schemas/GetWebhookSettings" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -14510,10 +14765,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PutWebhookSettingsResponse" - "400": *a22 + "400": *a24 "401": *a1 - "413": *a23 - "415": *a24 + "403": *a9 + "413": *a25 + "415": *a26 "422": description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = @@ -14540,7 +14796,7 @@ paths: schema: $ref: "#/components/schemas/GetReminderThresholdsResponse" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/settings/global-services: @@ -14564,6 +14820,7 @@ paths: schema: $ref: "#/components/schemas/GlobalServiceAvailabilityEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/settings/account: @@ -14628,8 +14885,10 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "403": description: >- - The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal - happens before any second-factor proof is looked at. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The Bearer token carries a narrow + scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor + proof is looked at. Refused: the request was made while the session is acting on another account (`meta.errorCode` = @@ -14677,6 +14936,7 @@ paths: schema: $ref: "#/components/schemas/GetEmailChannelEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -14711,6 +14971,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/settings/email/test: @@ -14734,6 +14995,7 @@ paths: schema: $ref: "#/components/schemas/EmailTestEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 "500": @@ -14771,6 +15033,7 @@ paths: schema: $ref: "#/components/schemas/NtfyTestEnvelope" "401": *a1 + "403": *a9 "422": description: The saved server is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a link-local, metadata or unspecified address that no grant can open @@ -14820,6 +15083,7 @@ paths: schema: $ref: "#/components/schemas/TelegramTestEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/settings/webhook/test: @@ -14842,6 +15106,7 @@ paths: schema: $ref: "#/components/schemas/WebhookTestEnvelope" "401": *a1 + "403": *a9 "422": description: The saved URL is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a link-local, metadata or unspecified address that no grant can open @@ -14933,8 +15198,10 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "403": description: >- - The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal - happens before any second-factor proof is looked at. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The Bearer token carries a narrow + scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor + proof is looked at. Refused: the request was made while the session is acting on another account (`meta.errorCode` = @@ -14976,6 +15243,7 @@ paths: schema: $ref: "#/components/schemas/ModuleListLayoutEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -14998,6 +15266,7 @@ paths: schema: $ref: "#/components/schemas/ModuleListLayoutSavedEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/settings/preview-features: @@ -15014,6 +15283,7 @@ paths: schema: $ref: "#/components/schemas/PreviewFeaturesEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -15035,6 +15305,7 @@ paths: schema: $ref: "#/components/schemas/PreviewFeaturesSavedEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/settings/privacy-summary: @@ -15053,6 +15324,7 @@ paths: schema: $ref: "#/components/schemas/PrivacySummaryEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/consent/ai: @@ -15091,6 +15363,7 @@ paths: schema: $ref: "#/components/schemas/ConsentValidationErrorEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/consent/ai/web: @@ -15123,6 +15396,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/consent/ai/latest: @@ -15160,7 +15434,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentLatestForKindEnvelope" - $ref: "#/components/schemas/ConsentLatestByKindEnvelope" - "400": &a25 + "400": &a27 description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`, `ai_extraction`. Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422." @@ -15169,6 +15443,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 delete: @@ -15220,8 +15495,9 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentRevokeForKindEnvelope" - $ref: "#/components/schemas/ConsentRevokeAllEnvelope" - "400": *a25 + "400": *a27 "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/import/health-connect-export: @@ -15260,6 +15536,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "409": description: An import for this account (Health Connect or Apple Health) is already queued or running. The upload was discarded. `errorCode` is `import.apple_health.busy` for either kind, `jobId` names the running job. @@ -15302,6 +15579,7 @@ paths: schema: $ref: "#/components/schemas/HealthConnectImportStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/import/csv: @@ -15354,6 +15632,7 @@ paths: schema: $ref: "#/components/schemas/CsvImportResponseEnvelope" "401": *a1 + "403": *a9 "413": description: CSV exceeds the 16 MB limit. content: @@ -15549,7 +15828,9 @@ paths: $ref: "#/components/schemas/LabsCsvImportEnvelope" "401": *a1 "403": - description: "`module.disabled`: the module the import writes to is switched off." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: `module.disabled`: the module the + import writes to is switched off." content: application/json: schema: @@ -15739,7 +16020,9 @@ paths: $ref: "#/components/schemas/MedicationsCsvImportEnvelope" "401": *a1 "403": - description: "`module.disabled`: the module the import writes to is switched off." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: `module.disabled`: the module the + import writes to is switched off." content: application/json: schema: @@ -15787,6 +16070,7 @@ paths: schema: $ref: "#/components/schemas/JsonImportStatsEnvelope" "401": *a1 + "403": *a9 "413": description: Body exceeds the 16 MB limit. content: @@ -15856,6 +16140,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "409": description: An import for this account is already queued or running, and these bytes are not the ones it carries (the same bytes resolve to that job as a 202). `meta.errorCode` is `import.apple_health.busy` and `meta.jobId` @@ -15924,6 +16209,7 @@ paths: schema: $ref: "#/components/schemas/AppleHealthImportStatusEnvelope" "401": *a1 + "403": *a9 "404": description: No such job, or one the caller may not see. The two are indistinguishable on purpose. content: @@ -15952,7 +16238,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderListEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -15977,7 +16263,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCreatedEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -16001,8 +16287,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 - "403": *a11 - "404": &a26 + "403": *a12 + "404": &a28 description: Measurement reminder not found / not owned. content: application/json: @@ -16039,8 +16325,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 - "403": *a11 - "404": *a26 + "403": *a12 + "404": *a28 "422": *a4 "429": *a5 delete: @@ -16062,8 +16348,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 - "403": *a11 - "404": *a26 + "403": *a12 + "404": *a28 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/satisfy: @@ -16087,8 +16373,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 - "403": *a11 - "404": *a26 + "403": *a12 + "404": *a28 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/complete: @@ -16115,8 +16401,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" "401": *a1 - "403": *a11 - "404": *a26 + "403": *a12 + "404": *a28 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/skip: @@ -16145,8 +16431,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSkipEnvelope" "401": *a1 - "403": *a11 - "404": *a26 + "403": *a12 + "404": *a28 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/snooze: @@ -16179,8 +16465,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSnoozeEnvelope" "401": *a1 - "403": *a11 - "404": *a26 + "403": *a12 + "404": *a28 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/history: @@ -16220,8 +16506,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderHistoryEnvelope" "401": *a1 - "403": *a11 - "404": *a26 + "403": *a12 + "404": *a28 "422": *a4 "429": *a5 /api/labs: @@ -16299,7 +16585,7 @@ paths: schema: $ref: "#/components/schemas/ListLabResultsEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -16326,7 +16612,7 @@ paths: schema: $ref: "#/components/schemas/CreateLabResultResponse" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": description: "Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue @@ -16341,7 +16627,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a12 + "429": *a13 /api/labs/overview: get: tags: @@ -16359,7 +16645,7 @@ paths: schema: $ref: "#/components/schemas/LabOverviewEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/labs/{id}: @@ -16382,8 +16668,8 @@ paths: schema: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 - "403": *a11 - "404": &a27 + "403": *a12 + "404": &a29 description: Lab result not found (or owned by another user). content: application/json: @@ -16417,8 +16703,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 - "403": *a11 - "404": *a27 + "403": *a12 + "404": *a29 "422": *a4 "429": *a5 delete: @@ -16440,8 +16726,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 - "403": *a11 - "404": *a27 + "403": *a12 + "404": *a29 "422": *a4 "429": *a5 /api/labs/restore: @@ -16467,7 +16753,7 @@ paths: schema: $ref: "#/components/schemas/RestoreLabResultsResponse" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -16486,6 +16772,7 @@ paths: schema: $ref: "#/components/schemas/OcrCapabilityEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/labs/ocr/extract: @@ -16528,7 +16815,7 @@ paths: schema: $ref: "#/components/schemas/OcrTextExtractRequest" responses: - "202": &a37 + "202": &a39 description: Queued for the background worker. Every refusal the route can answer quickly (capability, provider, rate limit, budget, validation) has already been answered; anything that goes wrong from here is reported on the run. @@ -16552,15 +16839,16 @@ paths: $ref: "#/components/schemas/AiRunAcceptedEnvelope" "401": *a1 "403": - description: "AI refusal: the `labsOcr` capability is unavailable for this record. The envelope carries - `meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). - `assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master - switch). `module.disabled` with `meta.module = \"labs\"` — the module is off for this record. - `ai.record.notPermitted` — AI work is not admitted inside this record (a delegate acting in somebody else's - record). `consent.ai.required` — the provider this request would reach leaves the machine and the record - holds no active `ai_extraction` or `ai_full` receipt; only a self-hosted model is exempt. The question is - answered for the provider actually picked, so it can differ from the published capability when a text-mode - read takes a different provider than a vision read would." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: AI refusal: the `labsOcr` + capability is unavailable for this record. The envelope carries `meta.errorCode`, `meta.capability` and + `meta.reason` (one of `AiUnavailableReason`). `assistant.disabled.documentAi` — the operator turned off + reading documents, or every AI feature (the master switch). `module.disabled` with `meta.module = \"labs\"` + — the module is off for this record. `ai.record.notPermitted` — AI work is not admitted inside this record + (a delegate acting in somebody else's record). `consent.ai.required` — the provider this request would reach + leaves the machine and the record holds no active `ai_extraction` or `ai_full` receipt; only a self-hosted + model is exempt. The question is answered for the provider actually picked, so it can differ from the + published capability when a text-mode read takes a different provider than a vision read would." content: application/json: schema: @@ -16599,6 +16887,7 @@ paths: schema: $ref: "#/components/schemas/OcrCommitEnvelope" "401": *a1 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -16626,6 +16915,7 @@ paths: schema: $ref: "#/components/schemas/AiRunEnvelope" "401": *a1 + "403": *a9 "404": description: "`aiRuns.notFound`." content: @@ -16649,7 +16939,7 @@ paths: schema: $ref: "#/components/schemas/ListBiomarkersEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -16674,7 +16964,7 @@ paths: schema: $ref: "#/components/schemas/CreateBiomarkerResponse" "401": *a1 - "403": *a11 + "403": *a12 "409": description: A biomarker with this name already exists for the caller. content: @@ -16682,7 +16972,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a12 + "429": *a13 /api/biomarkers/{id}: get: tags: @@ -16705,8 +16995,8 @@ paths: schema: $ref: "#/components/schemas/GetBiomarkerResponse" "401": *a1 - "403": *a11 - "404": &a28 + "403": *a12 + "404": &a30 description: "Biomarker not found (or owned by another user). `meta.errorCode` = `labs.biomarker.notFound`. Since v1.43 a marker that was merged into another answers with `meta.mergedInto` naming the marker it went into: drop the cached marker and open that one." @@ -16747,8 +17037,8 @@ paths: schema: $ref: "#/components/schemas/UpdateBiomarkerResponse" "401": *a1 - "403": *a11 - "404": *a28 + "403": *a12 + "404": *a30 "409": description: "`labs.biomarker.duplicate`: a rename collides with another of the caller's markers. `labs.biomarker.alias.taken` (v1.43): one of `aliases` is already the name or an alias of another marker; @@ -16781,8 +17071,8 @@ paths: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 - "403": *a11 - "404": *a28 + "403": *a12 + "404": *a30 "422": *a4 "429": *a5 /api/biomarkers/{id}/merge: @@ -16819,8 +17109,8 @@ paths: schema: $ref: "#/components/schemas/MergeBiomarkersResponse" "401": *a1 - "403": *a11 - "404": *a28 + "403": *a12 + "404": *a30 "409": *a7 "422": description: "`labs.biomarker.merge.invalid`: a malformed body, or the target among the sources (Zod issues in @@ -16831,7 +17121,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a12 + "429": *a13 /api/encounters: get: tags: @@ -16894,7 +17184,7 @@ paths: schema: $ref: "#/components/schemas/ListEncountersEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -16920,10 +17210,10 @@ paths: schema: $ref: "#/components/schemas/CreateEncounterEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 - "429": *a12 + "429": *a13 /api/encounters/procedures: get: tags: @@ -16954,7 +17244,7 @@ paths: schema: $ref: "#/components/schemas/ListProceduresEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/body-sites: @@ -16991,7 +17281,7 @@ paths: schema: $ref: "#/components/schemas/ListBodySitesEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/encounters/suggest: @@ -17021,7 +17311,7 @@ paths: schema: $ref: "#/components/schemas/SuggestEncounterEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/encounters/{id}: @@ -17044,8 +17334,8 @@ paths: schema: $ref: "#/components/schemas/GetEncounterEnvelope" "401": *a1 - "403": *a11 - "404": &a29 + "403": *a12 + "404": &a31 description: Visit not found (or owned by another user). content: application/json: @@ -17078,8 +17368,8 @@ paths: schema: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 - "403": *a11 - "404": *a29 + "403": *a12 + "404": *a31 "422": *a4 "429": *a5 delete: @@ -17103,8 +17393,8 @@ paths: schema: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 - "403": *a11 - "404": *a29 + "403": *a12 + "404": *a31 "422": *a4 "429": *a5 /api/encounters/{id}/restore: @@ -17129,8 +17419,8 @@ paths: schema: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 - "403": *a11 - "404": *a29 + "403": *a12 + "404": *a31 "422": *a4 "429": *a5 /api/encounters/{id}/links: @@ -17161,7 +17451,8 @@ paths: schema: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 - "404": *a29 + "403": *a9 + "404": *a31 "422": *a4 "429": *a5 delete: @@ -17190,7 +17481,8 @@ paths: schema: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 - "404": *a29 + "403": *a9 + "404": *a31 "422": *a4 "429": *a5 /api/practitioners: @@ -17230,7 +17522,7 @@ paths: schema: $ref: "#/components/schemas/ListPractitionersEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -17254,7 +17546,7 @@ paths: schema: $ref: "#/components/schemas/CreatePractitionerEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -17278,8 +17570,8 @@ paths: schema: $ref: "#/components/schemas/GetPractitionerEnvelope" "401": *a1 - "403": *a11 - "404": &a30 + "403": *a12 + "404": &a32 description: Practitioner not found (or owned by another user). content: application/json: @@ -17312,8 +17604,8 @@ paths: schema: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 - "403": *a11 - "404": *a30 + "403": *a12 + "404": *a32 "422": *a4 "429": *a5 delete: @@ -17337,8 +17629,8 @@ paths: schema: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 - "403": *a11 - "404": *a30 + "403": *a12 + "404": *a32 "422": *a4 "429": *a5 /api/practitioners/{id}/restore: @@ -17363,8 +17655,8 @@ paths: schema: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 - "403": *a11 - "404": *a30 + "403": *a12 + "404": *a32 "422": *a4 "429": *a5 /api/vaccinations/custom: @@ -17382,7 +17674,7 @@ paths: schema: $ref: "#/components/schemas/ListCustomVaccinesEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -17407,8 +17699,8 @@ paths: schema: $ref: "#/components/schemas/CreateCustomVaccineEnvelope" "401": *a1 - "403": *a11 - "409": &a31 + "403": *a12 + "409": &a33 description: A live definition on the record already carries this name, compared without case (`vaccination.custom.name-taken`). content: @@ -17416,7 +17708,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a12 + "429": *a13 /api/vaccinations/custom/{id}: patch: tags: @@ -17444,14 +17736,14 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomVaccineEnvelope" "401": *a1 - "403": *a11 - "404": &a32 + "403": *a12 + "404": &a34 description: No live definition with this id on the record (`vaccination.custom.not-found`). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "409": *a31 + "409": *a33 "422": *a4 "429": *a5 delete: @@ -17475,8 +17767,8 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomVaccineEnvelope" "401": *a1 - "403": *a11 - "404": *a32 + "403": *a12 + "404": *a34 "422": *a4 "429": *a5 /api/vaccinations: @@ -17529,7 +17821,7 @@ paths: schema: $ref: "#/components/schemas/ListVaccinationsEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -17556,7 +17848,7 @@ paths: schema: $ref: "#/components/schemas/CreateVaccinationEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: A practice, visit or own vaccine the body names is not the record's (`vaccination.practitioner-not-found`, `vaccination.encounter-not-found`, `vaccination.custom-vaccine-not-found`). @@ -17566,7 +17858,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 "422": *a4 - "429": *a12 + "429": *a13 /api/vaccinations/{id}: get: tags: @@ -17587,8 +17879,8 @@ paths: schema: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 - "403": *a11 - "404": &a33 + "403": *a12 + "404": &a35 description: Vaccination not found (or owned by another user). content: application/json: @@ -17622,8 +17914,8 @@ paths: schema: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 - "403": *a11 - "404": *a33 + "403": *a12 + "404": *a35 "422": *a4 "429": *a5 delete: @@ -17647,8 +17939,8 @@ paths: schema: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 - "403": *a11 - "404": *a33 + "403": *a12 + "404": *a35 "422": *a4 "429": *a5 /api/vaccinations/{id}/restore: @@ -17672,8 +17964,8 @@ paths: schema: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 - "403": *a11 - "404": *a33 + "403": *a12 + "404": *a35 "422": *a4 "429": *a5 /api/vaccinations/{id}/booster: @@ -17722,8 +18014,10 @@ paths: "401": *a1 "403": description: >- - Refused: the caller's grant does not open this record's measurements, and the booster reminder lives there - (`meta.errorCode` = `vaccination.booster-out-of-scope`). Nothing was written. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Refused: the caller's grant does + not open this record's measurements, and the booster reminder lives there (`meta.errorCode` = + `vaccination.booster-out-of-scope`). Nothing was written. Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). @@ -17739,7 +18033,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": *a33 + "404": *a35 "422": *a4 "429": *a5 /api/vaccinations/suggest: @@ -17767,7 +18061,7 @@ paths: schema: $ref: "#/components/schemas/VaccinationSuggestEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/vaccinations/{id}/links: @@ -17799,7 +18093,8 @@ paths: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 - "404": *a33 + "403": *a9 + "404": *a35 "422": *a4 "429": *a5 delete: @@ -17828,7 +18123,8 @@ paths: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 - "404": *a33 + "403": *a9 + "404": *a35 "422": *a4 "429": *a5 /api/illness/episodes: @@ -17892,7 +18188,7 @@ paths: schema: $ref: "#/components/schemas/ListIllnessEpisodesEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -17918,7 +18214,7 @@ paths: schema: $ref: "#/components/schemas/CreateIllnessEpisodeEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -17942,8 +18238,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 - "403": *a11 - "404": &a34 + "403": *a12 + "404": &a36 description: Illness episode not found (or owned by another user). content: application/json: @@ -17977,8 +18273,8 @@ paths: schema: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 - "403": *a11 - "404": *a34 + "403": *a12 + "404": *a36 "422": *a4 "429": *a5 delete: @@ -18001,8 +18297,8 @@ paths: schema: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 - "403": *a11 - "404": *a34 + "403": *a12 + "404": *a36 "422": *a4 "429": *a5 /api/illness/episodes/{id}/restore: @@ -18029,8 +18325,8 @@ paths: schema: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 - "403": *a11 - "404": *a34 + "403": *a12 + "404": *a36 "409": *a7 "422": *a4 "429": *a5 @@ -18062,8 +18358,8 @@ paths: schema: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 - "403": *a11 - "404": *a34 + "403": *a12 + "404": *a36 "422": *a4 "429": *a5 /api/illness/episodes/{id}/day-logs: @@ -18113,8 +18409,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 - "403": *a11 - "404": *a34 + "403": *a12 + "404": *a36 "422": *a4 "429": *a5 post: @@ -18150,8 +18446,8 @@ paths: schema: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 - "403": *a11 - "404": *a34 + "403": *a12 + "404": *a36 "422": *a4 "429": *a5 /api/illness/episodes/{id}/correlation: @@ -18178,7 +18474,8 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a34 + "403": *a9 + "404": *a36 "422": *a4 "429": *a5 /api/illness/insights: @@ -18212,6 +18509,7 @@ paths: schema: $ref: "#/components/schemas/IllnessInsightsEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/mental-health/assessments: @@ -18251,7 +18549,7 @@ paths: schema: $ref: "#/components/schemas/ListMentalHealthAssessmentsEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -18284,7 +18582,7 @@ paths: schema: $ref: "#/components/schemas/CreateMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -18311,7 +18609,7 @@ paths: schema: $ref: "#/components/schemas/GetMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/allergies: @@ -18344,7 +18642,7 @@ paths: schema: $ref: "#/components/schemas/ListAllergiesEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -18369,10 +18667,10 @@ paths: schema: $ref: "#/components/schemas/CreateAllergyEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 - "429": *a12 + "429": *a13 /api/allergies/{id}: get: tags: @@ -18393,8 +18691,8 @@ paths: schema: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 - "403": *a11 - "404": &a35 + "403": *a12 + "404": &a37 description: Allergy not found (or owned by another user). content: application/json: @@ -18427,8 +18725,8 @@ paths: schema: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 - "403": *a11 - "404": *a35 + "403": *a12 + "404": *a37 "422": *a4 "429": *a5 delete: @@ -18451,8 +18749,8 @@ paths: schema: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 - "403": *a11 - "404": *a35 + "403": *a12 + "404": *a37 "422": *a4 "429": *a5 /api/family-history: @@ -18476,7 +18774,7 @@ paths: schema: $ref: "#/components/schemas/ListFamilyHistoryEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -18501,7 +18799,7 @@ paths: schema: $ref: "#/components/schemas/CreateFamilyHistoryEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -18525,8 +18823,8 @@ paths: schema: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 - "403": *a11 - "404": &a36 + "403": *a12 + "404": &a38 description: Family history entry not found (or owned by another user). content: application/json: @@ -18559,8 +18857,8 @@ paths: schema: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 - "403": *a11 - "404": *a36 + "403": *a12 + "404": *a38 "422": *a4 "429": *a5 delete: @@ -18583,8 +18881,8 @@ paths: schema: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 - "403": *a11 - "404": *a36 + "403": *a12 + "404": *a38 "422": *a4 "429": *a5 /api/documents/inbound: @@ -18699,7 +18997,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentListEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -18866,6 +19164,7 @@ paths: schema: $ref: "#/components/schemas/DocumentUploadEnvelope" "401": *a1 + "403": *a9 "409": description: The same bytes are already stored under 20 other import source ids (`documents.inbound.sourceAliasLimit`). Nothing was stored or remembered. @@ -18935,6 +19234,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceLookupEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/capability: @@ -18960,6 +19260,7 @@ paths: schema: $ref: "#/components/schemas/DocumentAiCapabilityEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/usage: @@ -18982,7 +19283,7 @@ paths: schema: $ref: "#/components/schemas/DocumentUsageEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/documents/inbound/layout: @@ -19000,7 +19301,7 @@ paths: schema: $ref: "#/components/schemas/DocumentsLayoutEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 put: @@ -19023,6 +19324,7 @@ paths: schema: $ref: "#/components/schemas/DocumentsLayoutSavedEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/bulk: @@ -19047,6 +19349,7 @@ paths: schema: $ref: "#/components/schemas/DocumentBulkEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/{id}: @@ -19069,7 +19372,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentDetailEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 patch: @@ -19098,6 +19401,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentUpdateEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 delete: @@ -19120,6 +19424,7 @@ paths: schema: $ref: "#/components/schemas/InboundDiscardEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/{id}/restore: @@ -19144,6 +19449,7 @@ paths: schema: $ref: "#/components/schemas/InboundRestoreEnvelope" "401": *a1 + "403": *a9 "409": description: "Not restorable: purged past the grace window, or an identical live document exists." content: @@ -19188,7 +19494,7 @@ paths: type: string format: binary "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/documents/inbound/{id}/thumbnail: @@ -19217,7 +19523,7 @@ paths: type: string format: binary "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/documents/inbound/{id}/extract: @@ -19249,7 +19555,7 @@ paths: required: true schema: type: string - - &a38 + - &a40 name: Prefer in: header required: false @@ -19274,18 +19580,20 @@ paths: application/json: schema: $ref: "#/components/schemas/InboundExtractEnvelope" - "202": *a37 + "202": *a39 "401": *a1 - "403": &a39 - description: "AI refusal: the `documentAi` capability is unavailable for this record. The envelope carries - `meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). - `assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master - switch). `module.disabled` with `meta.module = \"inboundDocuments\"` — the module is off for this record. - `ai.record.notPermitted` — AI work is not admitted inside this record (a delegate acting in somebody else's - record). `consent.ai.required` — the provider this request would reach leaves the machine and the record - holds no active `ai_extraction` or `ai_full` receipt; only a self-hosted model is exempt. The question is - answered for the provider actually picked, so it can differ from the published capability when a text-mode - read takes a different provider than a vision read would." + "403": &a41 + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: AI refusal: the `documentAi` + capability is unavailable for this record. The envelope carries `meta.errorCode`, `meta.capability` and + `meta.reason` (one of `AiUnavailableReason`). `assistant.disabled.documentAi` — the operator turned off + reading documents, or every AI feature (the master switch). `module.disabled` with `meta.module = + \"inboundDocuments\"` — the module is off for this record. `ai.record.notPermitted` — AI work is not + admitted inside this record (a delegate acting in somebody else's record). `consent.ai.required` — the + provider this request would reach leaves the machine and the record holds no active `ai_extraction` or + `ai_full` receipt; only a self-hosted model is exempt. The question is answered for the provider actually + picked, so it can differ from the published capability when a text-mode read takes a different provider than + a vision read would." content: application/json: schema: @@ -19300,7 +19608,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "503": &a40 + "503": &a42 description: "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. Nothing was sent. Retry later." content: @@ -19330,7 +19638,7 @@ paths: required: true schema: type: string - - *a38 + - *a40 requestBody: required: false content: @@ -19366,12 +19674,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentSuggestEnvelope" - "202": *a37 + "202": *a39 "401": *a1 - "403": *a39 + "403": *a41 "422": *a4 "429": *a5 - "503": *a40 + "503": *a42 /api/documents/inbound/{id}/summary: post: tags: @@ -19421,7 +19729,7 @@ paths: type: boolean default: false description: With `persist=true`, explicitly replace an existing stored summary. - - *a38 + - *a40 requestBody: required: false content: @@ -19457,12 +19765,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentSummaryEnvelope" - "202": *a37 + "202": *a39 "401": *a1 - "403": *a39 + "403": *a41 "422": *a4 "429": *a5 - "503": *a40 + "503": *a42 /api/documents/inbound/{id}/index: post: tags: @@ -19488,7 +19796,7 @@ paths: required: true schema: type: string - - *a38 + - *a40 requestBody: required: false content: @@ -19524,12 +19832,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentIndexEnvelope" - "202": *a37 + "202": *a39 "401": *a1 - "403": *a39 + "403": *a41 "422": *a4 "429": *a5 - "503": *a40 + "503": *a42 /api/documents/inbound/{id}/chat: post: tags: @@ -19569,10 +19877,10 @@ paths: type: string description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." "401": *a1 - "403": *a39 + "403": *a41 "422": *a4 "429": *a5 - "503": *a40 + "503": *a42 get: tags: - Documents @@ -19613,6 +19921,7 @@ paths: schema: $ref: "#/components/schemas/DocumentChatHistoryEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/reindex: @@ -19635,10 +19944,10 @@ paths: schema: $ref: "#/components/schemas/DocumentReindexEnvelope" "401": *a1 - "403": *a39 + "403": *a41 "422": *a4 "429": *a5 - "503": *a40 + "503": *a42 /api/documents/inbound/{id}/facts/{factId}: patch: tags: @@ -19671,6 +19980,7 @@ paths: schema: $ref: "#/components/schemas/ExtractedFactEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/{id}/confirm: @@ -19702,6 +20012,7 @@ paths: schema: $ref: "#/components/schemas/InboundConfirmEnvelope" "401": *a1 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -19720,7 +20031,7 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricsEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -19745,7 +20056,8 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a42 + "403": *a9 + "409": &a44 description: A custom metric with this name already exists for the caller. content: application/json: @@ -19773,8 +20085,8 @@ paths: schema: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 - "403": *a11 - "404": &a41 + "403": *a12 + "404": &a43 description: Custom metric not found (or owned by another user). content: application/json: @@ -19808,8 +20120,9 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a41 - "409": *a42 + "403": *a9 + "404": *a43 + "409": *a44 "422": *a4 "429": *a5 delete: @@ -19832,7 +20145,8 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a41 + "403": *a9 + "404": *a43 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -19875,8 +20189,8 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 - "403": *a11 - "404": *a41 + "403": *a12 + "404": *a43 "422": *a4 "429": *a5 post: @@ -19905,10 +20219,11 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a41 + "403": *a9 + "404": *a43 "409": *a7 "422": *a4 - "429": *a12 + "429": *a13 /api/custom-metrics/{id}/entries/{entryId}: patch: tags: @@ -19941,7 +20256,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a43 + "403": *a9 + "404": &a45 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -19974,7 +20290,8 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a43 + "403": *a9 + "404": *a45 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -20005,6 +20322,7 @@ paths: schema: $ref: "#/components/schemas/RestoreCustomMetricEntriesResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/onboarding/answers: @@ -20045,6 +20363,7 @@ paths: schema: $ref: "#/components/schemas/OnboardingStateEnvelope" "401": *a1 + "403": *a9 "413": description: Body exceeds 16 KiB. content: @@ -20102,6 +20421,7 @@ paths: schema: $ref: "#/components/schemas/OnboardingStateEnvelope" "401": *a1 + "403": *a9 "413": description: Body exceeds 4 KiB. content: @@ -20183,6 +20503,7 @@ paths: schema: $ref: "#/components/schemas/OnboardingCompleteEnvelope" "401": *a1 + "403": *a9 "404": description: "`managedRecordId` names no managed profile the caller actively guards — missing, not a managed record, or not theirs; one answer for all three. `meta.errorCode` = `onboarding.complete.recordNotFound`. Nothing was @@ -20235,6 +20556,7 @@ paths: schema: $ref: "#/components/schemas/DisclaimerAckEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/onboarding/tour: @@ -20259,6 +20581,7 @@ paths: schema: $ref: "#/components/schemas/TourUpdateEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/nutrients/catalog: @@ -20276,11 +20599,12 @@ paths: schema: $ref: "#/components/schemas/NutrientCatalogEnvelope" "401": *a1 - "403": &a44 + "403": &a46 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 - on. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: 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 on. Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). @@ -20389,9 +20713,10 @@ paths: $ref: "#/components/schemas/NutrientBatchEnvelope" "401": *a1 "403": - 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 on. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: 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 on." content: application/json: schema: @@ -20423,7 +20748,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": *a44 + "403": *a46 "422": *a4 "429": *a5 /api/nutrients/daily: @@ -20460,7 +20785,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a44 + "403": *a46 "422": *a4 "429": *a5 /api/nutrients/water: @@ -20489,7 +20814,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a44 + "403": *a46 "409": *a7 "422": *a4 "429": *a5 @@ -20508,6 +20833,7 @@ paths: schema: $ref: "#/components/schemas/HealthKitConfigEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -20531,6 +20857,7 @@ paths: schema: $ref: "#/components/schemas/HealthKitConfigPatchEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/oura/sync: @@ -20548,6 +20875,7 @@ paths: schema: $ref: "#/components/schemas/OuraSyncEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 "502": @@ -20573,6 +20901,7 @@ paths: schema: $ref: "#/components/schemas/PolarSyncEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 "502": @@ -20600,6 +20929,7 @@ paths: schema: $ref: "#/components/schemas/StravaSyncEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 "502": @@ -20626,6 +20956,7 @@ paths: schema: $ref: "#/components/schemas/NightscoutSyncEnvelope" "401": *a1 + "403": *a9 "422": description: The configured Nightscout origin is private (RFC 1918 / loopback / link-local) and the server operator has not approved private-origin access for this deployment. @@ -20659,6 +20990,7 @@ paths: schema: $ref: "#/components/schemas/GoogleHealthSyncStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/nightscout/connect: @@ -20691,6 +21023,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "413": description: Body exceeds 16 KiB. content: @@ -20728,6 +21061,7 @@ paths: schema: $ref: "#/components/schemas/NightscoutStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/nightscout/test: @@ -20746,6 +21080,7 @@ paths: schema: $ref: "#/components/schemas/NightscoutTestEnvelope" "401": *a1 + "403": *a9 "422": description: No Nightscout instance is configured for this user. `meta.errorCode` = `not_configured`. content: @@ -20786,6 +21121,7 @@ paths: schema: $ref: "#/components/schemas/NightscoutDisconnectEnvelope" "401": *a1 + "403": *a9 "404": description: "No Nightscout connection to clear. Note that this is NOT an idempotent no-op: a second disconnect answers 404 rather than 200." @@ -20810,6 +21146,7 @@ paths: schema: $ref: "#/components/schemas/WhoopCredentialsStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -20838,6 +21175,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "413": description: Body exceeds 64 KiB. content: @@ -20867,6 +21205,7 @@ paths: schema: $ref: "#/components/schemas/WhoopCredentialsDeleteEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/whoop/status: @@ -20885,6 +21224,7 @@ paths: schema: $ref: "#/components/schemas/WhoopStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/whoop/disconnect: @@ -20905,6 +21245,7 @@ paths: schema: $ref: "#/components/schemas/WhoopDisconnectEnvelope" "401": *a1 + "403": *a9 "404": description: "No WHOOP connection to remove. NOT an idempotent no-op: a second disconnect answers 404 rather than 200." content: @@ -20930,6 +21271,7 @@ paths: schema: $ref: "#/components/schemas/WithingsStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/withings/disconnect: @@ -20948,6 +21290,7 @@ paths: schema: $ref: "#/components/schemas/WithingsDisconnectEnvelope" "401": *a1 + "403": *a9 "404": description: "No Withings connection to remove. Note that this is NOT an idempotent no-op: a second disconnect answers 404 rather than 200." @@ -20971,6 +21314,7 @@ paths: schema: $ref: "#/components/schemas/WithingsCredentialsStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -20991,26 +21335,27 @@ paths: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" - "400": &a45 + "400": &a47 description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "413": &a46 + "403": *a9 + "413": &a48 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a47 + "415": &a49 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a48 + "422": &a50 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: @@ -21033,6 +21378,7 @@ paths: schema: $ref: "#/components/schemas/WithingsCredentialsDeleteEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/withings/sync: @@ -21063,6 +21409,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "413": description: Body exceeds 64 KiB. content: @@ -21111,6 +21458,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "413": description: Body exceeds 64 KiB. content: @@ -21155,6 +21503,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": description: More than 10 ticket mints in 60 s from this user. @@ -21176,6 +21525,7 @@ paths: schema: $ref: "#/components/schemas/FitbitCredentialsStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -21196,11 +21546,12 @@ paths: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" - "400": *a45 + "400": *a47 "401": *a1 - "413": *a46 - "415": *a47 - "422": *a48 + "403": *a9 + "413": *a48 + "415": *a49 + "422": *a50 "429": *a5 delete: tags: @@ -21217,6 +21568,7 @@ paths: schema: $ref: "#/components/schemas/FitbitCredentialsDeleteEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/fitbit/status: @@ -21234,6 +21586,7 @@ paths: schema: $ref: "#/components/schemas/FitbitStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/fitbit/disconnect: @@ -21253,6 +21606,7 @@ paths: schema: $ref: "#/components/schemas/FitbitDisconnectEnvelope" "401": *a1 + "403": *a9 "404": description: "No Fitbit connection to remove. NOT an idempotent no-op: a second disconnect answers 404 rather than 200." content: @@ -21289,6 +21643,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "410": description: Google turned the legacy Fitbit Web API off on 2026-10-30; nothing is fetched. `meta.errorCode` = `provider_retired`. The data already imported stays; Google Health reads the same devices. @@ -21330,6 +21685,7 @@ paths: schema: $ref: "#/components/schemas/GoogleHealthCredentialsStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -21350,11 +21706,12 @@ paths: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" - "400": *a45 + "400": *a47 "401": *a1 - "413": *a46 - "415": *a47 - "422": *a48 + "403": *a9 + "413": *a48 + "415": *a49 + "422": *a50 "429": *a5 delete: tags: @@ -21371,6 +21728,7 @@ paths: schema: $ref: "#/components/schemas/GoogleHealthCredentialsDeleteEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/google-health/status: @@ -21389,6 +21747,7 @@ paths: schema: $ref: "#/components/schemas/GoogleHealthStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/google-health/disconnect: @@ -21407,6 +21766,7 @@ paths: schema: $ref: "#/components/schemas/GoogleHealthDisconnectEnvelope" "401": *a1 + "403": *a9 "404": description: "No Google Health connection to remove. NOT an idempotent no-op: a second disconnect answers 404 rather than 200." @@ -21446,6 +21806,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "409": description: Another sync of this account is already running (the history backfill after a reconnect, or the hourly poll), so none was started. `meta.errorCode` = `sync_in_progress`. `GET /api/google-health/sync/status` @@ -21495,6 +21856,7 @@ paths: schema: $ref: "#/components/schemas/OuraCredentialsStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -21515,11 +21877,12 @@ paths: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" - "400": *a45 + "400": *a47 "401": *a1 - "413": *a46 - "415": *a47 - "422": *a48 + "403": *a9 + "413": *a48 + "415": *a49 + "422": *a50 "429": *a5 delete: tags: @@ -21536,6 +21899,7 @@ paths: schema: $ref: "#/components/schemas/OuraCredentialsDeleteEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/oura/status: @@ -21554,6 +21918,7 @@ paths: schema: $ref: "#/components/schemas/OuraStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/oura/disconnect: @@ -21572,6 +21937,7 @@ paths: schema: $ref: "#/components/schemas/OuraDisconnectEnvelope" "401": *a1 + "403": *a9 "404": description: "No Oura token stored. NOT an idempotent no-op: a second disconnect answers 404 rather than 200." content: @@ -21595,21 +21961,22 @@ paths: schema: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 - "422": &a49 + "403": *a9 + "422": &a51 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": &a50 + "429": &a52 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": &a51 + "502": &a53 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` @@ -21633,6 +22000,7 @@ paths: schema: $ref: "#/components/schemas/PolarCredentialsStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -21654,11 +22022,12 @@ paths: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" - "400": *a45 + "400": *a47 "401": *a1 - "413": *a46 - "415": *a47 - "422": *a48 + "403": *a9 + "413": *a48 + "415": *a49 + "422": *a50 "429": *a5 delete: tags: @@ -21675,6 +22044,7 @@ paths: schema: $ref: "#/components/schemas/PolarCredentialsDeleteEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/polar/status: @@ -21693,6 +22063,7 @@ paths: schema: $ref: "#/components/schemas/PolarStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/polar/disconnect: @@ -21711,6 +22082,7 @@ paths: schema: $ref: "#/components/schemas/PolarDisconnectEnvelope" "401": *a1 + "403": *a9 "404": description: "No Polar token stored. NOT an idempotent no-op: a second disconnect answers 404 rather than 200." content: @@ -21734,9 +22106,10 @@ paths: schema: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 - "422": *a49 - "429": *a50 - "502": *a51 + "403": *a9 + "422": *a51 + "429": *a52 + "502": *a53 /api/strava/credentials: get: tags: @@ -21752,6 +22125,7 @@ paths: schema: $ref: "#/components/schemas/StravaCredentialsStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -21772,11 +22146,12 @@ paths: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" - "400": *a45 + "400": *a47 "401": *a1 - "413": *a46 - "415": *a47 - "422": *a48 + "403": *a9 + "413": *a48 + "415": *a49 + "422": *a50 "429": *a5 delete: tags: @@ -21794,6 +22169,7 @@ paths: schema: $ref: "#/components/schemas/StravaCredentialsDeleteEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/strava/status: @@ -21812,6 +22188,7 @@ paths: schema: $ref: "#/components/schemas/StravaStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/strava/disconnect: @@ -21831,6 +22208,7 @@ paths: schema: $ref: "#/components/schemas/StravaDisconnectEnvelope" "401": *a1 + "403": *a9 "404": description: "No Strava connection. NOT an idempotent no-op: a second disconnect answers 404 rather than 200." content: @@ -21854,9 +22232,10 @@ paths: schema: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 - "422": *a49 - "429": *a50 - "502": *a51 + "403": *a9 + "422": *a51 + "429": *a52 + "502": *a53 /api/integrations/status: get: tags: @@ -21873,6 +22252,7 @@ paths: schema: $ref: "#/components/schemas/IntegrationStatusEnvelopeResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/integrations/fitbit/resume: @@ -21891,8 +22271,9 @@ paths: schema: $ref: "#/components/schemas/FitbitResumeEnvelope" "401": *a1 + "403": *a9 "422": *a4 - "429": *a50 + "429": *a52 /api/integrations/fitbit/test: post: tags: @@ -21910,6 +22291,7 @@ paths: schema: $ref: "#/components/schemas/FitbitIntegrationTestEnvelope" "401": *a1 + "403": *a9 "410": description: Google turned the legacy Fitbit Web API off on 2026-10-30; nothing is probed. `meta.errorCode` = `provider_retired`. @@ -21917,9 +22299,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a49 - "429": *a50 - "502": *a51 + "422": *a51 + "429": *a52 + "502": *a53 /api/integrations/google-health/resume: post: tags: @@ -21935,8 +22317,9 @@ paths: schema: $ref: "#/components/schemas/GoogleHealthResumeEnvelope" "401": *a1 + "403": *a9 "422": *a4 - "429": *a50 + "429": *a52 /api/integrations/google-health/test: post: tags: @@ -21963,15 +22346,16 @@ paths: schema: $ref: "#/components/schemas/GoogleHealthIntegrationTestEnvelope" "401": *a1 + "403": *a9 "413": description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a49 - "429": *a50 - "502": *a51 + "422": *a51 + "429": *a52 + "502": *a53 /api/integrations/whoop/resume: post: tags: @@ -21987,8 +22371,9 @@ paths: schema: $ref: "#/components/schemas/WhoopResumeEnvelope" "401": *a1 + "403": *a9 "422": *a4 - "429": *a50 + "429": *a52 /api/integrations/whoop/test: post: tags: @@ -22005,9 +22390,10 @@ paths: schema: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 - "422": *a49 - "429": *a50 - "502": *a51 + "403": *a9 + "422": *a51 + "429": *a52 + "502": *a53 /api/integrations/withings/resume: post: tags: @@ -22024,8 +22410,9 @@ paths: schema: $ref: "#/components/schemas/WithingsResumeEnvelope" "401": *a1 + "403": *a9 "422": *a4 - "429": *a50 + "429": *a52 /api/integrations/withings/test: post: tags: @@ -22043,8 +22430,9 @@ paths: schema: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 - "422": *a49 - "429": *a50 + "403": *a9 + "422": *a51 + "429": *a52 "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: @@ -22077,7 +22465,7 @@ paths: schema: $ref: "#/components/schemas/DailyDigestEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/daily/digest/dismiss: @@ -22112,6 +22500,7 @@ paths: schema: $ref: "#/components/schemas/DismissPriorityItemEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/dashboard/chart-overlay-prefs: @@ -22146,6 +22535,7 @@ paths: schema: $ref: "#/components/schemas/ChartOverlayPrefsSavedEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/dashboard/summary: @@ -22168,7 +22558,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSummaryEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/dashboard/widgets: @@ -22187,7 +22577,7 @@ paths: schema: $ref: "#/components/schemas/DashboardLayout" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 put: @@ -22213,6 +22603,7 @@ paths: schema: $ref: "#/components/schemas/DashboardLayoutSaved" "401": *a1 + "403": *a9 "409": description: Dashboard layout 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. @@ -22221,7 +22612,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a15 + "422": *a16 "429": *a5 delete: tags: @@ -22237,6 +22628,7 @@ paths: schema: $ref: "#/components/schemas/DashboardLayoutReset" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/managed-profiles/{id}/handover: @@ -22260,6 +22652,7 @@ paths: schema: $ref: "#/components/schemas/ManagedProfileHandoverStatusEnvelope" "401": *a1 + "403": *a9 "404": description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`)." @@ -22300,9 +22693,11 @@ paths: $ref: "#/components/schemas/ManagedProfileHandoverEnvelope" "401": *a1 "403": - description: "Neither a password nor single sign-on could redeem a link on this instance (`meta.errorCode: - profile_claim.oidc_only_unsupported`). Since v1.43 a single-sign-on-only instance hands over through the - identity provider, so a working configuration never answers this." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Neither a password nor single + sign-on could redeem a link on this instance (`meta.errorCode: profile_claim.oidc_only_unsupported`). Since + v1.43 a single-sign-on-only instance hands over through the identity provider, so a working configuration + never answers this." content: application/json: schema: @@ -22342,6 +22737,7 @@ paths: schema: $ref: "#/components/schemas/RevokeManagedProfileHandoverEnvelope" "401": *a1 + "403": *a9 "404": description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`)." @@ -22366,6 +22762,7 @@ paths: schema: $ref: "#/components/schemas/PendingHandoverDecisionEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 post: @@ -22389,6 +22786,7 @@ paths: schema: $ref: "#/components/schemas/HandoverDecisionEnvelope" "401": *a1 + "403": *a9 "409": description: "No decision is waiting (`meta.errorCode: managed_profile.handover.no_pending`)." content: @@ -22423,7 +22821,9 @@ paths: $ref: "#/components/schemas/AccountPayloadEnvelope" "401": *a1 "403": - description: "A selector header was attached to an actor surface (`meta.errorCode: sharing.not_permitted`)." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: A selector header was attached to + an actor surface (`meta.errorCode: sharing.not_permitted`)." content: application/json: schema: @@ -22450,11 +22850,13 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a52 - 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 - nothing — the refusal is not an enumeration oracle. + "403": &a54 + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: 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 nothing — the refusal is not an + enumeration oracle." content: application/json: schema: @@ -22475,7 +22877,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a52 + "403": *a54 "422": *a4 "429": *a5 post: @@ -22519,10 +22921,12 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": - 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.invite.manage_browser_only` when a Bearer caller - tried to offer MANAGE — the step-up that gates it is cookie-only by construction, so the native client - cannot mint it and should route the person to a browser rather than surface an error. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Refused. `meta.errorCode` is + `sharing.not_permitted` when the request was made while acting on another account (grant management is never + delegable), or `sharing.invite.manage_browser_only` when a Bearer caller tried to offer MANAGE — the step-up + that gates it is cookie-only by construction, so the native client cannot mint it and should route the + person to a browser rather than surface an error." content: application/json: schema: @@ -22577,7 +22981,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a52 + "403": *a54 "404": description: No such invitation for this account. content: @@ -22621,7 +23025,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a52 + "403": *a54 "404": description: No such grant on this account's record. content: @@ -22662,7 +23066,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a52 + "403": *a54 "404": description: No such grant held by this account. content: @@ -22717,6 +23121,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "422": *a4 "429": description: "More than ten profiles from one caller within the hour (`meta.errorCode: http.rate_limited`, the status @@ -22750,6 +23155,7 @@ paths: schema: $ref: "#/components/schemas/ManagedProfileEnvelope" "401": *a1 + "403": *a9 "404": description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`). The two are byte-identical, so the refusal is not an enumeration oracle." @@ -22794,6 +23200,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "404": description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`). The two are byte-identical." @@ -22837,6 +23244,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "404": description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`). The two are byte-identical, so the refusal is not an enumeration oracle — and @@ -22875,6 +23283,7 @@ paths: schema: $ref: "#/components/schemas/ManagedProfileGuardianListEnvelope" "401": *a1 + "403": *a9 "404": description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`). The two are byte-identical." @@ -22919,6 +23328,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "404": description: "Two refusals share this status, each with its own code. `meta.errorCode: sharing.invite.no_account`: no account on this instance carries that identifier — the same deliberate disclosure to an authenticated caller @@ -22989,6 +23399,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "404": description: "`meta.errorCode: managed_profile.not_found`, for four situations the caller cannot tell apart: no such profile, no such grant on it, the caller is not one of its Guardians, or the target grant is the caller's @@ -23047,7 +23458,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a52 + "403": *a54 "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 — @@ -23127,7 +23538,9 @@ paths: $ref: "#/components/schemas/WebPushSubscribeEnvelope" "401": *a1 "403": - description: The operator switched Web Push off for this instance. Nothing was stored. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator switched Web Push + off for this instance. Nothing was stored." content: application/json: schema: @@ -23166,6 +23579,7 @@ paths: schema: $ref: "#/components/schemas/WebPushUnsubscribeEnvelope" "401": *a1 + "403": *a9 "413": description: Request body exceeds 65536 bytes. content: @@ -23197,6 +23611,7 @@ paths: schema: $ref: "#/components/schemas/WebPushTestEnvelope" "401": *a1 + "403": *a9 "422": description: "Nothing to test against: the account has no Push subscription (`meta.errorCode` = `not_configured`), or the operator has configured no VAPID keys (`vapid_not_configured`)." @@ -23238,6 +23653,7 @@ paths: schema: $ref: "#/components/schemas/ApnsTestEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": description: More than 5 tests in a minute (`meta.errorCode` = `rate_limited_self`). @@ -23312,6 +23728,7 @@ paths: schema: $ref: "#/components/schemas/GetNotificationPreferences" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -23334,6 +23751,7 @@ paths: schema: $ref: "#/components/schemas/PutNotificationPreferenceResponse" "401": *a1 + "403": *a9 "404": description: No such channel for this caller. content: @@ -23369,6 +23787,7 @@ paths: schema: $ref: "#/components/schemas/GetNotificationStatus" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 post: @@ -23393,6 +23812,7 @@ paths: schema: $ref: "#/components/schemas/ReEnableNotificationChannelResponse" "401": *a1 + "403": *a9 "404": description: No such channel for this caller. content: @@ -23427,10 +23847,11 @@ paths: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 - "403": &a53 - 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. + "403": &a55 + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: 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." content: application/json: schema: @@ -23460,7 +23881,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 - "403": *a53 + "403": *a55 "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. @@ -23487,7 +23908,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a53 + "403": *a55 "422": *a4 "429": *a5 post: @@ -23520,7 +23941,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a53 + "403": *a55 "413": description: Body exceeds 16 KiB. content: @@ -23563,7 +23984,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 - "403": *a53 + "403": *a55 "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. @@ -23590,7 +24011,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/record-settings/integrations: @@ -23609,7 +24030,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsIntegrationsEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/record-settings/integrations/{provider}/connect: @@ -23625,7 +24046,7 @@ paths: record (audited). Fitbit is not offered: its classic API is retired. Cookie session, while acting on a managed record the caller guards." parameters: - - &a54 + - &a56 name: provider in: path required: true @@ -23647,7 +24068,7 @@ paths: schema: $ref: "#/components/schemas/ManagedConnectStartEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: The path named a provider outside the closed set. Answered before the caller is authenticated. content: @@ -23672,7 +24093,7 @@ paths: Audited against the record with the Guardian as the actor. Cookie session or Bearer, while acting on a managed record the caller guards." parameters: - - *a54 + - *a56 responses: "200": description: The provider is disconnected. @@ -23681,7 +24102,7 @@ paths: schema: $ref: "#/components/schemas/ManagedDisconnectEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: The path named a provider outside the closed set, or the record has no connection to it. content: @@ -23698,7 +24119,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: - - &a55 + - &a57 name: family in: path required: true @@ -23722,7 +24143,7 @@ paths: schema: $ref: "#/components/schemas/GetRecordSettingsFamilyEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "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. @@ -23742,7 +24163,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: - - *a55 + - *a57 requestBody: required: true content: @@ -23763,7 +24184,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: The path named a family outside the closed set. Answered before authentication. content: @@ -23877,7 +24298,7 @@ paths: schema: $ref: "#/components/schemas/ListPersonalRecordsEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": description: "`metricType` or `limit` carried an unacceptable value. `meta.errorCode` = `personal_records.invalid_query`, with the offending path under `details.issues`." @@ -23930,7 +24351,7 @@ paths: schema: $ref: "#/components/schemas/AchievementsEnvelope" "401": *a1 - "403": *a13 + "403": *a14 "422": *a4 "429": *a5 /api/environment/preferences: @@ -23956,7 +24377,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentPreferencesEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "422": *a4 "429": *a5 /api/environment: @@ -23974,7 +24395,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentOverviewEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "422": *a4 "429": *a5 /api/environment/home: @@ -24011,7 +24432,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentHomeEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "413": description: Body exceeds 4 KiB. content: @@ -24071,7 +24492,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentBackfillEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "409": description: No home location is set, so there is nothing to resolve the days against (`meta.errorCode` = `environment.no_home`), or a backfill for this account is already queued in the current twenty-minute slot @@ -24131,7 +24552,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentGeocodeEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "422": description: "`q` was missing, empty after trimming, or longer than 120 characters. `meta.errorCode` = `environment.invalid`." @@ -24170,7 +24591,7 @@ paths: schema: $ref: "#/components/schemas/CreateEnvironmentTravelEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "413": description: Body exceeds 4 KiB. content: @@ -24223,7 +24644,7 @@ paths: schema: $ref: "#/components/schemas/DeleteEnvironmentTravelEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "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. @@ -24369,13 +24790,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a56 + "403": &a58 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": &a57 + "404": &a59 description: No stored copy with this id. content: application/json: @@ -24449,8 +24870,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a56 - "404": *a57 + "403": *a58 + "404": *a59 "409": description: A restore of the same account is already queued or running (`meta.errorCode` = `backup.restore.active`, `meta.jobId` names it), the stored copy was replaced by a newer one while it was being checked @@ -24494,7 +24915,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobListResponse" - "403": *a56 + "403": *a58 /api/admin/backups/restores/{jobId}: get: tags: @@ -24517,7 +24938,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobResponse" - "403": *a56 + "403": *a58 "404": description: No restore job with this id. content: @@ -24536,7 +24957,7 @@ paths: request leaves the server. Needs neither the Documents module nor the list: with the list unset, `available` is false and any connection still stored is listed (with `originAllowed: false`) so it can be removed. Cookie session only; not delegable." - security: &a58 + security: &a60 - cookieAuth: [] responses: "200": @@ -24546,10 +24967,12 @@ paths: schema: $ref: "#/components/schemas/DocumentSourcesStatusEnvelope" "401": *a1 - "403": &a59 - description: "Refused before any request to the source: `documents.sources.browserOnly` for a Bearer caller, - `sharing.not_permitted` while acting on another person's record (a delegate or guardian never uses the - owner's archive), or `module.disabled` when the Documents module is off." + "403": &a61 + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Refused before any request to the + source: `documents.sources.browserOnly` for a Bearer caller, `sharing.not_permitted` while acting on another + person's record (a delegate or guardian never uses the owner's archive), or `module.disabled` when the + Documents module is off." content: application/json: schema: @@ -24569,9 +24992,9 @@ paths: `documents.sources.tokenRequired`), so a stored token is never sent to an address it was not saved for. A Papra organization id is letters, digits, `_` and `-`. The token is stored encrypted (AES-256-GCM) and never returned. Ten saves and tests a minute per person. Cookie session only; not delegable." - security: *a58 + security: *a60 parameters: - - &a60 + - &a62 name: system in: path required: true @@ -24610,8 +25033,8 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceConnectionEnvelope" "401": *a1 - "403": *a59 - "404": &a61 + "403": *a61 + "404": &a63 description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. `documents.sources.notFound`: the source has no such document, or (Papra) no such organization, including @@ -24630,7 +25053,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": &a62 + "502": &a64 description: "The source could not answer usefully, with the reason in `meta.errorCode` and, when the source answered at all, its HTTP status in `meta.upstreamStatus` (its body is never passed on): `documents.sources.unreachable` (no connection, timeout, or a destination refused at dial time), `documents.sources.redirected` (the source @@ -24649,9 +25072,9 @@ paths: description: Deletes the caller's connection for this system, and its token with it. Works even after the operator removed the origin from the list, and with the Documents module switched off. `disconnected` is false when there was nothing to delete. Cookie session only; not delegable. - security: *a58 + security: *a60 parameters: - - *a60 + - *a62 responses: "200": description: Removed, or there was nothing to remove. @@ -24660,7 +25083,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceDisconnectEnvelope" "401": *a1 - "403": *a59 + "403": *a61 "422": *a4 "429": *a5 /api/documents/sources/{system}/test: @@ -24671,9 +25094,9 @@ paths: description: One request to the source with the stored token. Answers `ok` with the round-trip time and stamps `lastVerifiedAt`, or the reason in `meta.errorCode`. Shares the save's allowance of ten a minute. Cookie session only; not delegable. - security: *a58 + security: *a60 parameters: - - *a60 + - *a62 responses: "200": description: The source answered. @@ -24682,9 +25105,9 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTestEnvelope" "401": *a1 - "403": *a59 - "404": *a61 - "422": &a63 + "403": *a61 + "404": *a63 + "422": &a65 description: "`documents.sources.originNotAllowed`: the base address's origin is not on the operator's list (checked on every request, so a connection saved earlier is cut off once its origin is removed), or another validation failure." @@ -24693,7 +25116,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": *a62 + "502": *a64 /api/documents/sources/{system}/tags: get: tags: @@ -24701,9 +25124,9 @@ paths: summary: Tags of a connected archive description: The source's tags for the picker's filter, the first 250 by name, in the source's own ids. Shares the search allowance of sixty a minute. Cookie session only; not delegable. - security: *a58 + security: *a60 parameters: - - *a60 + - *a62 responses: "200": description: The tags. @@ -24712,11 +25135,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTagsEnvelope" "401": *a1 - "403": *a59 - "404": *a61 - "422": *a63 + "403": *a61 + "404": *a63 + "422": *a65 "429": *a5 - "502": *a62 + "502": *a64 /api/documents/sources/{system}/search: get: tags: @@ -24730,9 +25153,9 @@ paths: under that source key: `new`, `imported` with the vault document's `documentId`, or `deleted` (deleted in HealthLog; importing it again stores nothing). Nothing is stored. Sixty searches a minute, shared with the tag read. Cookie session only; not delegable." - security: *a58 + security: *a60 parameters: - - *a60 + - *a62 - name: q in: query required: false @@ -24773,11 +25196,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceSearchEnvelope" "401": *a1 - "403": *a59 - "404": *a61 - "422": *a63 + "403": *a61 + "404": *a63 + "422": *a65 "429": *a5 - "502": *a62 + "502": *a64 /api/documents/sources/{system}/import: post: tags: @@ -24796,9 +25219,9 @@ paths: fetched), including a duplicate. `kind` sets the document type (default OTHER). Automatic AI reading follows the person's own setting. Counts against the person's upload allowance (60 an hour) and storage quota. 201 for a new document, 200 otherwise. Cookie session only; not delegable." - security: *a58 + security: *a60 parameters: - - *a60 + - *a62 requestBody: required: true content: @@ -24856,7 +25279,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceImportEnvelope" "401": *a1 - "403": *a59 + "403": *a61 "404": description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. @@ -24887,7 +25310,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a63 + "422": *a65 "429": description: "`documents.inbound.rateLimited` (the person's hourly upload allowance, with the rate-limit headers) or `documents.inbound.uploadBusy` (too many uploads in progress; `Retry-After: 1`)." @@ -24895,7 +25318,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": *a62 + "502": *a64 /api/symptoms/definitions: get: tags: @@ -24919,10 +25342,11 @@ paths: schema: $ref: "#/components/schemas/ListSymptomDefinitionsEnvelope" "401": *a1 - "403": &a64 + "403": &a66 description: >- - `illness.disabled`: the record has the illness module switched off (or the operator turned it off - server-wide); symptoms ride that module. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: `illness.disabled`: the record + has the illness module switched off (or the operator turned it off server-wide); symptoms ride that module. Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). @@ -24962,7 +25386,7 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomDefinitionEnvelope" "401": *a1 - "403": *a64 + "403": *a66 "409": *a7 "422": *a4 "429": *a5 @@ -24992,8 +25416,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomDefinitionEnvelope" "401": *a1 - "403": *a64 - "404": &a65 + "403": *a66 + "404": &a67 description: "`symptoms.definition.notFound`: no definition with this id on the record." content: application/json: @@ -25027,8 +25451,8 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomDefinitionEnvelope" "401": *a1 - "403": *a64 - "404": *a65 + "403": *a66 + "404": *a67 "422": *a4 "429": *a5 /api/symptoms/events: @@ -25077,7 +25501,7 @@ paths: schema: $ref: "#/components/schemas/ListSymptomEventsEnvelope" "401": *a1 - "403": *a64 + "403": *a66 "422": *a4 "429": *a5 post: @@ -25103,8 +25527,8 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomEventEnvelope" "401": *a1 - "403": *a64 - "404": *a65 + "403": *a66 + "404": *a67 "409": *a7 "422": *a4 "429": *a5 @@ -25134,8 +25558,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomEventEnvelope" "401": *a1 - "403": *a64 - "404": &a66 + "403": *a66 + "404": &a68 description: "`symptoms.event.notFound`: no occurrence with this id on the record." content: application/json: @@ -25162,8 +25586,8 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomEventEnvelope" "401": *a1 - "403": *a64 - "404": *a66 + "403": *a66 + "404": *a68 "422": *a4 "429": *a5 /api/day/index: @@ -25196,7 +25620,7 @@ paths: schema: $ref: "#/components/schemas/DayIndexEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/day/notable: @@ -25231,7 +25655,7 @@ paths: schema: $ref: "#/components/schemas/DayNotableWindowEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/day/{date}: @@ -25261,7 +25685,7 @@ paths: schema: $ref: "#/components/schemas/DayEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/timeline: @@ -25314,10 +25738,12 @@ paths: schema: $ref: "#/components/schemas/TimelineEnvelope" "401": *a1 - "403": &a67 + "403": &a69 description: >- - `module.disabled` with `meta.module = timeline`: the record has the timeline switched off (it is opt-in), or - the operator turned it off server-wide. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: `module.disabled` with + `meta.module = timeline`: the record has the timeline switched off (it is opt-in), or the operator turned it + off server-wide. Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). @@ -25351,7 +25777,7 @@ paths: schema: $ref: "#/components/schemas/TimelineReadinessEnvelope" "401": *a1 - "403": *a67 + "403": *a69 "422": *a4 "429": *a5 /api/life-events: @@ -25369,10 +25795,12 @@ paths: schema: $ref: "#/components/schemas/LifeEventListEnvelope" "401": *a1 - "403": &a68 + "403": &a70 description: >- - Owner-only in v1.42: no share reaches life events, not a `profile` share, a whole-record share or MANAGE, - and not a guardian acting for a managed profile. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Owner-only in v1.42: no share + reaches life events, not a `profile` share, a whole-record share or MANAGE, and not a guardian acting for a + managed profile. Refused: the request was made while the session is acting on another account (`meta.errorCode` = @@ -25409,10 +25837,10 @@ paths: schema: $ref: "#/components/schemas/CreateLifeEventEnvelope" "401": *a1 - "403": *a68 + "403": *a70 "409": *a7 "422": *a4 - "429": *a12 + "429": *a13 /api/life-events/{id}: patch: tags: @@ -25440,8 +25868,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLifeEventEnvelope" "401": *a1 - "403": *a68 - "404": &a69 + "403": *a70 + "404": &a71 description: No such life event on this account. content: application/json: @@ -25468,8 +25896,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLifeEventEnvelope" "401": *a1 - "403": *a68 - "404": *a69 + "403": *a70 + "404": *a71 "422": *a4 "429": *a5 /api/search: @@ -25515,7 +25943,7 @@ paths: schema: $ref: "#/components/schemas/SearchEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/auth/me/ai-usage: @@ -25540,6 +25968,7 @@ paths: schema: $ref: "#/components/schemas/PutAiUsageResponse" "401": *a1 + "403": *a9 "422": description: "`ai.usage.invalid` (body) or `ai.usage.aboveCeiling` (`meta.maxLevel` names the ceiling)." content: @@ -25582,8 +26011,9 @@ paths: "401": *a1 "403": description: >- - The opt-in `nutrients` module is off for this account: 403 `module.disabled` with `meta.module: - "nutrients"`. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The opt-in `nutrients` module is + off for this account: 403 `module.disabled` with `meta.module: "nutrients"`. Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). @@ -25642,16 +26072,17 @@ paths: required: - file responses: - "202": *a37 + "202": *a39 "401": *a1 "403": - description: "The opt-in `nutrients` module is off for this account: 403 `module.disabled` with `meta.module: - \"nutrients\"`. The `supplementLabel` AI capability is unavailable for this record. `meta.capability` = - `supplementLabel` and `meta.reason` says which layer refused: the operator's switch - (`assistant.disabled.documentAi`, or `assistant.disabled.enabled` for the master switch), an owning module - or the person's own AI opt-out (`module.disabled` with `meta.module`), a delegate inside somebody else's - record (`ai.record.notPermitted`), or a missing consent receipt (`consent.ai.required`). Nothing was - generated, read from a stored model answer, or enqueued." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The opt-in `nutrients` module is + off for this account: 403 `module.disabled` with `meta.module: \"nutrients\"`. The `supplementLabel` AI + capability is unavailable for this record. `meta.capability` = `supplementLabel` and `meta.reason` says + which layer refused: the operator's switch (`assistant.disabled.documentAi`, or `assistant.disabled.enabled` + for the master switch), an owning module or the person's own AI opt-out (`module.disabled` with + `meta.module`), a delegate inside somebody else's record (`ai.record.notPermitted`), or a missing consent + receipt (`consent.ai.required`). Nothing was generated, read from a stored model answer, or enqueued." content: application/json: schema: @@ -25677,7 +26108,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "503": *a18 + "503": *a20 /api/anamnesis/life-circumstances: get: tags: @@ -25692,7 +26123,7 @@ paths: schema: $ref: "#/components/schemas/LifeCircumstancesEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/anamnesis/life-circumstances/ai-sections: @@ -25716,6 +26147,7 @@ paths: schema: $ref: "#/components/schemas/LifeAiSectionsEnvelope" "401": *a1 + "403": *a9 "409": description: "`about_me_conflict`." content: @@ -25745,6 +26177,7 @@ paths: schema: $ref: "#/components/schemas/LifeConfirmEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/anamnesis/audit-c: @@ -25779,7 +26212,7 @@ paths: schema: $ref: "#/components/schemas/AuditCCreatedEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -25803,7 +26236,7 @@ paths: schema: $ref: "#/components/schemas/AuditCRemovedEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: "`anamnesis.auditC.notFound`." content: @@ -25834,7 +26267,7 @@ paths: schema: $ref: "#/components/schemas/DawarichConnectEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "422": *a4 "429": *a5 /api/dawarich/status: @@ -25850,7 +26283,7 @@ paths: schema: $ref: "#/components/schemas/DawarichStatusEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "422": *a4 "429": *a5 /api/dawarich/places: @@ -25874,7 +26307,7 @@ paths: schema: $ref: "#/components/schemas/DawarichPlacesEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "409": description: "`dawarich.not_connected`." content: @@ -25912,7 +26345,7 @@ paths: schema: $ref: "#/components/schemas/DawarichSettingsEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "409": description: "`dawarich.not_connected`." content: @@ -25944,7 +26377,7 @@ paths: schema: $ref: "#/components/schemas/DawarichProbeEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "422": *a4 "429": *a5 "502": @@ -25980,7 +26413,7 @@ paths: schema: $ref: "#/components/schemas/DawarichSyncEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "409": description: "`dawarich.not_connected`." content: @@ -26015,7 +26448,7 @@ paths: schema: $ref: "#/components/schemas/DawarichDisconnectEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "422": *a4 "429": *a5 /api/environment/travel/suggestions: @@ -26033,7 +26466,7 @@ paths: schema: $ref: "#/components/schemas/TravelSuggestionsEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "422": *a4 "429": *a5 /api/environment/travel/suggestions/{id}/dismiss: @@ -26056,7 +26489,7 @@ paths: schema: $ref: "#/components/schemas/TravelSuggestionDismissEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "404": description: "`environment.travel_suggestion.not_found`." content: @@ -26093,6 +26526,7 @@ paths: schema: $ref: "#/components/schemas/MedicationLinkResponse" "401": *a1 + "403": *a9 "404": description: The medication was not found. content: @@ -26128,6 +26562,7 @@ paths: schema: $ref: "#/components/schemas/MedicationUnlinkResponse" "401": *a1 + "403": *a9 "404": description: The medication was not found. content: @@ -26164,6 +26599,7 @@ paths: schema: $ref: "#/components/schemas/LabResultLinkResponse" "401": *a1 + "403": *a9 "404": description: The lab result was not found. content: @@ -26199,6 +26635,7 @@ paths: schema: $ref: "#/components/schemas/LabResultUnlinkResponse" "401": *a1 + "403": *a9 "404": description: The lab result was not found. content: diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index fddd0c974..741528ed9 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -89,10 +89,12 @@ Every call is also a contract check. The status must be one the registry (`src/lib/openapi/routes/`) declares for that path and method, a JSON body must parse with the registered schema, and a key the schema does not declare fails the call: a client generated from the contract cannot see that field, so -nobody promised to keep it. The one generic answer accepted without its own -entry is the scope gate's 403 `auth.scope.insufficient`, which the shared 401 -documents for every authenticated operation. A test that calls an unregistered -route on purpose passes `contract: { skip: "" }`. +nobody promised to keep it. There is no generic answer the kit accepts on an +operation's behalf: the scope gate's 403 `auth.scope.insufficient` is declared +on every operation that passes the gate (`withScopeRefusal` in +`src/lib/openapi/routes/shared.ts`), so it is checked like any other status. A +test that calls an unregistered route on purpose passes +`contract: { skip: "" }`. `tests/integration/routes/kit-contract.test.ts` proves the check can fail. Assert what a person or a client would notice: the stored row, the response, diff --git a/src/lib/openapi/__tests__/gate-refusal-publication.test.ts b/src/lib/openapi/__tests__/gate-refusal-publication.test.ts new file mode 100644 index 000000000..3a70c7c04 --- /dev/null +++ b/src/lib/openapi/__tests__/gate-refusal-publication.test.ts @@ -0,0 +1,134 @@ +/** + * The generic auth gate's 403, declared on every operation that passes it. + * + * `requireAuth` answers 403 `auth.scope.insufficient` to a narrow Bearer token + * on a route that names no scope it holds, and `requireAdmin` answers 403 + * `auth.admin.required` to a session that is not an administrator's. Both run + * before a route's own body, so every operation behind them can send the + * status; `withScopeRefusal` declares it on the assembled table. These checks + * hold the published document to that, and hold the helper to not trampling + * the 403s operations already publish for reasons of their own. + */ +import { describe, expect, it } from "vitest"; + +import { openApiPaths } from "../routes"; +import { + adminRefusalResponse, + errorEnvelope, + scopeRefusalResponse, + stdResponses, + withScopeRefusal, +} from "../routes/shared"; + +const METHODS = ["get", "put", "post", "patch", "delete"] as const; + +interface Op { + security?: unknown[]; + responses?: Record; +} + +function operations(): Array<{ path: string; method: string; op: Op }> { + const out: Array<{ path: string; method: string; op: Op }> = []; + for (const [path, item] of Object.entries(openApiPaths)) { + for (const method of METHODS) { + const op = (item as Record)[method]; + if (op?.responses) out.push({ path, method, op }); + } + } + return out; +} + +const URL_CREDENTIAL_OR_OWN_GATE = new Set([ + "/api/c/{token}/unlock", + "/api/notifications/action/{token}", + "/api/ingest/medication", +]); + +describe("the gate's 403 in the published contract", () => { + it("is declared, naming its code, on every operation behind the gate", () => { + const behind = operations().filter( + ({ path, op }) => + op.responses?.["401"] && + !(Array.isArray(op.security) && op.security.length === 0) && + !URL_CREDENTIAL_OR_OWN_GATE.has(path), + ); + expect(behind.length).toBeGreaterThan(500); + + const silent = behind.filter(({ path, op }) => { + const code = path.startsWith("/api/admin/") + ? "auth.admin.required" + : "auth.scope.insufficient"; + return !op.responses?.["403"]?.description?.includes(code); + }); + expect(silent.map(({ path, method }) => `${method} ${path}`)).toEqual([]); + }); + + it("is not declared on an operation that takes no credential", () => { + const open = operations().filter( + ({ op }) => Array.isArray(op.security) && op.security.length === 0, + ); + expect(open.length).toBeGreaterThan(0); + const claimed = open.filter(({ op }) => + op.responses?.["403"]?.description?.includes("auth.scope.insufficient"), + ); + expect(claimed.map(({ path, method }) => `${method} ${path}`)).toEqual([]); + }); + + it("keeps an operation's own 403 and its wording", () => { + const calendar = ( + openApiPaths["/api/cycle/calendar"] as unknown as { + get: Op; + } + ).get; + const description = calendar.responses?.["403"]?.description ?? ""; + expect(description).toContain("cycle.disabled"); + expect(description).toContain("auth.scope.insufficient"); + }); +}); + +describe("withScopeRefusal", () => { + const ownForbidden = { + description: "Module off. `meta.errorCode` = `module.disabled`.", + content: { "application/json": { schema: errorEnvelope } }, + }; + const table = withScopeRefusal({ + "/api/a": { + get: { responses: { "200": {}, ...stdResponses } }, + post: { responses: { "200": {}, ...stdResponses, "403": ownForbidden } }, + }, + "/api/b": { + get: { responses: { "200": {}, "403": ownForbidden, ...stdResponses } }, + }, + "/api/admin/x": { get: { responses: { "200": {}, ...stdResponses } } }, + "/api/open": { + get: { security: [], responses: { "200": {}, ...stdResponses } }, + }, + "/api/no-auth-declared": { get: { responses: { "200": {} } } }, + }) as unknown as Record>; + + it("adds the shared response where an operation has none", () => { + expect(table["/api/a"].get.responses?.["403"]).toBe(scopeRefusalResponse); + expect(table["/api/admin/x"].get.responses?.["403"]).toBe( + adminRefusalResponse, + ); + }); + + it("prefixes an own 403, once per shared object, and leaves its text", () => { + const a = table["/api/a"].post.responses?.["403"]; + const b = table["/api/b"].get.responses?.["403"]; + expect(a).toBe(b); + expect(a?.description?.startsWith("`meta.errorCode` = `auth.scope")).toBe( + true, + ); + expect(a?.description?.endsWith(ownForbidden.description)).toBe(true); + // The input object is not mutated. + expect(ownForbidden.description).not.toContain("auth.scope"); + }); + + it("leaves an operation that takes no credential or declares no 401 alone", () => { + expect(table["/api/open"].get.responses?.["403"]).toBeUndefined(); + expect( + table["/api/no-auth-declared"].get.responses?.["403"], + ).toBeUndefined(); + }); +}); diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index e72f9655a..31da5dec6 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -96,9 +96,9 @@ import { settingsPaths } from "./settings"; import { symptomPaths } from "./symptoms"; import { syncPaths } from "./sync"; import { workoutPaths } from "./workouts"; -import { coachPrefsSchema } from "./shared"; +import { coachPrefsSchema, withScopeRefusal } from "./shared"; -export const openApiPaths: NonNullable = { +const registeredPaths: NonNullable = { ...cyclePaths, ...metaPaths, ...healthRecordPaths, @@ -214,6 +214,11 @@ export const openApiPaths: NonNullable = { ...retiredPaths, }; +// The generic auth gate's 403 is declared on the assembled table, not spread +// from `stdResponses`; `scopeRefusalResponse` in `./shared` says why. +export const openApiPaths: NonNullable = + withScopeRefusal(registeredPaths); + export const openApiComponents: NonNullable = { // Schemas listed here are forced into `components.schemas` even when no // route references them directly. `Medication` lives in this slot diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 1e929b18e..8dfe8b32b 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -351,6 +351,128 @@ export const stdResponses = { }, }; +// ── The generic 403 ────────────────────────────────────────────────── + +/** + * The 403 the generic auth gates answer before a route's own body runs. + * + * `requireAuth` refuses a narrow Bearer token on a route that names no scope + * the token holds (`auth.scope.insufficient`), and `requireAdmin` refuses a + * session that is not an administrator's (`auth.admin.required`). The shared + * 401 described both in its prose and no operation declared the status, so a + * decoder generated from the contract had no typed branch for a refusal any + * authenticated route can send. + * + * Neither is a member of `stdResponses`, on purpose. That set is spread into + * several hundred operations, about three hundred of them declare a 403 of + * their own, and most spread the set AFTER their own entries — a shared 403 + * in it would silently overwrite every module-off and sharing refusal in the + * document. `withScopeRefusal` adds them to the assembled table instead, where + * the order of a spread cannot decide it. + */ +export const scopeRefusalResponse = { + description: + "The credential is valid but its scope does not reach this operation. `meta.errorCode` = `auth.scope.insufficient`: a narrow Bearer token on a route that names no scope it holds, or a connector-bound token on a write. Retrying with the same token will not help; use a session, or a token minted with the scope the route names.", + content: { "application/json": { schema: errorEnvelope } }, +}; + +/** + * The admin surfaces' variant. `requireAdmin` reads only the session cookie, + * so a Bearer caller never gets this far (it is a 401 there); the 403 is a + * signed-in account that is not an administrator. + */ +export const adminRefusalResponse = { + description: + "The session is valid but its account is not an administrator. `meta.errorCode` = `auth.admin.required`.", + content: { "application/json": { schema: errorEnvelope } }, +}; + +const GATE_SENTENCE = { + scope: + "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: ", + admin: + "`meta.errorCode` = `auth.admin.required` when the session's account is not an administrator; the admin gate answers it before the route's own checks run. Otherwise: ", +} as const; + +const GATE_CODE = { + scope: "auth.scope.insufficient", + admin: "auth.admin.required", +} as const; + +/** + * Surfaces that authenticate by a credential of their own and never pass the + * generic gate, so they cannot answer its 403: a URL credential (the + * clinician share link, the notification quick answer) or the external ingest + * token, which runs its own scope check and documents its own 403. + */ +const OUTSIDE_THE_GENERIC_GATE = new Set([ + "/api/c/{token}/unlock", + "/api/notifications/action/{token}", + "/api/ingest/medication", +]); + +/** + * Declare the gate's 403 on every operation that passes the generic auth + * gate: one that declares a 401 and does not opt out of authentication with + * `security: []`. Under `/api/admin/` the gate is `requireAdmin`, everywhere + * else `requireAuth`. An operation without a 403 of its own gets the shared + * response; one with its own keeps it, and its description is prefixed with + * the gate's code, because a client branching on that 403 has to tell a + * module-off refusal from a gate refusal. Prefixed, not appended: the sharing + * refusal has to stay the description's closing sentence, byte for byte + * (`openapi-sharing-denial.test.ts`). + * + * Response objects shared between operations stay shared (one augmented copy + * per original), so the emitted document keeps its anchors. + */ +export function withScopeRefusal>( + paths: T, +): T { + const augmented = { + scope: new WeakMap(), + admin: new WeakMap(), + }; + const out: Record = {}; + for (const [path, item] of Object.entries(paths)) { + if (!isPlainRecord(item) || OUTSIDE_THE_GENERIC_GATE.has(path)) { + out[path] = item; + continue; + } + const gate = path.startsWith("/api/admin/") ? "admin" : "scope"; + const nextItem: Record = { ...item }; + for (const method of ["get", "put", "post", "patch", "delete"] as const) { + const op = item[method]; + if (!isPlainRecord(op) || !isPlainRecord(op.responses)) continue; + if (Array.isArray(op.security) && op.security.length === 0) continue; + if (!op.responses["401"]) continue; + const own = op.responses["403"]; + let forbidden: unknown = + gate === "admin" ? adminRefusalResponse : scopeRefusalResponse; + if (isPlainRecord(own)) { + forbidden = augmented[gate].get(own); + if (!forbidden) { + const description = + typeof own.description === "string" ? own.description : ""; + forbidden = description.includes(GATE_CODE[gate]) + ? own + : { ...own, description: GATE_SENTENCE[gate] + description }; + augmented[gate].set(own, forbidden as object); + } + } + nextItem[method] = { + ...op, + responses: { ...op.responses, "403": forbidden }, + }; + } + out[path] = nextItem; + } + return out as T; +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + /** * The 429 the shared single-record write bucket answers with. * diff --git a/tests/integration/routes/kit-contract.test.ts b/tests/integration/routes/kit-contract.test.ts index 99e0106e1..7359258c1 100644 --- a/tests/integration/routes/kit-contract.test.ts +++ b/tests/integration/routes/kit-contract.test.ts @@ -76,15 +76,17 @@ describe("callRoute's contract check", () => { ).rejects.toThrow(/answered 409, which the contract does not declare/); }); - it("refuses a 403 that is not the generic scope refusal", async () => { + it("refuses the scope gate's 403 on an operation that declares none", async () => { + // `/api/version` takes no credential, so no 403 is declared on it; the + // generic refusal is accepted only where the contract says so. await expect( callRoute( answering(403, { data: null, - error: "nope", - meta: { errorCode: "http.forbidden" }, + error: "Insufficient scope", + meta: { errorCode: "auth.scope.insufficient" }, }), - { method: "GET", route: "/api/tokens", auth: anonymous }, + { method: "GET", route: "/api/version", auth: anonymous }, ), ).rejects.toThrow(/answered 403/); }); diff --git a/tests/kit/call-route.ts b/tests/kit/call-route.ts index 1634c3e87..d4ac7f0b6 100644 --- a/tests/kit/call-route.ts +++ b/tests/kit/call-route.ts @@ -207,20 +207,10 @@ interface ResponseObject { $ref?: string; } -/** - * The generic scope gate answers 403 `auth.scope.insufficient` on any route a - * narrow Bearer token does not reach. The registry documents that refusal in - * the prose of the shared 401 (`stdResponses`) rather than as a 403 entry on - * every operation, so an operation that declares the shared 401 is taken to - * declare this 403 as well, and nothing else is. - */ -const SCOPE_REFUSAL_CODE = "auth.scope.insufficient"; - function lookupResponse( method: HttpMethod, route: string, status: number, - body: unknown, ): ResponseObject { const pathItem = (openApiPaths as Record)[route]; if (!isRecord(pathItem)) { @@ -235,17 +225,7 @@ function lookupResponse( ); } const responses = isRecord(operation.responses) ? operation.responses : {}; - let declared = responses[String(status)] ?? responses.default; - if ( - !isRecord(declared) && - status === 403 && - isRecord(responses["401"]) && - isRecord(body) && - isRecord(body.meta) && - body.meta.errorCode === SCOPE_REFUSAL_CODE - ) { - declared = responses["401"]; - } + const declared = responses[String(status)] ?? responses.default; if (!isRecord(declared)) { throw new ContractViolation( `${method} ${route} answered ${status}, which the contract does not declare (declared: ${Object.keys(responses).join(", ")}).`, @@ -274,7 +254,7 @@ function checkContract( body: unknown, isJson: boolean, ): void { - const declared = lookupResponse(method, route, status, body); + const declared = lookupResponse(method, route, status); const content = declared.content ?? {}; const mediaTypes = Object.keys(content); if (!isJson) {