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 && (