From b100c73a62e3077e5c12c71fec4cca6871d98c35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 18:19:57 +0200 Subject: [PATCH] fix(auth): refuse a sign-in over plain http before it spends a factor (#1097) A server that issues Secure session cookies cannot sign anyone in on a plain http:// page: the browser drops the cookie. The login request was still verified, the MFA ticket minted and then claimed, and the next attempt answered "Invalid or expired challenge". The web client now sends an optional clientTransport on the browser sign-in requests (password login, passkey start, both MFA completions). When the server issues Secure cookies and the page is plain http on a non-loopback host, the request is refused with 409 auth.session.insecure_transport before the password is checked, a ticket is minted or claimed, a code is spent, or a passkey challenge is issued. A request without the field is never refused. The SSO start takes client_proto/client_host and sends such a page back to the login page instead of to the provider. GET /api/auth/registration-status publishes sessionCookieSecure, and the login page shows the reason with both fixes and disables the sign-in controls before anything is sent; on localhost it shows a note only. SESSION_COOKIE_SECURE keeps its meaning; the header heuristic log line stays. --- docs/api/openapi.yaml | 964 ++++++++++-------- docs/self-hosting/getting-started.md | 7 + .../auth/registration-status/route.test.ts | 27 + src/app/api/auth/login/route.ts | 8 + src/app/api/auth/mfa/verify/route.ts | 6 + src/app/api/auth/mfa/webauthn/verify/route.ts | 5 + src/app/api/auth/oidc/login/route.ts | 19 + .../api/auth/passkey/login-options/route.ts | 12 +- src/app/api/auth/registration-status/route.ts | 9 +- src/app/auth/login/page.tsx | 83 +- .../insecure-transport-banner.test.tsx | 46 + .../auth/insecure-transport-banner.tsx | 75 ++ src/components/auth/mfa-login-step.tsx | 15 +- .../auth/__tests__/transport-refusal.test.ts | 106 ++ src/lib/auth/client-transport.ts | 64 ++ src/lib/auth/transport-refusal.ts | 40 + src/lib/openapi/error-codes.ts | 1 + src/lib/openapi/routes/auth.ts | 43 +- src/lib/validations/auth.ts | 11 + src/lib/validations/client-transport.ts | 23 + src/lib/validations/mfa.ts | 3 + .../login-insecure-transport.test.ts | 221 ++++ 22 files changed, 1325 insertions(+), 463 deletions(-) create mode 100644 src/components/auth/__tests__/insecure-transport-banner.test.tsx create mode 100644 src/components/auth/insecure-transport-banner.tsx create mode 100644 src/lib/auth/__tests__/transport-refusal.test.ts create mode 100644 src/lib/auth/client-transport.ts create mode 100644 src/lib/auth/transport-refusal.ts create mode 100644 src/lib/validations/client-transport.ts create mode 100644 tests/integration/login-insecure-transport.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 20ca5766a..bcd5cff68 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -391,7 +391,7 @@ paths: "403": *a2 "409": *a7 "422": *a4 - "429": &a11 + "429": &a12 description: "Rate limit exceeded. This route shares one per-account bucket with the other single-record writes — `record-write:`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns their own allowance rather than the record owner's. Nothing was written. `meta.errorCode` is @@ -701,7 +701,7 @@ paths: application/json: schema: $ref: "#/components/schemas/CyclePrefsPatchEnvelope" - "400": &a13 + "400": &a14 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. @@ -893,7 +893,7 @@ paths: type: string format: binary "401": *a1 - "403": &a10 + "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 @@ -1900,6 +1900,16 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "409": &a9 + 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 + verified, no MFA ticket was minted or consumed, no code was spent. A request without `clientTransport` (the + iOS app, scripts) is never refused this way.' + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/auth/mfa/verify: @@ -1925,6 +1935,7 @@ paths: schema: $ref: "#/components/schemas/MfaVerifyResponse" "401": *a1 + "409": *a9 "422": *a4 "429": *a5 /api/auth/oidc/native/token: @@ -2221,7 +2232,7 @@ paths: application/json: schema: $ref: "#/components/schemas/TotpSetupEnvelope" - "401": &a9 + "401": &a10 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: @@ -2369,7 +2380,7 @@ paths: application/json: schema: $ref: "#/components/schemas/MfaWebauthnRegisterOptionsEnvelope" - "401": *a9 + "401": *a10 "422": *a4 "429": *a5 /api/auth/me/mfa/webauthn/register/verify: @@ -2517,6 +2528,7 @@ paths: schema: $ref: "#/components/schemas/MfaWebauthnVerifyResponse" "401": *a1 + "409": *a9 "422": *a4 "429": *a5 /api/auth/passkey/login-verify: @@ -2832,10 +2844,17 @@ paths: - Auth summary: Begin a passkey sign-in (discoverable credentials) description: Returns SimpleWebAuthn assertion options plus a server-issued challenge id to present at POST - /api/auth/passkey/login-verify. Anonymous, takes no body, and scopes the assertion to no account — the options - carry no `allowCredentials`, so the authenticator picks the credential and the endpoint is not an - account-existence oracle. Rate-limited 10 / 15 min through the anonymous auth-surface bucket, which collapses - every caller into one bucket if the proxy trust chain is misconfigured rather than falling open. + /api/auth/passkey/login-verify. Anonymous, takes no body (the web client may send an optional + `clientTransport`), and scopes the assertion to no account — the options carry no `allowCredentials`, so the + authenticator picks the credential and the endpoint is not an account-existence oracle. Rate-limited 10 / 15 min + through the anonymous auth-surface bucket, which collapses every caller into one bucket if the proxy trust chain + is misconfigured rather than falling open. + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/PasskeyLoginOptionsRequest" responses: "200": description: Assertion options issued. @@ -2850,6 +2869,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "409": *a9 "429": *a5 /api/auth/passkeys/{id}: patch: @@ -3460,7 +3480,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a15 + "429": &a16 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. @@ -3547,19 +3567,42 @@ paths: type: string description: "Browser leg only: where to land after login. Sanitised to an in-app path; ignored entirely on the native leg." + - in: query + name: client_proto + schema: + description: "Browser leg only: the scheme of the page the SSO button was pressed on. With `http` on a non-loopback + `client_host` while the server issues `Secure` session cookies, the start redirects to + `/auth/login?error=insecure_transport` instead of the provider, because the session cookie would be + dropped on return." + type: string + enum: + - http + - https + description: "Browser leg only: the scheme of the page the SSO button was pressed on. With `http` on a non-loopback + `client_host` while the server issues `Secure` session cookies, the start redirects to + `/auth/login?error=insecure_transport` instead of the provider, because the session cookie would be dropped + on return." + - in: query + name: client_host + schema: + description: "Browser leg only: `location.host` of that page, read together with `client_proto`." + type: string + maxLength: 255 + description: "Browser leg only: `location.host` of that page, read together with `client_proto`." responses: "302": description: Always a redirect. To the provider on success. On failure to `/auth/login?error=` (browser) or `healthlog://oidc-callback?error=` (native), where reason is one of `oidc_disabled`, - `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`. The provider then returns to the callback (not - listed here, because the identity provider drives it rather than a client), which ends the native leg at - `healthlog://oidc-callback` with `code=`, with `mfa_ticket=&methods=`, or with - `error=`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, - `oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_link_required`, - `oidc_registration_disabled`, `oidc_failed`. `oidc_link_required` means the provider's e-mail matches an - existing local account that is not linked yet; the owner links it by signing in once with that account's own - password or passkey in a browser. The reasons are this exact snake_case vocabulary on both legs; a client - maps a value it does not know to a generic sign-in failure. + `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`, and on the browser leg `insecure_transport` (see + `client_proto`). The provider then returns to the callback (not listed here, because the identity provider + drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with + `code=`, with `mfa_ticket=&methods=`, or with `error=`, where reason is one of + `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, `oidc_no_email`, `oidc_email_unverified`, + `oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `oidc_failed`. + `oidc_link_required` means the provider's e-mail matches an existing local account that is not linked yet; + the owner links it by signing in once with that account's own password or passkey in a browser. The reasons + are this exact snake_case vocabulary on both legs; a client maps a value it does not know to a generic + sign-in failure. /api/auth/passkey/register-options: post: tags: @@ -3873,7 +3916,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesBatchResponse" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/measurements/series: @@ -3925,7 +3968,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesResponse" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/measurements: @@ -4123,7 +4166,7 @@ paths: schema: $ref: "#/components/schemas/ListMeasurementsResponse" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -4179,7 +4222,7 @@ paths: schema: $ref: "#/components/schemas/CreateMeasurementResponse" "401": *a1 - "403": *a10 + "403": *a11 "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. @@ -4195,7 +4238,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a11 + "429": *a12 /api/measurements/{id}: get: tags: @@ -4216,7 +4259,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Measurement not found (or owned by another user). content: @@ -4264,7 +4307,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMeasurementResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Measurement not found (or owned by another user). content: @@ -4300,7 +4343,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMeasurementResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Measurement not found (or owned by another user). content: @@ -4406,7 +4449,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsBulkDeleteResponse" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -4435,7 +4478,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsRestoreResponse" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -4466,7 +4509,7 @@ paths: schema: $ref: "#/components/schemas/SleepNightResponse" "401": *a1 - "403": &a12 + "403": &a13 description: >- Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a @@ -4507,7 +4550,7 @@ paths: schema: $ref: "#/components/schemas/SleepRhythmResponse" "401": *a1 - "403": *a12 + "403": *a13 "422": *a4 "429": *a5 /api/personal-records: @@ -4554,7 +4597,7 @@ paths: schema: $ref: "#/components/schemas/ListPersonalRecordsEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": description: "`metricType` or `limit` carried an unacceptable value. `meta.errorCode` = `personal_records.invalid_query`, with the offending path under `details.issues`." @@ -4609,7 +4652,7 @@ paths: schema: $ref: "#/components/schemas/ListWorkoutsResponse" "401": *a1 - "403": *a12 + "403": *a13 "422": *a4 "429": *a5 /api/workouts/{id}: @@ -4645,7 +4688,7 @@ paths: schema: $ref: "#/components/schemas/GetWorkoutResponse" "401": *a1 - "403": &a18 + "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.' @@ -4867,7 +4910,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeAggregateEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "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 @@ -4992,7 +5035,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a11 + "429": *a12 /api/medications/{id}/glp1: get: tags: @@ -5017,7 +5060,7 @@ paths: schema: $ref: "#/components/schemas/Glp1DetailResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another account). content: @@ -5101,7 +5144,7 @@ paths: schema: $ref: "#/components/schemas/Glp1PostResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another account). content: @@ -5236,7 +5279,7 @@ paths: schema: $ref: "#/components/schemas/ReminderPhaseConfigEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another account). content: @@ -5352,7 +5395,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationSideEffectsEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another account). content: @@ -5391,7 +5434,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationSideEffectEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another account). content: @@ -5436,7 +5479,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationSideEffectEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "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. @@ -5484,7 +5527,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationIntakeEventEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: No live event with that id under this medication and record. An unknown id and an already-tombstoned one are indistinguishable. @@ -5532,7 +5575,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationIntakeEventEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: No event with that id under this medication and record. content: @@ -5575,7 +5618,7 @@ paths: schema: $ref: "#/components/schemas/BulkDeleteMedicationIntakeEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another account). content: @@ -5617,7 +5660,7 @@ paths: schema: $ref: "#/components/schemas/PurgeMedicationIntakeEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another account). content: @@ -5662,7 +5705,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportAcceptedEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another account). content: @@ -5742,7 +5785,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationsResponse" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -5773,7 +5816,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationResponse" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -5793,7 +5836,7 @@ paths: schema: $ref: "#/components/schemas/MedicationListLayoutResponse" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 put: @@ -5827,7 +5870,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a14 + "422": &a15 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. @@ -5872,7 +5915,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -5909,7 +5952,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -5982,7 +6025,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationIntakeResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -5991,7 +6034,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 "422": *a4 - "429": *a11 + "429": *a12 get: tags: - Medications @@ -6056,7 +6099,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationIntakeEventsResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -6089,7 +6132,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationInventoryResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -6125,7 +6168,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationInventoryItemResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -6169,7 +6212,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationInventoryItemResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -6204,7 +6247,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationInventoryItemResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -6300,7 +6343,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationCadenceResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -6326,7 +6369,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationComplianceResponse" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/medications/{id}/compliance: @@ -6507,7 +6550,7 @@ paths: schema: $ref: "#/components/schemas/ListScheduleRevisionsResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -6545,7 +6588,7 @@ paths: schema: $ref: "#/components/schemas/CreateScheduleRevisionResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -6589,7 +6632,7 @@ paths: schema: $ref: "#/components/schemas/UpdateScheduleRevisionResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication or revision not found (or owned by another user). content: @@ -6630,7 +6673,7 @@ paths: schema: $ref: "#/components/schemas/DeleteScheduleRevisionResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication or revision not found (or owned by another user). content: @@ -6693,7 +6736,7 @@ paths: schema: $ref: "#/components/schemas/MedicationDoseHistoryImportQueuedEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "409": description: A dose-history import is already in progress. content: @@ -6755,7 +6798,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: No such job for this medication (or owned by another user). content: @@ -6789,7 +6832,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: No such job for this account (or owned by another user). content: @@ -6819,7 +6862,7 @@ paths: schema: $ref: "#/components/schemas/ProfileSummaryEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/ai/test: @@ -7024,7 +7067,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutCoachPrefsResponse" - "400": *a13 + "400": *a14 "401": *a1 "409": description: Coach preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read @@ -7034,7 +7077,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a14 + "422": *a15 "429": *a5 /api/auth/me/disable-coach: get: @@ -7072,7 +7115,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchDisableCoachResponse" - "400": *a13 + "400": *a14 "401": *a1 "422": *a4 "429": *a5 @@ -7227,7 +7270,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchModulesResponse" - "400": *a13 + "400": *a14 "401": *a1 "409": description: Module preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. @@ -7237,7 +7280,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a14 + "422": *a15 "429": *a5 /api/auth/me/source-priority: get: @@ -8106,7 +8149,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutSourcePriorityResponse" - "400": *a13 + "400": *a14 "401": *a1 "422": *a4 "429": *a5 @@ -8259,7 +8302,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a15 + "429": *a16 /api/auth/me/notification-prefs: get: tags: @@ -8300,7 +8343,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchNotificationPrefsResponse" - "400": *a13 + "400": *a14 "401": *a1 "409": description: Notification preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. @@ -8310,7 +8353,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a14 + "422": *a15 "429": *a5 /api/auth/me/report-selection: get: @@ -8352,7 +8395,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutReportSelectionResponseEnvelope" - "400": *a13 + "400": *a14 "401": *a1 "422": *a4 "429": *a5 @@ -9073,7 +9116,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "503": &a17 + "503": &a18 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: @@ -9438,7 +9481,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a14 + "422": *a15 "429": *a5 delete: tags: @@ -9529,7 +9572,7 @@ paths: schema: $ref: "#/components/schemas/CoachNudgeStatus" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/coach/seen: @@ -9647,7 +9690,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a14 + "422": *a15 "429": *a5 /api/anamnesis/facts: get: @@ -9665,7 +9708,7 @@ paths: schema: $ref: "#/components/schemas/GetAnamnesisFactsResponse" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -9775,7 +9818,7 @@ paths: schema: $ref: "#/components/schemas/CorrectAnamnesisFactResponse" "401": *a1 - "404": &a16 + "404": &a17 description: No current revision with this id exists for the authenticated account. content: application/json: @@ -9811,7 +9854,7 @@ paths: schema: $ref: "#/components/schemas/RemoveAnamnesisFactResponse" "401": *a1 - "404": *a16 + "404": *a17 "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. @@ -10171,7 +10214,7 @@ paths: schema: $ref: "#/components/schemas/CoachRemindersList" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -10520,7 +10563,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSnapshotResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/comprehensive: @@ -10542,7 +10585,7 @@ paths: schema: $ref: "#/components/schemas/InsightsComprehensiveResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/cards: @@ -10563,7 +10606,7 @@ paths: schema: $ref: "#/components/schemas/InsightsCardsEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/pregenerate: @@ -10698,7 +10741,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "503": *a17 + "503": *a18 post: tags: - Insights @@ -10900,7 +10943,7 @@ paths: schema: $ref: "#/components/schemas/CoachReadStripEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/coach/seeded-question: @@ -10973,7 +11016,7 @@ paths: schema: $ref: "#/components/schemas/NarrativeEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/glp1-timeline: @@ -11010,7 +11053,7 @@ paths: schema: $ref: "#/components/schemas/Glp1TimelineEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/pulse/intraday: @@ -11046,7 +11089,7 @@ paths: schema: $ref: "#/components/schemas/IntradayPulseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/settings: @@ -11120,7 +11163,7 @@ paths: schema: $ref: "#/components/schemas/TargetsEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/analytics/range: @@ -11193,7 +11236,7 @@ paths: schema: $ref: "#/components/schemas/BloodPressureStatusResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/pulse-status: @@ -11225,7 +11268,7 @@ paths: schema: $ref: "#/components/schemas/PulseStatusResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/weight-status: @@ -11257,7 +11300,7 @@ paths: schema: $ref: "#/components/schemas/WeightStatusResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/bmi-status: @@ -11289,7 +11332,7 @@ paths: schema: $ref: "#/components/schemas/BmiStatusResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/mood-status: @@ -11321,7 +11364,7 @@ paths: schema: $ref: "#/components/schemas/MoodStatusResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/medication-compliance-status: @@ -11354,7 +11397,7 @@ paths: schema: $ref: "#/components/schemas/MedicationComplianceStatusResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/metric-status: @@ -11449,7 +11492,7 @@ paths: schema: $ref: "#/components/schemas/MetricStatusResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/biomarker-assessment: @@ -11494,7 +11537,7 @@ paths: schema: $ref: "#/components/schemas/BiomarkerAssessmentResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/derived: @@ -11606,7 +11649,7 @@ paths: schema: $ref: "#/components/schemas/DerivedMetricResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/derived/batch: @@ -11646,7 +11689,7 @@ paths: schema: $ref: "#/components/schemas/DerivedBatchResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/glp1-plateau: @@ -11666,7 +11709,7 @@ paths: schema: $ref: "#/components/schemas/InsightsGlp1PlateauResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/ecg: @@ -11688,7 +11731,7 @@ paths: schema: $ref: "#/components/schemas/EcgListResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -11765,7 +11808,7 @@ paths: schema: $ref: "#/components/schemas/EcgDetailResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: No ECG recording with that id for the authenticated user (existence sealed — a foreign id is indistinguishable from a missing one). @@ -11797,7 +11840,7 @@ paths: schema: $ref: "#/components/schemas/RhythmEventsResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/correlations: @@ -11819,7 +11862,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationDiscoveryResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/patterns: @@ -11837,7 +11880,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationPatternListResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/patterns/{id}: @@ -11867,7 +11910,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCorrelationPatternResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: No current pattern with that id for this account. content: @@ -11892,7 +11935,7 @@ paths: schema: $ref: "#/components/schemas/InsightsHealthStatusEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/breathing-screening: @@ -11911,7 +11954,7 @@ paths: schema: $ref: "#/components/schemas/InsightsBreathingScreeningEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/labs-changes: @@ -11930,7 +11973,7 @@ paths: schema: $ref: "#/components/schemas/InsightsLabsChangesEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/mood/analytics: @@ -11953,7 +11996,7 @@ paths: schema: $ref: "#/components/schemas/MoodDailySeriesEnvelope" "401": *a1 - "403": *a18 + "403": *a19 "422": *a4 "429": *a5 /api/mood/insights: @@ -11975,7 +12018,7 @@ paths: schema: $ref: "#/components/schemas/MoodAggregatesEnvelope" "401": *a1 - "403": *a18 + "403": *a19 "422": *a4 "429": *a5 /api/mood/prognosis: @@ -11998,7 +12041,7 @@ paths: schema: $ref: "#/components/schemas/MoodPrognosisEnvelope" "401": *a1 - "403": *a12 + "403": *a13 "422": *a4 "429": *a5 /api/mood-entries/bulk: @@ -12108,7 +12151,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" "401": *a1 - "404": &a19 + "404": &a20 description: Not found / not owned by the caller. content: application/json: @@ -12143,7 +12186,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" "401": *a1 - "404": *a19 + "404": *a20 "422": *a4 "429": *a5 /api/mood/tags/{key}/hidden: @@ -12179,7 +12222,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "404": *a19 + "404": *a20 "422": *a4 "429": *a5 /api/mood/tags/groups: @@ -12232,7 +12275,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" "401": *a1 - "404": *a19 + "404": *a20 "422": *a4 "429": *a5 delete: @@ -12263,7 +12306,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" "401": *a1 - "404": *a19 + "404": *a20 "422": *a4 "429": *a5 /api/mood/tags/layout: @@ -12311,7 +12354,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a14 + "422": *a15 "429": *a5 /api/mood-entries: get: @@ -12394,7 +12437,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntryListEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -12427,8 +12470,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a10 - "409": &a20 + "403": *a11 + "409": &a21 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. @@ -12437,7 +12480,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a11 + "429": *a12 /api/mood-entries/{id}: get: tags: @@ -12459,8 +12502,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a10 - "404": *a19 + "403": *a11 + "404": *a20 "422": *a4 "429": *a5 put: @@ -12494,9 +12537,9 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a10 - "404": *a19 - "409": *a20 + "403": *a11 + "404": *a20 + "409": *a21 "422": *a4 "429": *a5 delete: @@ -12521,8 +12564,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryDeleteEnvelope" "401": *a1 - "403": *a10 - "404": *a19 + "403": *a11 + "404": *a20 "422": *a4 "429": *a5 /api/mood-entries/restore: @@ -12551,7 +12594,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesRestoreEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -12580,7 +12623,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesBulkDeleteEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -12624,7 +12667,7 @@ paths: schema: $ref: "#/components/schemas/MoodLinkedContextEnvelope" "401": *a1 - "403": *a12 + "403": *a13 "422": *a4 "429": *a5 /api/settings/ntfy: @@ -12672,7 +12715,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutNtfySettingsResponse" - "400": &a21 + "400": &a22 description: Body is not parseable JSON. content: application/json: @@ -12685,13 +12728,13 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": &a22 + "413": &a23 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a23 + "415": &a24 description: Content-Type is not `application/json`. content: application/json: @@ -12751,7 +12794,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutTelegramSettingsResponse" - "400": *a21 + "400": *a22 "401": *a1 "403": description: The operator switched Telegram off for this instance and the body asked to enable it. No webhook was @@ -12766,8 +12809,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": *a22 - "415": *a23 + "413": *a23 + "415": *a24 "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 @@ -12821,10 +12864,10 @@ paths: application/json: schema: $ref: "#/components/schemas/PutWebhookSettingsResponse" - "400": *a21 + "400": *a22 "401": *a1 - "413": *a22 - "415": *a23 + "413": *a23 + "415": *a24 "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` = @@ -12851,7 +12894,7 @@ paths: schema: $ref: "#/components/schemas/GetReminderThresholdsResponse" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/settings/global-services: @@ -13381,7 +13424,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentLatestForKindEnvelope" - $ref: "#/components/schemas/ConsentLatestByKindEnvelope" - "400": &a24 + "400": &a25 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." @@ -13441,7 +13484,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentRevokeForKindEnvelope" - $ref: "#/components/schemas/ConsentRevokeAllEnvelope" - "400": *a24 + "400": *a25 "401": *a1 "422": *a4 "429": *a5 @@ -13693,7 +13736,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderListEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -13718,7 +13761,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCreatedEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -13742,8 +13785,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 - "403": *a10 - "404": &a25 + "403": *a11 + "404": &a26 description: Measurement reminder not found / not owned. content: application/json: @@ -13780,8 +13823,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 - "403": *a10 - "404": *a25 + "403": *a11 + "404": *a26 "422": *a4 "429": *a5 delete: @@ -13803,8 +13846,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 - "403": *a10 - "404": *a25 + "403": *a11 + "404": *a26 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/satisfy: @@ -13828,8 +13871,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 - "403": *a10 - "404": *a25 + "403": *a11 + "404": *a26 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/complete: @@ -13856,8 +13899,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" "401": *a1 - "403": *a10 - "404": *a25 + "403": *a11 + "404": *a26 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/skip: @@ -13886,8 +13929,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSkipEnvelope" "401": *a1 - "403": *a10 - "404": *a25 + "403": *a11 + "404": *a26 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/snooze: @@ -13920,8 +13963,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSnoozeEnvelope" "401": *a1 - "403": *a10 - "404": *a25 + "403": *a11 + "404": *a26 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/history: @@ -13961,8 +14004,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderHistoryEnvelope" "401": *a1 - "403": *a10 - "404": *a25 + "403": *a11 + "404": *a26 "422": *a4 "429": *a5 /api/labs: @@ -14034,7 +14077,7 @@ paths: schema: $ref: "#/components/schemas/ListLabResultsEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -14061,10 +14104,10 @@ paths: schema: $ref: "#/components/schemas/CreateLabResultResponse" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 - "429": *a11 + "429": *a12 /api/labs/{id}: get: tags: @@ -14085,8 +14128,8 @@ paths: schema: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 - "403": *a10 - "404": &a26 + "403": *a11 + "404": &a27 description: Lab result not found (or owned by another user). content: application/json: @@ -14120,8 +14163,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 - "403": *a10 - "404": *a26 + "403": *a11 + "404": *a27 "422": *a4 "429": *a5 delete: @@ -14143,8 +14186,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 - "403": *a10 - "404": *a26 + "403": *a11 + "404": *a27 "422": *a4 "429": *a5 /api/labs/restore: @@ -14170,7 +14213,7 @@ paths: schema: $ref: "#/components/schemas/RestoreLabResultsResponse" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -14301,7 +14344,7 @@ paths: schema: $ref: "#/components/schemas/ListBiomarkersEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -14326,15 +14369,15 @@ paths: schema: $ref: "#/components/schemas/CreateBiomarkerResponse" "401": *a1 - "403": *a10 - "409": &a28 + "403": *a11 + "409": &a29 description: A biomarker with this name already exists for the caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a11 + "429": *a12 /api/biomarkers/{id}: get: tags: @@ -14355,8 +14398,8 @@ paths: schema: $ref: "#/components/schemas/GetBiomarkerResponse" "401": *a1 - "403": *a10 - "404": &a27 + "403": *a11 + "404": &a28 description: Biomarker not found (or owned by another user). content: application/json: @@ -14390,9 +14433,9 @@ paths: schema: $ref: "#/components/schemas/UpdateBiomarkerResponse" "401": *a1 - "403": *a10 - "404": *a27 - "409": *a28 + "403": *a11 + "404": *a28 + "409": *a29 "422": *a4 "429": *a5 delete: @@ -14415,7 +14458,7 @@ paths: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 - "404": *a27 + "404": *a28 "422": *a4 "429": *a5 /api/encounters: @@ -14473,7 +14516,7 @@ paths: schema: $ref: "#/components/schemas/ListEncountersEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -14499,10 +14542,10 @@ paths: schema: $ref: "#/components/schemas/CreateEncounterEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 - "429": *a11 + "429": *a12 /api/encounters/procedures: get: tags: @@ -14533,7 +14576,7 @@ paths: schema: $ref: "#/components/schemas/ListProceduresEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/body-sites: @@ -14570,7 +14613,7 @@ paths: schema: $ref: "#/components/schemas/ListBodySitesEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/encounters/suggest: @@ -14600,7 +14643,7 @@ paths: schema: $ref: "#/components/schemas/SuggestEncounterEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/encounters/{id}: @@ -14623,8 +14666,8 @@ paths: schema: $ref: "#/components/schemas/GetEncounterEnvelope" "401": *a1 - "403": *a10 - "404": &a29 + "403": *a11 + "404": &a30 description: Visit not found (or owned by another user). content: application/json: @@ -14657,8 +14700,8 @@ paths: schema: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 - "403": *a10 - "404": *a29 + "403": *a11 + "404": *a30 "422": *a4 "429": *a5 delete: @@ -14682,8 +14725,8 @@ paths: schema: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 - "403": *a10 - "404": *a29 + "403": *a11 + "404": *a30 "422": *a4 "429": *a5 /api/encounters/{id}/restore: @@ -14708,8 +14751,8 @@ paths: schema: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 - "403": *a10 - "404": *a29 + "403": *a11 + "404": *a30 "422": *a4 "429": *a5 /api/encounters/{id}/links: @@ -14739,7 +14782,7 @@ paths: schema: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 delete: @@ -14768,7 +14811,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 /api/practitioners: @@ -14798,7 +14841,7 @@ paths: schema: $ref: "#/components/schemas/ListPractitionersEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -14822,7 +14865,7 @@ paths: schema: $ref: "#/components/schemas/CreatePractitionerEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -14846,8 +14889,8 @@ paths: schema: $ref: "#/components/schemas/GetPractitionerEnvelope" "401": *a1 - "403": *a10 - "404": &a30 + "403": *a11 + "404": &a31 description: Practitioner not found (or owned by another user). content: application/json: @@ -14880,8 +14923,8 @@ paths: schema: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 - "403": *a10 - "404": *a30 + "403": *a11 + "404": *a31 "422": *a4 "429": *a5 delete: @@ -14904,8 +14947,8 @@ paths: schema: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 - "403": *a10 - "404": *a30 + "403": *a11 + "404": *a31 "422": *a4 "429": *a5 /api/practitioners/{id}/restore: @@ -14930,8 +14973,8 @@ paths: schema: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 - "403": *a10 - "404": *a30 + "403": *a11 + "404": *a31 "422": *a4 "429": *a5 /api/vaccinations: @@ -14974,7 +15017,7 @@ paths: schema: $ref: "#/components/schemas/ListVaccinationsEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -15001,10 +15044,10 @@ paths: schema: $ref: "#/components/schemas/CreateVaccinationEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 - "429": *a11 + "429": *a12 /api/vaccinations/{id}: get: tags: @@ -15025,8 +15068,8 @@ paths: schema: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 - "403": *a10 - "404": &a31 + "403": *a11 + "404": &a32 description: Vaccination not found (or owned by another user). content: application/json: @@ -15060,8 +15103,8 @@ paths: schema: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 - "403": *a10 - "404": *a31 + "403": *a11 + "404": *a32 "422": *a4 "429": *a5 delete: @@ -15085,8 +15128,8 @@ paths: schema: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 - "403": *a10 - "404": *a31 + "403": *a11 + "404": *a32 "422": *a4 "429": *a5 /api/vaccinations/{id}/restore: @@ -15110,8 +15153,8 @@ paths: schema: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 - "403": *a10 - "404": *a31 + "403": *a11 + "404": *a32 "422": *a4 "429": *a5 /api/vaccinations/{id}/booster: @@ -15177,7 +15220,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": *a31 + "404": *a32 "422": *a4 "429": *a5 /api/vaccinations/suggest: @@ -15205,7 +15248,7 @@ paths: schema: $ref: "#/components/schemas/VaccinationSuggestEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/vaccinations/{id}/links: @@ -15235,7 +15278,7 @@ paths: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 - "404": *a31 + "404": *a32 "422": *a4 "429": *a5 delete: @@ -15264,7 +15307,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 - "404": *a31 + "404": *a32 "422": *a4 "429": *a5 /api/illness/episodes: @@ -15297,7 +15340,7 @@ paths: schema: $ref: "#/components/schemas/ListIllnessEpisodesEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -15323,7 +15366,7 @@ paths: schema: $ref: "#/components/schemas/CreateIllnessEpisodeEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -15347,8 +15390,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 - "403": *a10 - "404": &a32 + "403": *a11 + "404": &a33 description: Illness episode not found (or owned by another user). content: application/json: @@ -15382,8 +15425,8 @@ paths: schema: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 - "403": *a10 - "404": *a32 + "403": *a11 + "404": *a33 "422": *a4 "429": *a5 delete: @@ -15406,8 +15449,8 @@ paths: schema: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 - "403": *a10 - "404": *a32 + "403": *a11 + "404": *a33 "422": *a4 "429": *a5 /api/illness/episodes/{id}/restore: @@ -15434,8 +15477,8 @@ paths: schema: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 - "403": *a10 - "404": *a32 + "403": *a11 + "404": *a33 "409": *a7 "422": *a4 "429": *a5 @@ -15467,8 +15510,8 @@ paths: schema: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 - "403": *a10 - "404": *a32 + "403": *a11 + "404": *a33 "422": *a4 "429": *a5 /api/illness/episodes/{id}/day-logs: @@ -15518,8 +15561,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 - "403": *a10 - "404": *a32 + "403": *a11 + "404": *a33 "422": *a4 "429": *a5 post: @@ -15555,8 +15598,8 @@ paths: schema: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 - "403": *a10 - "404": *a32 + "403": *a11 + "404": *a33 "422": *a4 "429": *a5 /api/illness/episodes/{id}/correlation: @@ -15583,7 +15626,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a32 + "404": *a33 "422": *a4 "429": *a5 /api/illness/insights: @@ -15656,7 +15699,7 @@ paths: schema: $ref: "#/components/schemas/ListMentalHealthAssessmentsEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -15689,7 +15732,7 @@ paths: schema: $ref: "#/components/schemas/CreateMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -15716,7 +15759,7 @@ paths: schema: $ref: "#/components/schemas/GetMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/allergies: @@ -15749,7 +15792,7 @@ paths: schema: $ref: "#/components/schemas/ListAllergiesEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -15774,10 +15817,10 @@ paths: schema: $ref: "#/components/schemas/CreateAllergyEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 - "429": *a11 + "429": *a12 /api/allergies/{id}: get: tags: @@ -15798,8 +15841,8 @@ paths: schema: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 - "403": *a10 - "404": &a33 + "403": *a11 + "404": &a34 description: Allergy not found (or owned by another user). content: application/json: @@ -15832,8 +15875,8 @@ paths: schema: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 - "403": *a10 - "404": *a33 + "403": *a11 + "404": *a34 "422": *a4 "429": *a5 delete: @@ -15856,8 +15899,8 @@ paths: schema: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 - "403": *a10 - "404": *a33 + "403": *a11 + "404": *a34 "422": *a4 "429": *a5 /api/family-history: @@ -15881,7 +15924,7 @@ paths: schema: $ref: "#/components/schemas/ListFamilyHistoryEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -15906,7 +15949,7 @@ paths: schema: $ref: "#/components/schemas/CreateFamilyHistoryEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -15930,8 +15973,8 @@ paths: schema: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 - "403": *a10 - "404": &a34 + "403": *a11 + "404": &a35 description: Family history entry not found (or owned by another user). content: application/json: @@ -15964,8 +16007,8 @@ paths: schema: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 - "403": *a10 - "404": *a34 + "403": *a11 + "404": *a35 "422": *a4 "429": *a5 delete: @@ -15988,8 +16031,8 @@ paths: schema: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 - "403": *a10 - "404": *a34 + "403": *a11 + "404": *a35 "422": *a4 "429": *a5 /api/documents/inbound: @@ -16102,7 +16145,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentListEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -16381,7 +16424,7 @@ paths: schema: $ref: "#/components/schemas/DocumentUsageEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/documents/inbound/bulk: @@ -16428,7 +16471,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentDetailEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 patch: @@ -16547,7 +16590,7 @@ paths: type: string format: binary "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/documents/inbound/{id}/thumbnail: @@ -16576,7 +16619,7 @@ paths: type: string format: binary "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/documents/inbound/{id}/extract: @@ -16615,7 +16658,7 @@ paths: schema: $ref: "#/components/schemas/InboundExtractEnvelope" "401": *a1 - "403": &a35 + "403": &a36 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 @@ -16639,7 +16682,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "503": &a36 + "503": &a37 description: "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. Nothing was sent. Retry later." content: @@ -16701,10 +16744,10 @@ paths: schema: $ref: "#/components/schemas/DocumentSuggestEnvelope" "401": *a1 - "403": *a35 + "403": *a36 "422": *a4 "429": *a5 - "503": *a36 + "503": *a37 /api/documents/inbound/{id}/summary: post: tags: @@ -16783,10 +16826,10 @@ paths: schema: $ref: "#/components/schemas/DocumentSummaryEnvelope" "401": *a1 - "403": *a35 + "403": *a36 "422": *a4 "429": *a5 - "503": *a36 + "503": *a37 /api/documents/inbound/{id}/index: post: tags: @@ -16843,10 +16886,10 @@ paths: schema: $ref: "#/components/schemas/DocumentIndexEnvelope" "401": *a1 - "403": *a35 + "403": *a36 "422": *a4 "429": *a5 - "503": *a36 + "503": *a37 /api/documents/inbound/{id}/chat: post: tags: @@ -16886,10 +16929,10 @@ paths: type: string description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." "401": *a1 - "403": *a35 + "403": *a36 "422": *a4 "429": *a5 - "503": *a36 + "503": *a37 get: tags: - Documents @@ -16952,10 +16995,10 @@ paths: schema: $ref: "#/components/schemas/DocumentReindexEnvelope" "401": *a1 - "403": *a35 + "403": *a36 "422": *a4 "429": *a5 - "503": *a36 + "503": *a37 /api/documents/inbound/{id}/facts/{factId}: patch: tags: @@ -17037,7 +17080,7 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricsEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -17062,7 +17105,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a38 + "409": &a39 description: A custom metric with this name already exists for the caller. content: application/json: @@ -17090,8 +17133,8 @@ paths: schema: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 - "403": *a10 - "404": &a37 + "403": *a11 + "404": &a38 description: Custom metric not found (or owned by another user). content: application/json: @@ -17125,8 +17168,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a37 - "409": *a38 + "404": *a38 + "409": *a39 "422": *a4 "429": *a5 delete: @@ -17149,7 +17192,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a37 + "404": *a38 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -17192,8 +17235,8 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 - "403": *a10 - "404": *a37 + "403": *a11 + "404": *a38 "422": *a4 "429": *a5 post: @@ -17222,10 +17265,10 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a37 + "404": *a38 "409": *a7 "422": *a4 - "429": *a11 + "429": *a12 /api/custom-metrics/{id}/entries/{entryId}: patch: tags: @@ -17258,7 +17301,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a39 + "404": &a40 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -17291,7 +17334,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a39 + "404": *a40 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -17703,7 +17746,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a40 + "403": &a41 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 @@ -17758,7 +17801,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a40 + "403": *a41 "422": *a4 "429": *a5 /api/nutrients/water: @@ -17787,7 +17830,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a40 + "403": *a41 "409": *a7 "422": *a4 "429": *a5 @@ -18289,26 +18332,26 @@ paths: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" - "400": &a41 + "400": &a42 description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "413": &a42 + "413": &a43 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a43 + "415": &a44 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a44 + "422": &a45 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: @@ -18494,11 +18537,11 @@ paths: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" - "400": *a41 + "400": *a42 "401": *a1 - "413": *a42 - "415": *a43 - "422": *a44 + "413": *a43 + "415": *a44 + "422": *a45 "429": *a5 delete: tags: @@ -18641,11 +18684,11 @@ paths: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" - "400": *a41 + "400": *a42 "401": *a1 - "413": *a42 - "415": *a43 - "422": *a44 + "413": *a43 + "415": *a44 + "422": *a45 "429": *a5 delete: tags: @@ -18798,11 +18841,11 @@ paths: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" - "400": *a41 + "400": *a42 "401": *a1 - "413": *a42 - "415": *a43 - "422": *a44 + "413": *a43 + "415": *a44 + "422": *a45 "429": *a5 delete: tags: @@ -18878,21 +18921,21 @@ paths: schema: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 - "422": &a45 + "422": &a46 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": &a46 + "429": &a47 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": &a47 + "502": &a48 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` @@ -18937,11 +18980,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" - "400": *a41 + "400": *a42 "401": *a1 - "413": *a42 - "415": *a43 - "422": *a44 + "413": *a43 + "415": *a44 + "422": *a45 "429": *a5 delete: tags: @@ -19017,9 +19060,9 @@ paths: schema: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 - "422": *a45 - "429": *a46 - "502": *a47 + "422": *a46 + "429": *a47 + "502": *a48 /api/strava/credentials: get: tags: @@ -19055,11 +19098,11 @@ paths: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" - "400": *a41 + "400": *a42 "401": *a1 - "413": *a42 - "415": *a43 - "422": *a44 + "413": *a43 + "415": *a44 + "422": *a45 "429": *a5 delete: tags: @@ -19137,9 +19180,9 @@ paths: schema: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 - "422": *a45 - "429": *a46 - "502": *a47 + "422": *a46 + "429": *a47 + "502": *a48 /api/integrations/status: get: tags: @@ -19175,7 +19218,7 @@ paths: $ref: "#/components/schemas/FitbitResumeEnvelope" "401": *a1 "422": *a4 - "429": *a46 + "429": *a47 /api/integrations/fitbit/test: post: tags: @@ -19193,9 +19236,9 @@ paths: schema: $ref: "#/components/schemas/FitbitIntegrationTestEnvelope" "401": *a1 - "422": *a45 - "429": *a46 - "502": *a47 + "422": *a46 + "429": *a47 + "502": *a48 /api/integrations/google-health/resume: post: tags: @@ -19212,7 +19255,7 @@ paths: $ref: "#/components/schemas/GoogleHealthResumeEnvelope" "401": *a1 "422": *a4 - "429": *a46 + "429": *a47 /api/integrations/google-health/test: post: tags: @@ -19245,9 +19288,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a45 - "429": *a46 - "502": *a47 + "422": *a46 + "429": *a47 + "502": *a48 /api/integrations/whoop/resume: post: tags: @@ -19264,7 +19307,7 @@ paths: $ref: "#/components/schemas/WhoopResumeEnvelope" "401": *a1 "422": *a4 - "429": *a46 + "429": *a47 /api/integrations/whoop/test: post: tags: @@ -19281,9 +19324,9 @@ paths: schema: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 - "422": *a45 - "429": *a46 - "502": *a47 + "422": *a46 + "429": *a47 + "502": *a48 /api/integrations/withings/resume: post: tags: @@ -19301,7 +19344,7 @@ paths: $ref: "#/components/schemas/WithingsResumeEnvelope" "401": *a1 "422": *a4 - "429": *a46 + "429": *a47 /api/integrations/withings/test: post: tags: @@ -19319,8 +19362,8 @@ paths: schema: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 - "422": *a45 - "429": *a46 + "422": *a46 + "429": *a47 "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: @@ -19353,7 +19396,7 @@ paths: schema: $ref: "#/components/schemas/DailyDigestEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/daily/digest/dismiss: @@ -19444,7 +19487,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSummaryEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/dashboard/widgets: @@ -19463,7 +19506,7 @@ paths: schema: $ref: "#/components/schemas/DashboardLayout" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 put: @@ -19497,7 +19540,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a14 + "422": *a15 "429": *a5 delete: tags: @@ -19560,7 +19603,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a48 + "403": &a49 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 @@ -19585,7 +19628,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a48 + "403": *a49 "422": *a4 "429": *a5 post: @@ -19687,7 +19730,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a48 + "403": *a49 "404": description: No such invitation for this account. content: @@ -19731,7 +19774,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a48 + "403": *a49 "404": description: No such grant on this account's record. content: @@ -19772,7 +19815,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a48 + "403": *a49 "404": description: No such grant held by this account. content: @@ -20154,7 +20197,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a48 + "403": *a49 "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 — @@ -20484,7 +20527,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 - "403": &a49 + "403": &a50 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. @@ -20517,7 +20560,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 - "403": *a49 + "403": *a50 "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. @@ -20544,7 +20587,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a49 + "403": *a50 "422": *a4 "429": *a5 post: @@ -20577,7 +20620,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a49 + "403": *a50 "413": description: Body exceeds 16 KiB. content: @@ -20620,7 +20663,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 - "403": *a49 + "403": *a50 "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. @@ -20647,7 +20690,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/record-settings/integrations: @@ -20667,7 +20710,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsIntegrationsEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/record-settings/{family}: @@ -20678,7 +20721,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: - - &a50 + - &a51 name: family in: path required: true @@ -20702,7 +20745,7 @@ paths: schema: $ref: "#/components/schemas/GetRecordSettingsFamilyEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "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. @@ -20722,7 +20765,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: - - *a50 + - *a51 requestBody: required: true content: @@ -20743,7 +20786,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: The path named a family outside the closed set. Answered before authentication. content: @@ -20856,7 +20899,7 @@ paths: schema: $ref: "#/components/schemas/AchievementsEnvelope" "401": *a1 - "403": *a12 + "403": *a13 "422": *a4 "429": *a5 /api/environment: @@ -20874,7 +20917,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentOverviewEnvelope" "401": *a1 - "403": *a18 + "403": *a19 "422": *a4 "429": *a5 /api/environment/home: @@ -20911,7 +20954,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentHomeEnvelope" "401": *a1 - "403": *a18 + "403": *a19 "413": description: Body exceeds 4 KiB. content: @@ -20965,7 +21008,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentBackfillEnvelope" "401": *a1 - "403": *a18 + "403": *a19 "409": description: No home location is set, so there is nothing to resolve the days against. `meta.errorCode` = `environment.no_home`. @@ -21024,7 +21067,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentGeocodeEnvelope" "401": *a1 - "403": *a18 + "403": *a19 "422": description: "`q` was missing, empty after trimming, or longer than 120 characters. `meta.errorCode` = `environment.invalid`." @@ -21063,7 +21106,7 @@ paths: schema: $ref: "#/components/schemas/CreateEnvironmentTravelEnvelope" "401": *a1 - "403": *a18 + "403": *a19 "413": description: Body exceeds 4 KiB. content: @@ -21116,7 +21159,7 @@ paths: schema: $ref: "#/components/schemas/DeleteEnvironmentTravelEnvelope" "401": *a1 - "403": *a18 + "403": *a19 "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. @@ -21259,13 +21302,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a51 + "403": &a52 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": &a52 + "404": &a53 description: No stored copy with this id. content: application/json: @@ -21339,8 +21382,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a51 - "404": *a52 + "403": *a52 + "404": *a53 "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 @@ -21384,7 +21427,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobListResponse" - "403": *a51 + "403": *a52 /api/admin/backups/restores/{jobId}: get: tags: @@ -21407,7 +21450,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobResponse" - "403": *a51 + "403": *a52 "404": description: No restore job with this id. content: @@ -21426,7 +21469,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: &a53 + security: &a54 - cookieAuth: [] responses: "200": @@ -21436,7 +21479,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourcesStatusEnvelope" "401": *a1 - "403": &a54 + "403": &a55 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." @@ -21459,9 +21502,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: *a53 + security: *a54 parameters: - - &a55 + - &a56 name: system in: path required: true @@ -21500,8 +21543,8 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceConnectionEnvelope" "401": *a1 - "403": *a54 - "404": &a56 + "403": *a55 + "404": &a57 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 @@ -21520,7 +21563,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": &a57 + "502": &a58 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 @@ -21539,9 +21582,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: *a53 + security: *a54 parameters: - - *a55 + - *a56 responses: "200": description: Removed, or there was nothing to remove. @@ -21550,7 +21593,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceDisconnectEnvelope" "401": *a1 - "403": *a54 + "403": *a55 "422": *a4 "429": *a5 /api/documents/sources/{system}/test: @@ -21561,9 +21604,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: *a53 + security: *a54 parameters: - - *a55 + - *a56 responses: "200": description: The source answered. @@ -21572,9 +21615,9 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTestEnvelope" "401": *a1 - "403": *a54 - "404": *a56 - "422": &a58 + "403": *a55 + "404": *a57 + "422": &a59 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." @@ -21583,7 +21626,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": *a57 + "502": *a58 /api/documents/sources/{system}/tags: get: tags: @@ -21591,9 +21634,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: *a53 + security: *a54 parameters: - - *a55 + - *a56 responses: "200": description: The tags. @@ -21602,11 +21645,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTagsEnvelope" "401": *a1 - "403": *a54 - "404": *a56 - "422": *a58 + "403": *a55 + "404": *a57 + "422": *a59 "429": *a5 - "502": *a57 + "502": *a58 /api/documents/sources/{system}/search: get: tags: @@ -21620,9 +21663,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: *a53 + security: *a54 parameters: - - *a55 + - *a56 - name: q in: query required: false @@ -21663,11 +21706,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceSearchEnvelope" "401": *a1 - "403": *a54 - "404": *a56 - "422": *a58 + "403": *a55 + "404": *a57 + "422": *a59 "429": *a5 - "502": *a57 + "502": *a58 /api/documents/sources/{system}/import: post: tags: @@ -21686,9 +21729,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: *a53 + security: *a54 parameters: - - *a55 + - *a56 requestBody: required: true content: @@ -21745,7 +21788,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceImportEnvelope" "401": *a1 - "403": *a54 + "403": *a55 "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. @@ -21776,7 +21819,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a58 + "422": *a59 "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`)." @@ -21784,7 +21827,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": *a57 + "502": *a58 /api/bugreport: post: security: [] @@ -22670,11 +22713,32 @@ components: password: type: string minLength: 1 + clientTransport: + $ref: "#/components/schemas/ClientTransport" required: - email - password description: "Email-or-username login. The native-client flow returns a paired access + refresh token when X-Client-Type: native or the iOS UA prefix is present." + ClientTransport: + type: object + properties: + protocol: + type: string + enum: + - http + - https + description: The scheme of the page the request comes from. + host: + type: string + maxLength: 255 + description: "`location.host` of that page: hostname and optional port." + required: + - protocol + - host + description: Sent by the web client only. When the server issues `Secure` session cookies and this says the page is on + plain `http://` (and not a loopback host), the sign-in is refused with 409 `auth.session.insecure_transport` + before any credential, ticket or code is checked or used, because the browser would drop the session cookie. MfaVerifyRequest: type: object properties: @@ -22696,6 +22760,8 @@ components: description: Opt in to trusting this device for 30 days — subsequent logins skip the second factor (the password is still required). type: boolean + clientTransport: + $ref: "#/components/schemas/ClientTransport" required: - mfaTicket - code @@ -22981,6 +23047,8 @@ components: description: Opt in to trusting this device for 30 days — subsequent logins skip the second factor (the password is still required). type: boolean + clientTransport: + $ref: "#/components/schemas/ClientTransport" required: - mfaTicket - challengeId @@ -23042,6 +23110,13 @@ components: required: - refreshToken description: Exchange a one-time-use refresh token for a fresh access + refresh pair. + PasskeyLoginOptionsRequest: + type: object + properties: + clientTransport: + $ref: "#/components/schemas/ClientTransport" + description: Optional. The web client sends its page transport so a sign-in that could not keep its cookie is refused + before a challenge is issued. Any other caller may send no body at all. CreateMeasurementTokenRequest: type: object properties: @@ -31716,31 +31791,32 @@ components: anamnesis.fact.invalidValue; auth — auth.admin.required, auth.already_authenticated, auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, auth.reproof.failed, auth.reproof.required, auth.reproof.too_weak, auth.scope.insufficient, - auth.stepup.required, auth.token.expired, auth.token.invalid; backup — backup.foreign_reference, - backup.key.missing, backup.payload.undecryptable, backup.restore.active, backup.section.missing, - backup.upload.too_large; body-site — body-site.invalid; coach — coach.budget.exceeded, - coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict, - coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, - coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid, - coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; - consent — consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, - cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, - cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; - cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — - disable-coach.body.invalid_json; documents — documents.chat.conversationNotFound, - documents.chat.invalid, documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, - documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, - documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound, - documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, - documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery, - documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notFound, - documents.inbound.notIndexed, documents.inbound.pdfNeedsAnthropic, - documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited, - documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, - documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, - documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly, - documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected, - documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed, + auth.session.insecure_transport, auth.stepup.required, auth.token.expired, auth.token.invalid; backup — + backup.foreign_reference, backup.key.missing, backup.payload.undecryptable, backup.restore.active, + backup.section.missing, backup.upload.too_large; body-site — body-site.invalid; coach — + coach.budget.exceeded, coach.conversation.invalidTitle, coach.conversation.notFound, + coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, + coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, + coach.fenced.invalid, coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, + coach-prefs.body.invalid_shape; consent — consent.ai.required; cycle — cycle.bulk.invalid, + cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, cycle.cycles.invalid, + cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, cycle.period.invalid, + cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json, + cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; documents — + documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed, + documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded, + documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound, + documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending, + documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType, + documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate, + documents.inbound.localOcrDisabled, documents.inbound.notFound, documents.inbound.notIndexed, + documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported, + documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate, + documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy, + documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused, + documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress, + documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound, + documents.sources.organizationRequired, documents.sources.originNotAllowed, documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected, documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable, documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid, @@ -39853,8 +39929,14 @@ components: type: boolean description: Whether open self-registration is on. An operator who turned it off can still admit a signup through an invite token. + sessionCookieSecure: + type: boolean + description: Whether this server issues its session cookies with the `Secure` flag (`SESSION_COOKIE_SECURE`, else on in + production). A browser on a plain `http://` page cannot keep such a cookie; the web login page compares this + with its own scheme before any credential is sent. required: - registrationEnabled + - sessionCookieSecure additionalProperties: false PasskeyLoginOptionsEnvelope: type: object diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index fe1c87d1b..f67feb66f 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -207,6 +207,13 @@ ps` — the `app` healthcheck polls `/api/health` every 30 seconds the container unhealthy if Postgres is still starting. Tail `docker compose logs db` to confirm Postgres came up cleanly. +- **The login page says "Signing in cannot complete on this page".** The + server sets its session cookie with the `Secure` flag (the production + default) and the page was opened over plain `http://`, where the browser + would drop it. Set `SESSION_COOKIE_SECURE=false` for a server reached only + over HTTP on a private network, or open the `https://` address (behind a TLS + proxy, make sure it forwards `X-Forwarded-Proto: https`). The sign-in is + refused before any password, code or ticket is used. - **OAuth callbacks loop back to localhost.** Confirm `APP_URL` and `NEXT_PUBLIC_APP_URL` both point at the public hostname, not at `localhost`, and restart `app` after the change. diff --git a/src/__tests__/api/auth/registration-status/route.test.ts b/src/__tests__/api/auth/registration-status/route.test.ts index ee2943bf9..72a12d416 100644 --- a/src/__tests__/api/auth/registration-status/route.test.ts +++ b/src/__tests__/api/auth/registration-status/route.test.ts @@ -114,3 +114,30 @@ describe("GET /api/auth/registration-status", () => { expect(body.data.registrationEnabled).toBe(false); }); }); + +describe("GET /api/auth/registration-status — sessionCookieSecure (#1097)", () => { + it("publishes whether the server issues Secure session cookies, on every branch", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + id: "singleton", + registrationEnabled: true, + } as never); + vi.stubEnv("SESSION_COOKIE_SECURE", "true"); + let body = await (await callGet()).json(); + expect(body.data.sessionCookieSecure).toBe(true); + + vi.stubEnv("SESSION_COOKIE_SECURE", "false"); + body = await (await callGet()).json(); + expect(body.data.sessionCookieSecure).toBe(false); + + vi.mocked(prisma.appSettings.findUnique).mockRejectedValue( + new Error("db down"), + ); + vi.stubEnv("SESSION_COOKIE_SECURE", "true"); + body = await (await callGet()).json(); + expect(body.data).toEqual({ + registrationEnabled: false, + sessionCookieSecure: true, + }); + vi.unstubAllEnvs(); + }); +}); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 75dc974d6..e34470049 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -21,6 +21,7 @@ import { consumeTrustedDevice } from "@/lib/auth/trusted-device"; import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; import { isOidcOnly } from "@/lib/auth/oidc"; import { readPendingLink } from "@/lib/auth/oidc-pending-link"; +import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal"; export const POST = apiHandler(async (request: NextRequest) => { // OIDC_ONLY means password login must be a dead end, not just a hidden @@ -65,6 +66,13 @@ export const POST = apiHandler(async (request: NextRequest) => { }); if (jsonError) return jsonError; + + // A browser page on plain http:// with Secure-only cookies cannot keep the + // session. Refuse before the password is verified (the account throttle + // never counts it) and before an MFA ticket is minted. + const transportRefusal = refuseWhenCookieCannotStick(body); + if (transportRefusal) return transportRefusal; + const parsed = loginPasswordSchema.safeParse(body); if (!parsed.success) { diff --git a/src/app/api/auth/mfa/verify/route.ts b/src/app/api/auth/mfa/verify/route.ts index 94bd30393..6bec4ebf3 100644 --- a/src/app/api/auth/mfa/verify/route.ts +++ b/src/app/api/auth/mfa/verify/route.ts @@ -41,6 +41,7 @@ import { mfaVerifySchema } from "@/lib/validations/mfa"; import { mintTrustedDevice } from "@/lib/auth/trusted-device"; import { coarseDeviceLabel } from "@/lib/auth/device-fingerprint"; import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; +import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal"; export const dynamic = "force-dynamic"; @@ -65,6 +66,11 @@ export const POST = apiHandler(async (request: NextRequest) => { }); if (jsonError) return jsonError; + // Before the ticket is loaded, an attempt reserved, or a one-time code + // spent: a sign-in whose cookie cannot stick must not consume either. + const transportRefusal = refuseWhenCookieCannotStick(body); + if (transportRefusal) return transportRefusal; + const parsed = mfaVerifySchema.safeParse(body); if (!parsed.success) { return apiValidationError( diff --git a/src/app/api/auth/mfa/webauthn/verify/route.ts b/src/app/api/auth/mfa/webauthn/verify/route.ts index 9df1e93d7..6c46bf27b 100644 --- a/src/app/api/auth/mfa/webauthn/verify/route.ts +++ b/src/app/api/auth/mfa/webauthn/verify/route.ts @@ -39,6 +39,7 @@ import { mfaWebauthnLoginVerifySchema } from "@/lib/validations/mfa"; import { mintTrustedDevice } from "@/lib/auth/trusted-device"; import { coarseDeviceLabel } from "@/lib/auth/device-fingerprint"; import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; +import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal"; export const dynamic = "force-dynamic"; @@ -63,6 +64,10 @@ export const POST = apiHandler(async (request: NextRequest) => { }); if (jsonError) return jsonError; + // Before the ticket is loaded or an attempt reserved (see mfa/verify). + const transportRefusal = refuseWhenCookieCannotStick(body); + if (transportRefusal) return transportRefusal; + const parsed = mfaWebauthnLoginVerifySchema.safeParse(body); if (!parsed.success) { return apiValidationError( diff --git a/src/app/api/auth/oidc/login/route.ts b/src/app/api/auth/oidc/login/route.ts index 3c5c0ff36..9e55d9c0a 100644 --- a/src/app/api/auth/oidc/login/route.ts +++ b/src/app/api/auth/oidc/login/route.ts @@ -5,6 +5,7 @@ import { annotate, getEvent } from "@/lib/logging/context"; import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; import { encrypt } from "@/lib/crypto"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; +import { transportVerdict } from "@/lib/auth/client-transport"; import { s256Challenge, isValidChallenge } from "@/lib/mcp/oauth/pkce"; import { buildNativeCallbackUrl } from "@/lib/auth/oidc-native-handoff"; import { @@ -54,6 +55,24 @@ export const GET = apiHandler(async (req: NextRequest) => { return loginError("oidc_disabled"); } + // The SSO button states the page's transport. A browser on plain http:// + // with Secure-only cookies would come back from the provider and drop the + // session cookie, so it is stopped here instead of after the round trip. + if (!isNative) { + const clientProto = req.nextUrl.searchParams.get("client_proto"); + const clientHost = req.nextUrl.searchParams.get("client_host") ?? ""; + if ( + (clientProto === "http" || clientProto === "https") && + transportVerdict(shouldEmitSecureCookie(), { + protocol: clientProto, + host: clientHost, + }) === "blocked" + ) { + annotate({ meta: { reason: "insecure_transport" } }); + return loginError("insecure_transport"); + } + } + const rl = await checkAuthSurfaceRateLimit( req, "auth:oidc:login", diff --git a/src/app/api/auth/passkey/login-options/route.ts b/src/app/api/auth/passkey/login-options/route.ts index e9ea2e6dc..cf1969cef 100644 --- a/src/app/api/auth/passkey/login-options/route.ts +++ b/src/app/api/auth/passkey/login-options/route.ts @@ -1,10 +1,11 @@ import { createAuthenticationOptions } from "@/lib/auth/passkey"; -import { apiSuccess, apiError } from "@/lib/api-response"; +import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { NextResponse } from "next/server"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { isOidcOnly } from "@/lib/auth/oidc"; +import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal"; export const POST = apiHandler(async (request: Request) => { // OIDC_ONLY must block passkey login too, not just password login. @@ -31,6 +32,15 @@ export const POST = apiHandler(async (request: Request) => { ); } + // The body is optional (the web client sends its page transport; nothing + // else needs to send anything). A missing or unreadable body is no body, as + // before; only a stated plain-http page refuses, before a challenge exists. + if (request.headers.get("content-type")?.includes("application/json")) { + const { data: body } = await safeJson(request, { maxBytes: 4 * 1024 }); + const transportRefusal = refuseWhenCookieCannotStick(body); + if (transportRefusal) return transportRefusal; + } + const { options, challengeId } = await createAuthenticationOptions(); annotate({ action: { name: "auth.passkey.login-options" } }); diff --git a/src/app/api/auth/registration-status/route.ts b/src/app/api/auth/registration-status/route.ts index 41f5dd26b..3a8961aeb 100644 --- a/src/app/api/auth/registration-status/route.ts +++ b/src/app/api/auth/registration-status/route.ts @@ -2,6 +2,7 @@ import { prisma } from "@/lib/db"; import { apiSuccess } from "@/lib/api-response"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; export const dynamic = "force-dynamic"; @@ -15,10 +16,16 @@ export const GET = apiHandler(async () => { return apiSuccess({ registrationEnabled: settings?.registrationEnabled ?? true, + // Whether this server issues `Secure` session cookies. The login page + // compares it with its own scheme before any credential is sent. + sessionCookieSecure: shouldEmitSecureCookie(), }); } catch { // Fail closed on backend errors. annotate({ action: { name: "auth.registration-status" } }); - return apiSuccess({ registrationEnabled: false }); + return apiSuccess({ + registrationEnabled: false, + sessionCookieSecure: shouldEmitSecureCookie(), + }); } }); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 571c9aeb7..08915392c 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -25,6 +25,14 @@ import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; import { sanitizeSameOriginPath } from "@/lib/url-safety"; +import { + InsecureTransportBanner, + useClientTransport, +} from "@/components/auth/insecure-transport-banner"; +import { transportVerdict } from "@/lib/auth/client-transport"; + +/** The server's refusal for a page whose sign-in cookie cannot stick. */ +const INSECURE_TRANSPORT_CODE = "auth.session.insecure_transport"; import { loadDocument } from "@/lib/navigation/load-document"; export default function LoginPage() { @@ -44,7 +52,8 @@ export default function LoginPage() { const [password, setPassword] = useState(""); const [error, setError] = useState(() => { const oidcError = searchParams.get("error"); - if (!oidcError) return null; + // The SSO start refused a page on plain http://; the banner says why. + if (!oidcError || oidcError === "insecure_transport") return null; const key: Record = { oidc_denied: "auth.oidc.errorDenied", oidc_no_email: "auth.oidc.errorNoEmail", @@ -71,22 +80,55 @@ export default function LoginPage() { // standalone alert detached from the form. const errorId = useId(); const errorDescriptor = error ? errorId : undefined; - const { data: registrationEnabled } = useQuery({ + const { data: registrationStatus } = useQuery({ queryKey: queryKeys.authRegistrationStatus(), queryFn: async () => { try { - const data = await apiGet<{ registrationEnabled?: boolean }>( - "/api/auth/registration-status", - { cache: "no-store" }, - ); - return Boolean(data?.registrationEnabled ?? true); + const data = await apiGet<{ + registrationEnabled?: boolean; + sessionCookieSecure?: boolean; + }>("/api/auth/registration-status", { cache: "no-store" }); + return { + registrationEnabled: Boolean(data?.registrationEnabled ?? true), + sessionCookieSecure: data?.sessionCookieSecure === true, + }; } catch { - // Fail open, exactly as the raw `if (!res.ok) return true` did. - return true; + // Fail open, exactly as the raw `if (!res.ok) return true` did; and + // no transport gate without the server's word on its cookies. + return { registrationEnabled: true, sessionCookieSecure: false }; } }, staleTime: 60 * 1000, }); + const registrationEnabled = registrationStatus?.registrationEnabled; + + // #1097 — a server that sets its sign-in cookie only over https:// cannot + // sign anyone in on a plain http:// page: the browser drops the cookie. Say + // so before a credential leaves the browser, and stop the sign-in controls. + // The server refuses the same requests (409) if they are sent anyway, and + // the SSO start sends the browser back here with `insecure_transport`. + const clientTransport = useClientTransport(); + const [transportRefused, setTransportRefused] = useState( + () => searchParams.get("error") === "insecure_transport", + ); + const verdict = transportRefused + ? "blocked" + : transportVerdict( + registrationStatus?.sessionCookieSecure === true, + clientTransport, + ); + const signInBlocked = verdict === "blocked"; + + function noteRefusal(err: unknown): boolean { + if ( + err instanceof ApiError && + err.meta?.errorCode === INSECURE_TRANSPORT_CODE + ) { + setTransportRefused(true); + return true; + } + return false; + } const { data: oidcStatus } = useQuery({ queryKey: queryKeys.authOidcStatus(), @@ -106,6 +148,10 @@ export default function LoginPage() { function handleOidcLogin() { const params = new URLSearchParams({ next: getRedirectTarget() }); + if (clientTransport) { + params.set("client_proto", clientTransport.protocol); + params.set("client_host", clientTransport.host); + } loadDocument(`/api/auth/oidc/login?${params}`); } @@ -176,7 +222,7 @@ export default function LoginPage() { const { options, challengeId } = await apiPost<{ options: Parameters[0]["optionsJSON"]; challengeId: string; - }>("/api/auth/passkey/login-options"); + }>("/api/auth/passkey/login-options", { clientTransport }); const credential = await startAuthentication({ optionsJSON: options }); @@ -189,6 +235,7 @@ export default function LoginPage() { // navigates; the shell refetches `["auth","me"]` fresh on mount. navigateAfterLogin(); } catch (err) { + if (noteRefusal(err)) return; // Route rejections carry the envelope error verbatim; everything // else (WebAuthn ceremony failures) keeps the descriptive mapping. if (err instanceof ApiError) { @@ -220,7 +267,7 @@ export default function LoginPage() { >("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password }), + body: JSON.stringify({ email, password, clientTransport }), }); if (meta?.mfaRequired && meta.mfaTicket) { @@ -235,6 +282,7 @@ export default function LoginPage() { // navigates; the shell refetches `["auth","me"]` fresh on mount. navigateAfterLogin(); } catch (err) { + if (noteRefusal(err)) return; setError( err instanceof ApiError && err.message ? err.message @@ -289,6 +337,9 @@ export default function LoginPage() { useEffect(() => { if (oidcStatus === undefined) return; if (oidcStatus.only === true) return; + // Wait for the server's word on its cookies, and never arm the browser's + // credential prompt for a sign-in that cannot keep its cookie. + if (registrationStatus === undefined || signInBlocked) return; if (autofillStarted.current) return; autofillStarted.current = true; let cancelled = false; @@ -302,7 +353,7 @@ export default function LoginPage() { const { options, challengeId } = await apiPost<{ options: Parameters[0]["optionsJSON"]; challengeId: string; - }>("/api/auth/passkey/login-options"); + }>("/api/auth/passkey/login-options", { clientTransport }); const credential = await startAuthentication({ optionsJSON: options, @@ -327,7 +378,7 @@ export default function LoginPage() { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [oidcStatus]); + }, [oidcStatus, registrationStatus, signInBlocked]); return (
@@ -380,12 +431,14 @@ export default function LoginPage() { data-slot="login-actions" className="mt-8 space-y-4" > + {oidcStatus?.enabled && (