diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 89ecc1799..e612d34e0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -391,7 +391,7 @@ paths: "403": *a2 "409": *a7 "422": *a4 - "429": &a10 + "429": &a11 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": &a12 + "400": &a13 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. @@ -885,7 +885,7 @@ paths: type: string format: binary "401": *a1 - "403": &a9 + "403": &a10 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 @@ -2049,7 +2049,9 @@ paths: Presenting the token alone mints nothing: the body must carry a fresh factor proof. Every failure — wrong password, no password set on an SSO-provisioned account, an assertion for another account, a stale challenge, a replayed TOTP step — returns the same 401 with the same prose, and is audited server-side. Rate-limited per - account (5 / 15 min) and per source address. + source address and per account (5 / 15 min); the per-account budget is shared with the web's + credential-enrollment proofs. A `totp` proof counts only from an authenticator whose enrollment was confirmed: a + code from a pending secret is refused like a wrong one. Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST @@ -2085,9 +2087,27 @@ paths: tags: - Auth summary: Begin TOTP enrollment (cookie session or step-up elevation) - description: Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is - not active until /confirm. Accepts a cookie session, or a Bearer token presenting a single-use elevation from - POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused. + description: >- + Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is not + active until /confirm, and a pending secret is not a factor anywhere: a code from it is refused by every route + that verifies one. Accepts a cookie session, or a Bearer token presenting a single-use elevation from POST + /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused. + + + Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof (a + password-proved one is enough). On the cookie path the request passes when the session signed in, or completed a + second factor, within the last five minutes; otherwise the body must carry a proof in the shape POST + /api/auth/step-up takes (`password`, a `totp` code from an already confirmed authenticator, or a passkey / + security-key assertion begun at POST /api/auth/passkey/register-options). Without one the answer is 401 + `auth.reproof.required`; a proof that does not verify is 401 `auth.reproof.failed` and is audited. Proofs draw + on the account's shared re-proof budget of five per fifteen minutes (429 once spent), the same budget as the + step-up mint. + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/StepUpMintRequest" responses: "200": description: Pending secret created. @@ -2095,7 +2115,13 @@ paths: application/json: schema: $ref: "#/components/schemas/TotpSetupEnvelope" - "401": *a1 + "401": &a9 + description: No session or elevation; or, on the cookie path, `auth.reproof.required` (no recent sign-in and no proof in + the body) or `auth.reproof.failed` (the proof did not verify). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "409": description: A second factor is already active on this account. content: @@ -2206,9 +2232,26 @@ paths: tags: - Auth summary: Begin registering a security key as a second factor - description: Returns SimpleWebAuthn creation options + a challenge id. Accepts a cookie session, or a Bearer token - presenting a single-use elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is - still refused. + description: >- + Returns SimpleWebAuthn creation options + a challenge id. Accepts a cookie session, or a Bearer token presenting + a single-use elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still + refused. + + + Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof (a + password-proved one is enough). On the cookie path the request passes when the session signed in, or completed a + second factor, within the last five minutes; otherwise the body must carry a proof in the shape POST + /api/auth/step-up takes (`password`, a `totp` code from an already confirmed authenticator, or a passkey / + security-key assertion begun at POST /api/auth/passkey/register-options). Without one the answer is 401 + `auth.reproof.required`; a proof that does not verify is 401 `auth.reproof.failed` and is audited. Proofs draw + on the account's shared re-proof budget of five per fifteen minutes (429 once spent), the same budget as the + step-up mint. + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/StepUpMintRequest" responses: "200": description: Registration options issued. @@ -2216,7 +2259,7 @@ paths: application/json: schema: $ref: "#/components/schemas/MfaWebauthnRegisterOptionsEnvelope" - "401": *a1 + "401": *a9 "422": *a4 "429": *a5 /api/auth/me/mfa/webauthn/register/verify: @@ -3202,7 +3245,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a14 + "429": &a15 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. @@ -3311,7 +3354,13 @@ paths: Every refusal on the proof path is the same 401 with the same prose — a missing content type, an unparseable - body, a body that is not a valid proof shape, and a wrong password are indistinguishable to the caller. + body, a body that is not a valid proof shape, and a wrong password are indistinguishable to the caller. A `totp` + proof counts only from an authenticator whose enrollment was confirmed. + + + Each proof on the enrollment arm draws on the account's shared re-proof budget (five per fifteen minutes, the + same budget as POST /api/auth/step-up), and every failed proof writes an `auth.mfa.failed` audit row. Once the + budget is spent the answer is 429 until the window passes, even for a correct proof. requestBody: required: true content: @@ -3583,7 +3632,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesBatchResponse" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/measurements/series: @@ -3635,7 +3684,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesResponse" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/measurements: @@ -3833,7 +3882,7 @@ paths: schema: $ref: "#/components/schemas/ListMeasurementsResponse" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 post: @@ -3889,7 +3938,7 @@ paths: schema: $ref: "#/components/schemas/CreateMeasurementResponse" "401": *a1 - "403": *a9 + "403": *a10 "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. @@ -3905,7 +3954,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a10 + "429": *a11 /api/measurements/{id}: get: tags: @@ -3926,7 +3975,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementResponse" "401": *a1 - "403": *a9 + "403": *a10 "404": description: Measurement not found (or owned by another user). content: @@ -3974,7 +4023,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMeasurementResponse" "401": *a1 - "403": *a9 + "403": *a10 "404": description: Measurement not found (or owned by another user). content: @@ -4010,7 +4059,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMeasurementResponse" "401": *a1 - "403": *a9 + "403": *a10 "404": description: Measurement not found (or owned by another user). content: @@ -4116,7 +4165,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsBulkDeleteResponse" "401": *a1 - "403": *a9 + "403": *a10 "409": *a7 "422": *a4 "429": *a5 @@ -4145,7 +4194,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsRestoreResponse" "401": *a1 - "403": *a9 + "403": *a10 "409": *a7 "422": *a4 "429": *a5 @@ -4176,7 +4225,7 @@ paths: schema: $ref: "#/components/schemas/SleepNightResponse" "401": *a1 - "403": &a11 + "403": &a12 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 @@ -4217,7 +4266,7 @@ paths: schema: $ref: "#/components/schemas/SleepRhythmResponse" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/personal-records: @@ -4264,7 +4313,7 @@ paths: schema: $ref: "#/components/schemas/ListPersonalRecordsEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": description: "`metricType` or `limit` carried an unacceptable value. `meta.errorCode` = `personal_records.invalid_query`, with the offending path under `details.issues`." @@ -4319,7 +4368,7 @@ paths: schema: $ref: "#/components/schemas/ListWorkoutsResponse" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/workouts/{id}: @@ -4355,7 +4404,7 @@ paths: schema: $ref: "#/components/schemas/GetWorkoutResponse" "401": *a1 - "403": &a17 + "403": &a18 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.' @@ -4524,7 +4573,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeAggregateEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "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 @@ -4649,7 +4698,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a10 + "429": *a11 /api/medications/{id}/glp1: get: tags: @@ -4674,7 +4723,7 @@ paths: schema: $ref: "#/components/schemas/Glp1DetailResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "404": description: Medication not found (or owned by another account). content: @@ -4758,7 +4807,7 @@ paths: schema: $ref: "#/components/schemas/Glp1PostResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "404": description: Medication not found (or owned by another account). content: @@ -4893,7 +4942,7 @@ paths: schema: $ref: "#/components/schemas/ReminderPhaseConfigEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "404": description: Medication not found (or owned by another account). content: @@ -5009,7 +5058,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationSideEffectsEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "404": description: Medication not found (or owned by another account). content: @@ -5048,7 +5097,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationSideEffectEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "404": description: Medication not found (or owned by another account). content: @@ -5093,7 +5142,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationSideEffectEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "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. @@ -5141,7 +5190,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationIntakeEventEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "404": description: No live event with that id under this medication and record. An unknown id and an already-tombstoned one are indistinguishable. @@ -5189,7 +5238,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationIntakeEventEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "404": description: No event with that id under this medication and record. content: @@ -5232,7 +5281,7 @@ paths: schema: $ref: "#/components/schemas/BulkDeleteMedicationIntakeEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "404": description: Medication not found (or owned by another account). content: @@ -5274,7 +5323,7 @@ paths: schema: $ref: "#/components/schemas/PurgeMedicationIntakeEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "404": description: Medication not found (or owned by another account). content: @@ -5319,7 +5368,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportAcceptedEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "404": description: Medication not found (or owned by another account). content: @@ -5399,7 +5448,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationsResponse" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 post: @@ -5430,7 +5479,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationResponse" "401": *a1 - "403": *a9 + "403": *a10 "409": *a7 "422": *a4 "429": *a5 @@ -5450,7 +5499,7 @@ paths: schema: $ref: "#/components/schemas/MedicationListLayoutResponse" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 put: @@ -5484,7 +5533,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a13 + "422": &a14 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. @@ -5529,7 +5578,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationResponse" "401": *a1 - "403": *a9 + "403": *a10 "404": description: Medication not found (or owned by another user). content: @@ -5566,7 +5615,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationResponse" "401": *a1 - "403": *a9 + "403": *a10 "404": description: Medication not found (or owned by another user). content: @@ -5639,7 +5688,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationIntakeResponse" "401": *a1 - "403": *a9 + "403": *a10 "404": description: Medication not found (or owned by another user). content: @@ -5648,7 +5697,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 "422": *a4 - "429": *a10 + "429": *a11 get: tags: - Medications @@ -5713,7 +5762,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationIntakeEventsResponse" "401": *a1 - "403": *a9 + "403": *a10 "404": description: Medication not found (or owned by another user). content: @@ -5746,7 +5795,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationInventoryResponse" "401": *a1 - "403": *a9 + "403": *a10 "404": description: Medication not found (or owned by another user). content: @@ -5782,7 +5831,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationInventoryItemResponse" "401": *a1 - "403": *a9 + "403": *a10 "404": description: Medication not found (or owned by another user). content: @@ -5826,7 +5875,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationInventoryItemResponse" "401": *a1 - "403": *a9 + "403": *a10 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -5861,7 +5910,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationInventoryItemResponse" "401": *a1 - "403": *a9 + "403": *a10 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -5957,7 +6006,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationCadenceResponse" "401": *a1 - "403": *a9 + "403": *a10 "404": description: Medication not found (or owned by another user). content: @@ -5983,7 +6032,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationComplianceResponse" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/medications/{id}/compliance: @@ -6164,7 +6213,7 @@ paths: schema: $ref: "#/components/schemas/ListScheduleRevisionsResponse" "401": *a1 - "403": *a9 + "403": *a10 "404": description: Medication not found (or owned by another user). content: @@ -6202,7 +6251,7 @@ paths: schema: $ref: "#/components/schemas/CreateScheduleRevisionResponse" "401": *a1 - "403": *a9 + "403": *a10 "404": description: Medication not found (or owned by another user). content: @@ -6246,7 +6295,7 @@ paths: schema: $ref: "#/components/schemas/UpdateScheduleRevisionResponse" "401": *a1 - "403": *a9 + "403": *a10 "404": description: Medication or revision not found (or owned by another user). content: @@ -6287,7 +6336,7 @@ paths: schema: $ref: "#/components/schemas/DeleteScheduleRevisionResponse" "401": *a1 - "403": *a9 + "403": *a10 "404": description: Medication or revision not found (or owned by another user). content: @@ -6350,7 +6399,7 @@ paths: schema: $ref: "#/components/schemas/MedicationDoseHistoryImportQueuedEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "409": description: A dose-history import is already in progress. content: @@ -6412,7 +6461,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "404": description: No such job for this medication (or owned by another user). content: @@ -6446,7 +6495,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "404": description: No such job for this account (or owned by another user). content: @@ -6476,7 +6525,7 @@ paths: schema: $ref: "#/components/schemas/ProfileSummaryEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/ai/test: @@ -6681,7 +6730,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutCoachPrefsResponse" - "400": *a12 + "400": *a13 "401": *a1 "409": description: Coach preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read @@ -6691,7 +6740,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a13 + "422": *a14 "429": *a5 /api/auth/me/disable-coach: get: @@ -6729,7 +6778,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchDisableCoachResponse" - "400": *a12 + "400": *a13 "401": *a1 "422": *a4 "429": *a5 @@ -6884,7 +6933,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchModulesResponse" - "400": *a12 + "400": *a13 "401": *a1 "409": description: Module preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. @@ -6894,7 +6943,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a13 + "422": *a14 "429": *a5 /api/auth/me/source-priority: get: @@ -7763,7 +7812,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutSourcePriorityResponse" - "400": *a12 + "400": *a13 "401": *a1 "422": *a4 "429": *a5 @@ -7916,7 +7965,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a14 + "429": *a15 /api/auth/me/notification-prefs: get: tags: @@ -7957,7 +8006,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchNotificationPrefsResponse" - "400": *a12 + "400": *a13 "401": *a1 "409": description: Notification preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. @@ -7967,7 +8016,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a13 + "422": *a14 "429": *a5 /api/auth/me/report-selection: get: @@ -8009,7 +8058,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutReportSelectionResponseEnvelope" - "400": *a12 + "400": *a13 "401": *a1 "422": *a4 "429": *a5 @@ -8721,7 +8770,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "503": &a16 + "503": &a17 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: @@ -9045,7 +9094,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a13 + "422": *a14 "429": *a5 delete: tags: @@ -9136,7 +9185,7 @@ paths: schema: $ref: "#/components/schemas/CoachNudgeStatus" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/insights/coach/seen: @@ -9254,7 +9303,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a13 + "422": *a14 "429": *a5 /api/anamnesis/facts: get: @@ -9272,7 +9321,7 @@ paths: schema: $ref: "#/components/schemas/GetAnamnesisFactsResponse" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 post: @@ -9382,7 +9431,7 @@ paths: schema: $ref: "#/components/schemas/CorrectAnamnesisFactResponse" "401": *a1 - "404": &a15 + "404": &a16 description: No current revision with this id exists for the authenticated account. content: application/json: @@ -9418,7 +9467,7 @@ paths: schema: $ref: "#/components/schemas/RemoveAnamnesisFactResponse" "401": *a1 - "404": *a15 + "404": *a16 "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. @@ -9778,7 +9827,7 @@ paths: schema: $ref: "#/components/schemas/CoachRemindersList" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 post: @@ -10127,7 +10176,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSnapshotResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/insights/comprehensive: @@ -10149,7 +10198,7 @@ paths: schema: $ref: "#/components/schemas/InsightsComprehensiveResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/insights/cards: @@ -10170,7 +10219,7 @@ paths: schema: $ref: "#/components/schemas/InsightsCardsEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/insights/pregenerate: @@ -10305,7 +10354,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "503": *a16 + "503": *a17 post: tags: - Insights @@ -10507,7 +10556,7 @@ paths: schema: $ref: "#/components/schemas/CoachReadStripEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/insights/coach/seeded-question: @@ -10580,7 +10629,7 @@ paths: schema: $ref: "#/components/schemas/NarrativeEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/insights/glp1-timeline: @@ -10617,7 +10666,7 @@ paths: schema: $ref: "#/components/schemas/Glp1TimelineEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/insights/pulse/intraday: @@ -10653,7 +10702,7 @@ paths: schema: $ref: "#/components/schemas/IntradayPulseEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/insights/settings: @@ -10727,7 +10776,7 @@ paths: schema: $ref: "#/components/schemas/TargetsEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/analytics/range: @@ -10800,7 +10849,7 @@ paths: schema: $ref: "#/components/schemas/BloodPressureStatusResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/insights/pulse-status: @@ -10832,7 +10881,7 @@ paths: schema: $ref: "#/components/schemas/PulseStatusResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/insights/weight-status: @@ -10864,7 +10913,7 @@ paths: schema: $ref: "#/components/schemas/WeightStatusResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/insights/bmi-status: @@ -10896,7 +10945,7 @@ paths: schema: $ref: "#/components/schemas/BmiStatusResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/insights/mood-status: @@ -10928,7 +10977,7 @@ paths: schema: $ref: "#/components/schemas/MoodStatusResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/insights/medication-compliance-status: @@ -10961,7 +11010,7 @@ paths: schema: $ref: "#/components/schemas/MedicationComplianceStatusResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/insights/metric-status: @@ -11056,7 +11105,7 @@ paths: schema: $ref: "#/components/schemas/MetricStatusResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/insights/biomarker-assessment: @@ -11101,7 +11150,7 @@ paths: schema: $ref: "#/components/schemas/BiomarkerAssessmentResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/insights/derived: @@ -11213,7 +11262,7 @@ paths: schema: $ref: "#/components/schemas/DerivedMetricResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/insights/derived/batch: @@ -11253,7 +11302,7 @@ paths: schema: $ref: "#/components/schemas/DerivedBatchResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/insights/glp1-plateau: @@ -11273,7 +11322,7 @@ paths: schema: $ref: "#/components/schemas/InsightsGlp1PlateauResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/insights/ecg: @@ -11295,7 +11344,7 @@ paths: schema: $ref: "#/components/schemas/EcgListResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 post: @@ -11372,7 +11421,7 @@ paths: schema: $ref: "#/components/schemas/EcgDetailResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "404": description: No ECG recording with that id for the authenticated user (existence sealed — a foreign id is indistinguishable from a missing one). @@ -11404,7 +11453,7 @@ paths: schema: $ref: "#/components/schemas/RhythmEventsResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/insights/correlations: @@ -11426,7 +11475,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationDiscoveryResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/insights/patterns: @@ -11444,7 +11493,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationPatternListResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/insights/patterns/{id}: @@ -11474,7 +11523,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCorrelationPatternResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "404": description: No current pattern with that id for this account. content: @@ -11499,7 +11548,7 @@ paths: schema: $ref: "#/components/schemas/InsightsHealthStatusEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/insights/breathing-screening: @@ -11518,7 +11567,7 @@ paths: schema: $ref: "#/components/schemas/InsightsBreathingScreeningEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/insights/labs-changes: @@ -11537,7 +11586,7 @@ paths: schema: $ref: "#/components/schemas/InsightsLabsChangesEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/mood/analytics: @@ -11560,7 +11609,7 @@ paths: schema: $ref: "#/components/schemas/MoodDailySeriesEnvelope" "401": *a1 - "403": *a17 + "403": *a18 "422": *a4 "429": *a5 /api/mood/insights: @@ -11582,7 +11631,7 @@ paths: schema: $ref: "#/components/schemas/MoodAggregatesEnvelope" "401": *a1 - "403": *a17 + "403": *a18 "422": *a4 "429": *a5 /api/mood/prognosis: @@ -11605,7 +11654,7 @@ paths: schema: $ref: "#/components/schemas/MoodPrognosisEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/mood-entries/bulk: @@ -11715,7 +11764,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" "401": *a1 - "404": &a18 + "404": &a19 description: Not found / not owned by the caller. content: application/json: @@ -11750,7 +11799,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" "401": *a1 - "404": *a18 + "404": *a19 "422": *a4 "429": *a5 /api/mood/tags/{key}/hidden: @@ -11786,7 +11835,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "404": *a18 + "404": *a19 "422": *a4 "429": *a5 /api/mood/tags/groups: @@ -11839,7 +11888,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" "401": *a1 - "404": *a18 + "404": *a19 "422": *a4 "429": *a5 delete: @@ -11870,7 +11919,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" "401": *a1 - "404": *a18 + "404": *a19 "422": *a4 "429": *a5 /api/mood/tags/layout: @@ -11918,7 +11967,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a13 + "422": *a14 "429": *a5 /api/mood-entries: get: @@ -12001,7 +12050,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntryListEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 post: @@ -12034,8 +12083,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a9 - "409": &a19 + "403": *a10 + "409": &a20 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. @@ -12044,7 +12093,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a10 + "429": *a11 /api/mood-entries/{id}: get: tags: @@ -12066,8 +12115,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a9 - "404": *a18 + "403": *a10 + "404": *a19 "422": *a4 "429": *a5 put: @@ -12101,9 +12150,9 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a9 - "404": *a18 - "409": *a19 + "403": *a10 + "404": *a19 + "409": *a20 "422": *a4 "429": *a5 delete: @@ -12128,8 +12177,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryDeleteEnvelope" "401": *a1 - "403": *a9 - "404": *a18 + "403": *a10 + "404": *a19 "422": *a4 "429": *a5 /api/mood-entries/restore: @@ -12158,7 +12207,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesRestoreEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "409": *a7 "422": *a4 "429": *a5 @@ -12187,7 +12236,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesBulkDeleteEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "409": *a7 "422": *a4 "429": *a5 @@ -12231,7 +12280,7 @@ paths: schema: $ref: "#/components/schemas/MoodLinkedContextEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/settings/ntfy: @@ -12279,7 +12328,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutNtfySettingsResponse" - "400": &a20 + "400": &a21 description: Body is not parseable JSON. content: application/json: @@ -12292,13 +12341,13 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": &a21 + "413": &a22 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a22 + "415": &a23 description: Content-Type is not `application/json`. content: application/json: @@ -12358,7 +12407,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutTelegramSettingsResponse" - "400": *a20 + "400": *a21 "401": *a1 "403": description: The operator switched Telegram off for this instance and the body asked to enable it. No webhook was @@ -12373,8 +12422,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": *a21 - "415": *a22 + "413": *a22 + "415": *a23 "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 @@ -12428,10 +12477,10 @@ paths: application/json: schema: $ref: "#/components/schemas/PutWebhookSettingsResponse" - "400": *a20 + "400": *a21 "401": *a1 - "413": *a21 - "415": *a22 + "413": *a22 + "415": *a23 "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` = @@ -12458,7 +12507,7 @@ paths: schema: $ref: "#/components/schemas/GetReminderThresholdsResponse" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/settings/global-services: @@ -12988,7 +13037,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentLatestForKindEnvelope" - $ref: "#/components/schemas/ConsentLatestByKindEnvelope" - "400": &a23 + "400": &a24 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." @@ -13048,7 +13097,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentRevokeForKindEnvelope" - $ref: "#/components/schemas/ConsentRevokeAllEnvelope" - "400": *a23 + "400": *a24 "401": *a1 "422": *a4 "429": *a5 @@ -13289,7 +13338,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderListEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 post: @@ -13314,7 +13363,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCreatedEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "409": *a7 "422": *a4 "429": *a5 @@ -13338,8 +13387,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 - "403": *a9 - "404": &a24 + "403": *a10 + "404": &a25 description: Measurement reminder not found / not owned. content: application/json: @@ -13376,8 +13425,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 - "403": *a9 - "404": *a24 + "403": *a10 + "404": *a25 "422": *a4 "429": *a5 delete: @@ -13399,8 +13448,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 - "403": *a9 - "404": *a24 + "403": *a10 + "404": *a25 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/satisfy: @@ -13424,8 +13473,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 - "403": *a9 - "404": *a24 + "403": *a10 + "404": *a25 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/complete: @@ -13452,8 +13501,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" "401": *a1 - "403": *a9 - "404": *a24 + "403": *a10 + "404": *a25 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/skip: @@ -13482,8 +13531,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSkipEnvelope" "401": *a1 - "403": *a9 - "404": *a24 + "403": *a10 + "404": *a25 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/snooze: @@ -13516,8 +13565,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSnoozeEnvelope" "401": *a1 - "403": *a9 - "404": *a24 + "403": *a10 + "404": *a25 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/history: @@ -13557,8 +13606,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderHistoryEnvelope" "401": *a1 - "403": *a9 - "404": *a24 + "403": *a10 + "404": *a25 "422": *a4 "429": *a5 /api/labs: @@ -13630,7 +13679,7 @@ paths: schema: $ref: "#/components/schemas/ListLabResultsEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 post: @@ -13657,10 +13706,10 @@ paths: schema: $ref: "#/components/schemas/CreateLabResultResponse" "401": *a1 - "403": *a9 + "403": *a10 "409": *a7 "422": *a4 - "429": *a10 + "429": *a11 /api/labs/{id}: get: tags: @@ -13681,8 +13730,8 @@ paths: schema: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 - "403": *a9 - "404": &a25 + "403": *a10 + "404": &a26 description: Lab result not found (or owned by another user). content: application/json: @@ -13716,8 +13765,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 - "403": *a9 - "404": *a25 + "403": *a10 + "404": *a26 "422": *a4 "429": *a5 delete: @@ -13739,8 +13788,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 - "403": *a9 - "404": *a25 + "403": *a10 + "404": *a26 "422": *a4 "429": *a5 /api/labs/restore: @@ -13766,7 +13815,7 @@ paths: schema: $ref: "#/components/schemas/RestoreLabResultsResponse" "401": *a1 - "403": *a9 + "403": *a10 "409": *a7 "422": *a4 "429": *a5 @@ -13897,7 +13946,7 @@ paths: schema: $ref: "#/components/schemas/ListBiomarkersEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 post: @@ -13922,15 +13971,15 @@ paths: schema: $ref: "#/components/schemas/CreateBiomarkerResponse" "401": *a1 - "403": *a9 - "409": &a27 + "403": *a10 + "409": &a28 description: A biomarker with this name already exists for the caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a10 + "429": *a11 /api/biomarkers/{id}: get: tags: @@ -13951,8 +14000,8 @@ paths: schema: $ref: "#/components/schemas/GetBiomarkerResponse" "401": *a1 - "403": *a9 - "404": &a26 + "403": *a10 + "404": &a27 description: Biomarker not found (or owned by another user). content: application/json: @@ -13986,9 +14035,9 @@ paths: schema: $ref: "#/components/schemas/UpdateBiomarkerResponse" "401": *a1 - "403": *a9 - "404": *a26 - "409": *a27 + "403": *a10 + "404": *a27 + "409": *a28 "422": *a4 "429": *a5 delete: @@ -14011,7 +14060,7 @@ paths: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 - "404": *a26 + "404": *a27 "422": *a4 "429": *a5 /api/encounters: @@ -14069,7 +14118,7 @@ paths: schema: $ref: "#/components/schemas/ListEncountersEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 post: @@ -14095,10 +14144,10 @@ paths: schema: $ref: "#/components/schemas/CreateEncounterEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "409": *a7 "422": *a4 - "429": *a10 + "429": *a11 /api/encounters/procedures: get: tags: @@ -14129,7 +14178,7 @@ paths: schema: $ref: "#/components/schemas/ListProceduresEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/body-sites: @@ -14166,7 +14215,7 @@ paths: schema: $ref: "#/components/schemas/ListBodySitesEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/encounters/suggest: @@ -14196,7 +14245,7 @@ paths: schema: $ref: "#/components/schemas/SuggestEncounterEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/encounters/{id}: @@ -14219,8 +14268,8 @@ paths: schema: $ref: "#/components/schemas/GetEncounterEnvelope" "401": *a1 - "403": *a9 - "404": &a28 + "403": *a10 + "404": &a29 description: Visit not found (or owned by another user). content: application/json: @@ -14253,8 +14302,8 @@ paths: schema: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 - "403": *a9 - "404": *a28 + "403": *a10 + "404": *a29 "422": *a4 "429": *a5 delete: @@ -14278,8 +14327,8 @@ paths: schema: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 - "403": *a9 - "404": *a28 + "403": *a10 + "404": *a29 "422": *a4 "429": *a5 /api/encounters/{id}/restore: @@ -14304,8 +14353,8 @@ paths: schema: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 - "403": *a9 - "404": *a28 + "403": *a10 + "404": *a29 "422": *a4 "429": *a5 /api/encounters/{id}/links: @@ -14335,7 +14384,7 @@ paths: schema: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 - "404": *a28 + "404": *a29 "422": *a4 "429": *a5 delete: @@ -14364,7 +14413,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 - "404": *a28 + "404": *a29 "422": *a4 "429": *a5 /api/practitioners: @@ -14394,7 +14443,7 @@ paths: schema: $ref: "#/components/schemas/ListPractitionersEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 post: @@ -14418,7 +14467,7 @@ paths: schema: $ref: "#/components/schemas/CreatePractitionerEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "409": *a7 "422": *a4 "429": *a5 @@ -14442,8 +14491,8 @@ paths: schema: $ref: "#/components/schemas/GetPractitionerEnvelope" "401": *a1 - "403": *a9 - "404": &a29 + "403": *a10 + "404": &a30 description: Practitioner not found (or owned by another user). content: application/json: @@ -14476,8 +14525,8 @@ paths: schema: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 - "403": *a9 - "404": *a29 + "403": *a10 + "404": *a30 "422": *a4 "429": *a5 delete: @@ -14500,8 +14549,8 @@ paths: schema: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 - "403": *a9 - "404": *a29 + "403": *a10 + "404": *a30 "422": *a4 "429": *a5 /api/practitioners/{id}/restore: @@ -14526,8 +14575,8 @@ paths: schema: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 - "403": *a9 - "404": *a29 + "403": *a10 + "404": *a30 "422": *a4 "429": *a5 /api/vaccinations: @@ -14570,7 +14619,7 @@ paths: schema: $ref: "#/components/schemas/ListVaccinationsEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 post: @@ -14597,10 +14646,10 @@ paths: schema: $ref: "#/components/schemas/CreateVaccinationEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "409": *a7 "422": *a4 - "429": *a10 + "429": *a11 /api/vaccinations/{id}: get: tags: @@ -14621,8 +14670,8 @@ paths: schema: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 - "403": *a9 - "404": &a30 + "403": *a10 + "404": &a31 description: Vaccination not found (or owned by another user). content: application/json: @@ -14656,8 +14705,8 @@ paths: schema: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 - "403": *a9 - "404": *a30 + "403": *a10 + "404": *a31 "422": *a4 "429": *a5 delete: @@ -14681,8 +14730,8 @@ paths: schema: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 - "403": *a9 - "404": *a30 + "403": *a10 + "404": *a31 "422": *a4 "429": *a5 /api/vaccinations/{id}/restore: @@ -14706,8 +14755,8 @@ paths: schema: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 - "403": *a9 - "404": *a30 + "403": *a10 + "404": *a31 "422": *a4 "429": *a5 /api/vaccinations/{id}/booster: @@ -14773,7 +14822,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": *a30 + "404": *a31 "422": *a4 "429": *a5 /api/vaccinations/suggest: @@ -14801,7 +14850,7 @@ paths: schema: $ref: "#/components/schemas/VaccinationSuggestEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/vaccinations/{id}/links: @@ -14831,7 +14880,7 @@ paths: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 - "404": *a30 + "404": *a31 "422": *a4 "429": *a5 delete: @@ -14860,7 +14909,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 - "404": *a30 + "404": *a31 "422": *a4 "429": *a5 /api/illness/episodes: @@ -14893,7 +14942,7 @@ paths: schema: $ref: "#/components/schemas/ListIllnessEpisodesEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 post: @@ -14919,7 +14968,7 @@ paths: schema: $ref: "#/components/schemas/CreateIllnessEpisodeEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "409": *a7 "422": *a4 "429": *a5 @@ -14943,8 +14992,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 - "403": *a9 - "404": &a31 + "403": *a10 + "404": &a32 description: Illness episode not found (or owned by another user). content: application/json: @@ -14978,8 +15027,8 @@ paths: schema: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 - "403": *a9 - "404": *a31 + "403": *a10 + "404": *a32 "422": *a4 "429": *a5 delete: @@ -15002,8 +15051,8 @@ paths: schema: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 - "403": *a9 - "404": *a31 + "403": *a10 + "404": *a32 "422": *a4 "429": *a5 /api/illness/episodes/{id}/restore: @@ -15030,8 +15079,8 @@ paths: schema: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 - "403": *a9 - "404": *a31 + "403": *a10 + "404": *a32 "409": *a7 "422": *a4 "429": *a5 @@ -15063,8 +15112,8 @@ paths: schema: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 - "403": *a9 - "404": *a31 + "403": *a10 + "404": *a32 "422": *a4 "429": *a5 /api/illness/episodes/{id}/day-logs: @@ -15114,8 +15163,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 - "403": *a9 - "404": *a31 + "403": *a10 + "404": *a32 "422": *a4 "429": *a5 post: @@ -15151,8 +15200,8 @@ paths: schema: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 - "403": *a9 - "404": *a31 + "403": *a10 + "404": *a32 "422": *a4 "429": *a5 /api/illness/episodes/{id}/correlation: @@ -15179,7 +15228,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a31 + "404": *a32 "422": *a4 "429": *a5 /api/illness/insights: @@ -15252,7 +15301,7 @@ paths: schema: $ref: "#/components/schemas/ListMentalHealthAssessmentsEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 post: @@ -15285,7 +15334,7 @@ paths: schema: $ref: "#/components/schemas/CreateMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "409": *a7 "422": *a4 "429": *a5 @@ -15312,7 +15361,7 @@ paths: schema: $ref: "#/components/schemas/GetMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/allergies: @@ -15345,7 +15394,7 @@ paths: schema: $ref: "#/components/schemas/ListAllergiesEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 post: @@ -15370,10 +15419,10 @@ paths: schema: $ref: "#/components/schemas/CreateAllergyEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "409": *a7 "422": *a4 - "429": *a10 + "429": *a11 /api/allergies/{id}: get: tags: @@ -15394,8 +15443,8 @@ paths: schema: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 - "403": *a9 - "404": &a32 + "403": *a10 + "404": &a33 description: Allergy not found (or owned by another user). content: application/json: @@ -15428,8 +15477,8 @@ paths: schema: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 - "403": *a9 - "404": *a32 + "403": *a10 + "404": *a33 "422": *a4 "429": *a5 delete: @@ -15452,8 +15501,8 @@ paths: schema: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 - "403": *a9 - "404": *a32 + "403": *a10 + "404": *a33 "422": *a4 "429": *a5 /api/family-history: @@ -15477,7 +15526,7 @@ paths: schema: $ref: "#/components/schemas/ListFamilyHistoryEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 post: @@ -15502,7 +15551,7 @@ paths: schema: $ref: "#/components/schemas/CreateFamilyHistoryEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "409": *a7 "422": *a4 "429": *a5 @@ -15526,8 +15575,8 @@ paths: schema: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 - "403": *a9 - "404": &a33 + "403": *a10 + "404": &a34 description: Family history entry not found (or owned by another user). content: application/json: @@ -15560,8 +15609,8 @@ paths: schema: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 - "403": *a9 - "404": *a33 + "403": *a10 + "404": *a34 "422": *a4 "429": *a5 delete: @@ -15584,8 +15633,8 @@ paths: schema: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 - "403": *a9 - "404": *a33 + "403": *a10 + "404": *a34 "422": *a4 "429": *a5 /api/documents/inbound: @@ -15698,7 +15747,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentListEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 post: @@ -15948,7 +15997,7 @@ paths: schema: $ref: "#/components/schemas/DocumentUsageEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/documents/inbound/bulk: @@ -15995,7 +16044,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentDetailEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 patch: @@ -16114,7 +16163,7 @@ paths: type: string format: binary "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/documents/inbound/{id}/thumbnail: @@ -16143,7 +16192,7 @@ paths: type: string format: binary "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/documents/inbound/{id}/extract: @@ -16182,7 +16231,7 @@ paths: schema: $ref: "#/components/schemas/InboundExtractEnvelope" "401": *a1 - "403": &a34 + "403": &a35 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 @@ -16206,7 +16255,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "503": &a35 + "503": &a36 description: "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. Nothing was sent. Retry later." content: @@ -16268,10 +16317,10 @@ paths: schema: $ref: "#/components/schemas/DocumentSuggestEnvelope" "401": *a1 - "403": *a34 + "403": *a35 "422": *a4 "429": *a5 - "503": *a35 + "503": *a36 /api/documents/inbound/{id}/summary: post: tags: @@ -16350,10 +16399,10 @@ paths: schema: $ref: "#/components/schemas/DocumentSummaryEnvelope" "401": *a1 - "403": *a34 + "403": *a35 "422": *a4 "429": *a5 - "503": *a35 + "503": *a36 /api/documents/inbound/{id}/index: post: tags: @@ -16410,10 +16459,10 @@ paths: schema: $ref: "#/components/schemas/DocumentIndexEnvelope" "401": *a1 - "403": *a34 + "403": *a35 "422": *a4 "429": *a5 - "503": *a35 + "503": *a36 /api/documents/inbound/{id}/chat: post: tags: @@ -16453,10 +16502,10 @@ paths: type: string description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." "401": *a1 - "403": *a34 + "403": *a35 "422": *a4 "429": *a5 - "503": *a35 + "503": *a36 get: tags: - Documents @@ -16519,10 +16568,10 @@ paths: schema: $ref: "#/components/schemas/DocumentReindexEnvelope" "401": *a1 - "403": *a34 + "403": *a35 "422": *a4 "429": *a5 - "503": *a35 + "503": *a36 /api/documents/inbound/{id}/facts/{factId}: patch: tags: @@ -16604,7 +16653,7 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricsEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 post: @@ -16629,7 +16678,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a37 + "409": &a38 description: A custom metric with this name already exists for the caller. content: application/json: @@ -16657,8 +16706,8 @@ paths: schema: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 - "403": *a9 - "404": &a36 + "403": *a10 + "404": &a37 description: Custom metric not found (or owned by another user). content: application/json: @@ -16692,8 +16741,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a36 - "409": *a37 + "404": *a37 + "409": *a38 "422": *a4 "429": *a5 delete: @@ -16716,7 +16765,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a36 + "404": *a37 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -16759,8 +16808,8 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 - "403": *a9 - "404": *a36 + "403": *a10 + "404": *a37 "422": *a4 "429": *a5 post: @@ -16789,10 +16838,10 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a36 + "404": *a37 "409": *a7 "422": *a4 - "429": *a10 + "429": *a11 /api/custom-metrics/{id}/entries/{entryId}: patch: tags: @@ -16825,7 +16874,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a38 + "404": &a39 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -16858,7 +16907,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a38 + "404": *a39 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -17270,7 +17319,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a39 + "403": &a40 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 @@ -17325,7 +17374,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a39 + "403": *a40 "422": *a4 "429": *a5 /api/nutrients/water: @@ -17354,7 +17403,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a39 + "403": *a40 "409": *a7 "422": *a4 "429": *a5 @@ -17856,26 +17905,26 @@ paths: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" - "400": &a40 + "400": &a41 description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "413": &a41 + "413": &a42 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a42 + "415": &a43 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a43 + "422": &a44 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: @@ -18061,11 +18110,11 @@ paths: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" - "400": *a40 + "400": *a41 "401": *a1 - "413": *a41 - "415": *a42 - "422": *a43 + "413": *a42 + "415": *a43 + "422": *a44 "429": *a5 delete: tags: @@ -18208,11 +18257,11 @@ paths: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" - "400": *a40 + "400": *a41 "401": *a1 - "413": *a41 - "415": *a42 - "422": *a43 + "413": *a42 + "415": *a43 + "422": *a44 "429": *a5 delete: tags: @@ -18365,11 +18414,11 @@ paths: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" - "400": *a40 + "400": *a41 "401": *a1 - "413": *a41 - "415": *a42 - "422": *a43 + "413": *a42 + "415": *a43 + "422": *a44 "429": *a5 delete: tags: @@ -18445,21 +18494,21 @@ paths: schema: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 - "422": &a44 + "422": &a45 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": &a45 + "429": &a46 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": &a46 + "502": &a47 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` @@ -18504,11 +18553,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" - "400": *a40 + "400": *a41 "401": *a1 - "413": *a41 - "415": *a42 - "422": *a43 + "413": *a42 + "415": *a43 + "422": *a44 "429": *a5 delete: tags: @@ -18584,9 +18633,9 @@ paths: schema: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 - "422": *a44 - "429": *a45 - "502": *a46 + "422": *a45 + "429": *a46 + "502": *a47 /api/strava/credentials: get: tags: @@ -18622,11 +18671,11 @@ paths: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" - "400": *a40 + "400": *a41 "401": *a1 - "413": *a41 - "415": *a42 - "422": *a43 + "413": *a42 + "415": *a43 + "422": *a44 "429": *a5 delete: tags: @@ -18704,9 +18753,9 @@ paths: schema: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 - "422": *a44 - "429": *a45 - "502": *a46 + "422": *a45 + "429": *a46 + "502": *a47 /api/integrations/status: get: tags: @@ -18742,7 +18791,7 @@ paths: $ref: "#/components/schemas/FitbitResumeEnvelope" "401": *a1 "422": *a4 - "429": *a45 + "429": *a46 /api/integrations/fitbit/test: post: tags: @@ -18760,9 +18809,9 @@ paths: schema: $ref: "#/components/schemas/FitbitIntegrationTestEnvelope" "401": *a1 - "422": *a44 - "429": *a45 - "502": *a46 + "422": *a45 + "429": *a46 + "502": *a47 /api/integrations/google-health/resume: post: tags: @@ -18779,7 +18828,7 @@ paths: $ref: "#/components/schemas/GoogleHealthResumeEnvelope" "401": *a1 "422": *a4 - "429": *a45 + "429": *a46 /api/integrations/google-health/test: post: tags: @@ -18812,9 +18861,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a44 - "429": *a45 - "502": *a46 + "422": *a45 + "429": *a46 + "502": *a47 /api/integrations/whoop/resume: post: tags: @@ -18831,7 +18880,7 @@ paths: $ref: "#/components/schemas/WhoopResumeEnvelope" "401": *a1 "422": *a4 - "429": *a45 + "429": *a46 /api/integrations/whoop/test: post: tags: @@ -18848,9 +18897,9 @@ paths: schema: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 - "422": *a44 - "429": *a45 - "502": *a46 + "422": *a45 + "429": *a46 + "502": *a47 /api/integrations/withings/resume: post: tags: @@ -18868,7 +18917,7 @@ paths: $ref: "#/components/schemas/WithingsResumeEnvelope" "401": *a1 "422": *a4 - "429": *a45 + "429": *a46 /api/integrations/withings/test: post: tags: @@ -18886,8 +18935,8 @@ paths: schema: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 - "422": *a44 - "429": *a45 + "422": *a45 + "429": *a46 "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: @@ -18920,7 +18969,7 @@ paths: schema: $ref: "#/components/schemas/DailyDigestEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/daily/digest/dismiss: @@ -19011,7 +19060,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSummaryEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/dashboard/widgets: @@ -19030,7 +19079,7 @@ paths: schema: $ref: "#/components/schemas/DashboardLayout" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 put: @@ -19064,7 +19113,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a13 + "422": *a14 "429": *a5 delete: tags: @@ -19127,7 +19176,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a47 + "403": &a48 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 @@ -19152,7 +19201,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a47 + "403": *a48 "422": *a4 "429": *a5 post: @@ -19247,7 +19296,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a47 + "403": *a48 "404": description: No such invitation for this account. content: @@ -19291,7 +19340,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a47 + "403": *a48 "404": description: No such grant on this account's record. content: @@ -19332,7 +19381,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a47 + "403": *a48 "404": description: No such grant held by this account. content: @@ -19714,7 +19763,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a47 + "403": *a48 "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 — @@ -20044,7 +20093,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 - "403": &a48 + "403": &a49 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. @@ -20077,7 +20126,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 - "403": *a48 + "403": *a49 "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. @@ -20104,7 +20153,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a48 + "403": *a49 "422": *a4 "429": *a5 post: @@ -20134,7 +20183,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a48 + "403": *a49 "413": description: Body exceeds 16 KiB. content: @@ -20177,7 +20226,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 - "403": *a48 + "403": *a49 "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. @@ -20204,7 +20253,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/record-settings/integrations: @@ -20224,7 +20273,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsIntegrationsEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "422": *a4 "429": *a5 /api/record-settings/{family}: @@ -20235,7 +20284,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: - - &a49 + - &a50 name: family in: path required: true @@ -20259,7 +20308,7 @@ paths: schema: $ref: "#/components/schemas/GetRecordSettingsFamilyEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "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. @@ -20279,7 +20328,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: - - *a49 + - *a50 requestBody: required: true content: @@ -20300,7 +20349,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a9 + "403": *a10 "404": description: The path named a family outside the closed set. Answered before authentication. content: @@ -20413,7 +20462,7 @@ paths: schema: $ref: "#/components/schemas/AchievementsEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/environment: @@ -20431,7 +20480,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentOverviewEnvelope" "401": *a1 - "403": *a17 + "403": *a18 "422": *a4 "429": *a5 /api/environment/home: @@ -20468,7 +20517,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentHomeEnvelope" "401": *a1 - "403": *a17 + "403": *a18 "413": description: Body exceeds 4 KiB. content: @@ -20522,7 +20571,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentBackfillEnvelope" "401": *a1 - "403": *a17 + "403": *a18 "409": description: No home location is set, so there is nothing to resolve the days against. `meta.errorCode` = `environment.no_home`. @@ -20581,7 +20630,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentGeocodeEnvelope" "401": *a1 - "403": *a17 + "403": *a18 "422": description: "`q` was missing, empty after trimming, or longer than 120 characters. `meta.errorCode` = `environment.invalid`." @@ -20620,7 +20669,7 @@ paths: schema: $ref: "#/components/schemas/CreateEnvironmentTravelEnvelope" "401": *a1 - "403": *a17 + "403": *a18 "413": description: Body exceeds 4 KiB. content: @@ -20673,7 +20722,7 @@ paths: schema: $ref: "#/components/schemas/DeleteEnvironmentTravelEnvelope" "401": *a1 - "403": *a17 + "403": *a18 "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. @@ -20814,13 +20863,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a50 + "403": &a51 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": &a51 + "404": &a52 description: No stored copy with this id. content: application/json: @@ -20891,8 +20940,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a50 - "404": *a51 + "403": *a51 + "404": *a52 "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 @@ -20936,7 +20985,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobListResponse" - "403": *a50 + "403": *a51 /api/admin/backups/restores/{jobId}: get: tags: @@ -20959,7 +21008,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobResponse" - "403": *a50 + "403": *a51 "404": description: No restore job with this id. content: @@ -30818,42 +30867,42 @@ components: analytics.invalid_query; anamnesis — anamnesis.fact.conflict, anamnesis.fact.currentExists, 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.scope.insufficient, auth.stepup.required, auth.token.expired, auth.token.invalid; backup — - 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; - encounter — encounter.invalid, encounter.link.invalid, encounter.practitioner-not-found, - encounter.reminder-conflict, encounter.reminder-not-found; environment — environment.invalid, - environment.no_home, environment.range_too_large, environment.travel.not_found; export — - export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid; feedback — - feedback.body.invalid; health_score_config — health_score_config.invalid, - health_score_config.too_narrow; illness — illness.day-log.invalid, illness.disabled, - illness.episode.chronic-no-resolve, illness.episode.invalid, illness.episode.invalid-window, - illness.episode.parent-not-found, illness.insights.invalid; import — import.write_failed; ingest — - ingest.rate_limited; insights — insights.generate.budgetExceeded, insights.generate.outboundScreened, - insights.generate.profileScopeChanged, insights.generate.scopeChanged; labs — - labs.biomarker.create.invalid, labs.biomarker.duplicate, labs.biomarker.notFound, - labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, labs.create.invalid, - labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge, labs.ocr.fileType, - labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported, + auth.reproof.failed, auth.reproof.required, auth.scope.insufficient, auth.stepup.required, + auth.token.expired, auth.token.invalid; backup — 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; encounter — encounter.invalid, + encounter.link.invalid, encounter.practitioner-not-found, encounter.reminder-conflict, + encounter.reminder-not-found; environment — environment.invalid, environment.no_home, + environment.range_too_large, environment.travel.not_found; export — export.selection.empty, + export.selection.unknown_leaf; familyHistory — familyHistory.invalid; feedback — feedback.body.invalid; + health_score_config — health_score_config.invalid, health_score_config.too_narrow; illness — + illness.day-log.invalid, illness.disabled, illness.episode.chronic-no-resolve, illness.episode.invalid, + illness.episode.invalid-window, illness.episode.parent-not-found, illness.insights.invalid; import — + import.write_failed; ingest — ingest.rate_limited; insights — insights.generate.budgetExceeded, + insights.generate.outboundScreened, insights.generate.profileScopeChanged, + insights.generate.scopeChanged; labs — labs.biomarker.create.invalid, labs.biomarker.duplicate, + labs.biomarker.notFound, labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, + labs.create.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge, + labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported, labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected, labs.update.qualitativeExpected, labs.update.referenceRangeInvalid, labs.update.sourceReferenceRangeInvalid; managed_profile — diff --git a/messages/de.json b/messages/de.json index 06801e37e..bfdb8da39 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5584,6 +5584,7 @@ "passkeyReauth": { "title": "Bestätige deine Identität", "description": "Bestätige vor dem Hinzufügen eines neuen Passkeys eine vorhandene Anmeldemethode.", + "factorDescription": "Bestätige eine vorhandene Anmeldemethode, bevor du einen zweiten Faktor hinzufügst.", "methodLabel": "Bestätigungsmethode", "methods": { "password": "Passwort", @@ -5594,7 +5595,8 @@ "currentPassword": "Aktuelles Passwort", "authenticatorCode": "Authenticator-Code", "authenticatorPlaceholder": "Sechsstelliger Authenticator-Code", - "verifyAndAdd": "Bestätigen und hinzufügen" + "verifyAndAdd": "Bestätigen und hinzufügen", + "verifyAndContinue": "Bestätigen und fortfahren" }, "passkeyName": "Name", "noPasskeys": "Keine Passkeys registriert.", diff --git a/messages/en.json b/messages/en.json index fa1fb5458..8abfade86 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5584,6 +5584,7 @@ "passkeyReauth": { "title": "Confirm it's you", "description": "Verify an existing sign-in method before adding a new passkey.", + "factorDescription": "Verify an existing sign-in method before adding a second factor.", "methodLabel": "Verification method", "methods": { "password": "Password", @@ -5594,7 +5595,8 @@ "currentPassword": "Current password", "authenticatorCode": "Authenticator code", "authenticatorPlaceholder": "6-digit authenticator code", - "verifyAndAdd": "Verify and add" + "verifyAndAdd": "Verify and add", + "verifyAndContinue": "Verify and continue" }, "passkeyName": "Name", "noPasskeys": "No passkeys registered.", diff --git a/messages/es.json b/messages/es.json index 46f903102..ca8347a0e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5584,6 +5584,7 @@ "passkeyReauth": { "title": "Confirma tu identidad", "description": "Verifica un método de inicio de sesión existente antes de añadir una nueva clave de acceso.", + "factorDescription": "Verifica un método de inicio de sesión existente antes de añadir un segundo factor.", "methodLabel": "Método de verificación", "methods": { "password": "Contraseña", @@ -5594,7 +5595,8 @@ "currentPassword": "Contraseña actual", "authenticatorCode": "Código del autenticador", "authenticatorPlaceholder": "Código de seis dígitos del autenticador", - "verifyAndAdd": "Verificar y añadir" + "verifyAndAdd": "Verificar y añadir", + "verifyAndContinue": "Verificar y continuar" }, "passkeyName": "Nombre", "noPasskeys": "No hay claves de acceso registradas.", diff --git a/messages/fr.json b/messages/fr.json index 722b77a24..0600cc41c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5584,6 +5584,7 @@ "passkeyReauth": { "title": "Confirmez votre identité", "description": "Vérifiez une méthode de connexion existante avant d’ajouter une nouvelle clé d’accès.", + "factorDescription": "Vérifiez une méthode de connexion existante avant d'ajouter un second facteur.", "methodLabel": "Méthode de vérification", "methods": { "password": "Mot de passe", @@ -5594,7 +5595,8 @@ "currentPassword": "Mot de passe actuel", "authenticatorCode": "Code d’authentification", "authenticatorPlaceholder": "Code d’authentification à 6 chiffres", - "verifyAndAdd": "Vérifier et ajouter" + "verifyAndAdd": "Vérifier et ajouter", + "verifyAndContinue": "Vérifier et continuer" }, "passkeyName": "Nom", "noPasskeys": "Aucune clé d’accès enregistrée.", diff --git a/messages/it.json b/messages/it.json index ae8310f2d..27d24cb9b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5584,6 +5584,7 @@ "passkeyReauth": { "title": "Conferma la tua identità", "description": "Verifica un metodo di accesso esistente prima di aggiungere una nuova passkey.", + "factorDescription": "Verifica un metodo di accesso esistente prima di aggiungere un secondo fattore.", "methodLabel": "Metodo di verifica", "methods": { "password": "Password", @@ -5594,7 +5595,8 @@ "currentPassword": "Password attuale", "authenticatorCode": "Codice dell’app di autenticazione", "authenticatorPlaceholder": "Codice a 6 cifre dell’app di autenticazione", - "verifyAndAdd": "Verifica e aggiungi" + "verifyAndAdd": "Verifica e aggiungi", + "verifyAndContinue": "Verifica e continua" }, "passkeyName": "Nome", "noPasskeys": "Nessuna passkey registrata.", diff --git a/messages/ko.json b/messages/ko.json index 674b6c9a3..e5837c96f 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5584,6 +5584,7 @@ "passkeyReauth": { "title": "본인 확인", "description": "새 패스키를 추가하기 전에 기존 로그인 수단으로 확인해 주세요.", + "factorDescription": "두 번째 인증 수단을 추가하기 전에 기존 로그인 방법을 확인하세요.", "methodLabel": "확인 방법", "methods": { "password": "비밀번호", @@ -5594,7 +5595,8 @@ "currentPassword": "현재 비밀번호", "authenticatorCode": "인증 앱 코드", "authenticatorPlaceholder": "6자리 인증 앱 코드", - "verifyAndAdd": "확인 후 추가" + "verifyAndAdd": "확인 후 추가", + "verifyAndContinue": "확인하고 계속" }, "passkeyName": "이름", "noPasskeys": "등록된 패스키가 없어요.", diff --git a/messages/pl.json b/messages/pl.json index a2c23d8b3..30f7639aa 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5584,6 +5584,7 @@ "passkeyReauth": { "title": "Potwierdź swoją tożsamość", "description": "Przed dodaniem nowego klucza dostępu zweryfikuj jedną z istniejących metod logowania.", + "factorDescription": "Potwierdź istniejącą metodę logowania, zanim dodasz drugi składnik.", "methodLabel": "Metoda weryfikacji", "methods": { "password": "Hasło", @@ -5594,7 +5595,8 @@ "currentPassword": "Aktualne hasło", "authenticatorCode": "Kod z aplikacji uwierzytelniającej", "authenticatorPlaceholder": "6-cyfrowy kod z aplikacji uwierzytelniającej", - "verifyAndAdd": "Zweryfikuj i dodaj" + "verifyAndAdd": "Zweryfikuj i dodaj", + "verifyAndContinue": "Potwierdź i kontynuuj" }, "passkeyName": "Nazwa", "noPasskeys": "Brak zarejestrowanych kluczy dostępu.", diff --git a/src/__tests__/factor-verification-surface-guard.test.ts b/src/__tests__/factor-verification-surface-guard.test.ts new file mode 100644 index 000000000..f4a9406f7 --- /dev/null +++ b/src/__tests__/factor-verification-surface-guard.test.ts @@ -0,0 +1,76 @@ +/** + * Every route that checks a password or a second factor is a guessing oracle, + * and every one must be throttled and must record a refused guess. + * + * The passkey-enrollment re-proof verified a password and a TOTP code with + * neither: a stolen session could guess there as fast as it could send + * requests, and nothing in the audit log would show it. Behavioural tests pin + * each route that was fixed; this freezes the SET, so a new route that starts + * verifying a credential has to be listed here, and the listing asks the two + * questions at the moment it is added. + * + * Matching is on the verifier's name anywhere in the file, not on an import + * shape, so an aliased import still counts. It remains a tripwire: it proves a + * throttle and an audit call are present in the file, not that they sit on the + * right branch — the per-route tests do that. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const APP = join(process.cwd(), "src", "app"); + +const VERIFIER = + /\b(verifyPassword|verifyPasswordOrDummy|verifyMfaFactor|verifyTotp|verifyMfaAuthentication|verifyAuthentication|verifyExistingFactorProof|checkCookieEnrollmentProof)\b/; + +const THROTTLE = + /\b(checkRateLimit|checkAuthSurfaceRateLimit|throttleReproof|checkCookieEnrollmentProof|reserveChallengeAttempt)\s*\(/; + +const AUDIT = + /\b(auditLog|recordReproofFailure|checkCookieEnrollmentProof)\s*\(/; + +/** + * Every file under src/app that verifies a credential. Adding one means + * answering, in its own tests, how a wrong guess is limited and recorded. + */ +const EXPECTED = [ + "api/auth/login/route.ts", + "api/auth/me/mfa/disable/route.ts", + "api/auth/me/mfa/totp/confirm/route.ts", + "api/auth/me/mfa/totp/setup/route.ts", + "api/auth/me/mfa/webauthn/register/options/route.ts", + "api/auth/mfa/verify/route.ts", + "api/auth/mfa/webauthn/verify/route.ts", + "api/auth/passkey/login-verify/route.ts", + "api/auth/passkey/register-options/route.ts", + "api/auth/password/route.ts", + "api/auth/step-up/route.ts", +]; + +function verifyingFiles(): string[] { + return walkSourceFiles(APP, { floor: 500 }) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !/\.test\.tsx?$/.test(p)) + .filter((p) => VERIFIER.test(readFileSync(join(APP, p), "utf8"))); +} + +describe("credential-verifying routes", () => { + const found = verifyingFiles(); + + it("finds the verifying routes at all", () => { + // An empty match agrees with nothing and would pass on a tree never read. + expect(found.length).toBeGreaterThanOrEqual(EXPECTED.length); + }); + + it("is exactly the listed set", () => { + expect(found).toEqual(EXPECTED); + }); + + it.each(EXPECTED)("%s is throttled and audits refusals", (rel) => { + const src = readFileSync(join(APP, rel), "utf8"); + expect(src).toMatch(THROTTLE); + expect(src).toMatch(AUDIT); + }); +}); diff --git a/src/app/api/auth/me/mfa/totp/setup/route.ts b/src/app/api/auth/me/mfa/totp/setup/route.ts index a9ef6b5ed..e1ac624ea 100644 --- a/src/app/api/auth/me/mfa/totp/setup/route.ts +++ b/src/app/api/auth/me/mfa/totp/setup/route.ts @@ -10,7 +10,14 @@ * * Gated by `requireMfaManagementAuth`: a cookie session, or a Bearer token * presenting a single-use step-up elevation minted against a re-proved factor. - * A token on its own can still never enrol MFA. The recovery-code batch is + * A token on its own can still never enrol MFA. + * + * A cookie session on its own cannot either. Enrolling hands the caller a + * factor that later satisfies every step-up gate, so a stolen session must not + * be able to mint one: the cookie arm also passes `checkCookieEnrollmentProof` + * — a sign-in or second factor inside the last five minutes, or a password / + * factor proof in the body (the step-up mint's shapes). The Bearer arm already + * carries that proof in its elevation. The recovery-code batch is * issued at `/confirm` (after the factor is proven), not here, so an abandoned * setup never persists codes. */ @@ -26,6 +33,7 @@ import { prisma } from "@/lib/db"; import { encrypt } from "@/lib/crypto"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { generateTotpSecret, buildOtpauthUri } from "@/lib/auth/mfa/totp"; +import { checkCookieEnrollmentProof } from "@/lib/auth/existing-factor-proof"; export const dynamic = "force-dynamic"; @@ -57,6 +65,17 @@ export const POST = apiHandler(async (req: Request) => { throw new HttpError(409, "A second factor is already active"); } + if (auth.transport === "cookie") { + const refusal = await checkCookieEnrollmentProof({ + user, + sessionId: auth.session.id, + request: req, + ipAddress: getClientIp(req), + stage: "totp_enroll", + }); + if (refusal) return refusal; + } + // Rate limit and the already-active check have passed; the write is next. await auth.commitElevation(); diff --git a/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts b/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts index e7704c9ca..996d75487 100644 --- a/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts +++ b/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts @@ -31,11 +31,16 @@ vi.mock("@/lib/auth/audit", () => ({ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/existing-factor-proof", () => ({ + checkCookieEnrollmentProof: vi.fn(), +})); + import { POST as REGISTER_OPTIONS } from "../options/route"; import { POST as REGISTER_VERIFY } from "../verify/route"; import { requireMfaManagementAuth } from "@/lib/api-handler"; import { prisma } from "@/lib/db"; import { checkRateLimit } from "@/lib/rate-limit"; +import { checkCookieEnrollmentProof } from "@/lib/auth/existing-factor-proof"; import { createMfaRegistrationOptions, verifyMfaRegistration, @@ -63,8 +68,16 @@ beforeEach(() => { commitElevation: vi.fn().mockResolvedValue(undefined), } as never); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); + vi.mocked(checkCookieEnrollmentProof).mockResolvedValue(null); }); +function optionsRequest(): NextRequest { + return new NextRequest( + "http://localhost/api/auth/me/mfa/webauthn/register/options", + { method: "POST" }, + ); +} + describe("POST /api/auth/me/mfa/webauthn/register/options", () => { it("returns ceremony options + challenge id", async () => { vi.mocked(createMfaRegistrationOptions).mockResolvedValue({ @@ -72,7 +85,7 @@ describe("POST /api/auth/me/mfa/webauthn/register/options", () => { challengeId: "ch-1", } as never); - const res = await REGISTER_OPTIONS(); + const res = await REGISTER_OPTIONS(optionsRequest()); const body = (await res.json()) as { data: { options: unknown; challengeId: string }; }; @@ -86,6 +99,40 @@ describe("POST /api/auth/me/mfa/webauthn/register/options", () => { }); }); +describe("security-key enrollment needs a fresh proof on the cookie arm", () => { + it("stops before any challenge exists when the session cannot show one", async () => { + vi.mocked(checkCookieEnrollmentProof).mockResolvedValue( + new Response(null, { status: 401 }), + ); + const res = await REGISTER_OPTIONS(optionsRequest()); + expect(res.status).toBe(401); + expect(createMfaRegistrationOptions).not.toHaveBeenCalled(); + expect(checkCookieEnrollmentProof).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "sess-1", + stage: "security_key_enroll", + }), + ); + }); + + it("leaves the Bearer arm to its elevation", async () => { + vi.mocked(requireMfaManagementAuth).mockResolvedValue({ + transport: "bearer", + user: USER, + apiTokenId: "tok-1", + accessTokenHash: "h", + commitElevation: vi.fn().mockResolvedValue(undefined), + } as never); + vi.mocked(createMfaRegistrationOptions).mockResolvedValue({ + options: {}, + challengeId: "ch-2", + } as never); + const res = await REGISTER_OPTIONS(optionsRequest()); + expect(res.status).toBe(200); + expect(checkCookieEnrollmentProof).not.toHaveBeenCalled(); + }); +}); + describe("POST /api/auth/me/mfa/webauthn/register/verify", () => { const credential = { id: "cred-1", diff --git a/src/app/api/auth/me/mfa/webauthn/register/options/route.ts b/src/app/api/auth/me/mfa/webauthn/register/options/route.ts index a472d8d55..998c2f3b5 100644 --- a/src/app/api/auth/me/mfa/webauthn/register/options/route.ts +++ b/src/app/api/auth/me/mfa/webauthn/register/options/route.ts @@ -5,16 +5,21 @@ * session or a Bearer token presenting a single-use step-up elevation; a token * on its own can never enrol MFA. Returns the SimpleWebAuthn creation options + * the server-issued challenge id to present back at /register/verify. + * + * The cookie arm additionally passes `checkCookieEnrollmentProof`: a session + * alone cannot add a security key, for the same reason it cannot enroll TOTP — + * the new key would satisfy every step-up gate from then on. */ import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler"; -import { apiError, apiSuccess } from "@/lib/api-response"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { checkCookieEnrollmentProof } from "@/lib/auth/existing-factor-proof"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { createMfaRegistrationOptions } from "@/lib/auth/mfa/webauthn"; export const dynamic = "force-dynamic"; -export const POST = apiHandler(async () => { +export const POST = apiHandler(async (req: Request) => { const auth = await requireMfaManagementAuth(); const { user } = auth; @@ -31,6 +36,17 @@ export const POST = apiHandler(async () => { return res; } + if (auth.transport === "cookie") { + const refusal = await checkCookieEnrollmentProof({ + user, + sessionId: auth.session.id, + request: req, + ipAddress: getClientIp(req), + stage: "security_key_enroll", + }); + if (refusal) return refusal; + } + await auth.commitElevation(); const { options, challengeId } = await createMfaRegistrationOptions( diff --git a/src/app/api/auth/passkey/register-options/route.ts b/src/app/api/auth/passkey/register-options/route.ts index a84bf7e8a..ac848880c 100644 --- a/src/app/api/auth/passkey/register-options/route.ts +++ b/src/app/api/auth/passkey/register-options/route.ts @@ -2,16 +2,20 @@ import { NextRequest } from "next/server"; import { createAuthenticationOptions, createRegistrationOptions, - verifyAuthentication, } from "@/lib/auth/passkey"; +import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn"; import { - createMfaAuthenticationOptions, - verifyMfaAuthentication, -} from "@/lib/auth/mfa/webauthn"; -import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor"; -import { verifyPassword } from "@/lib/auth/password"; + recordReproofFailure, + throttleReproof, + verifyExistingFactorProof, +} from "@/lib/auth/existing-factor-proof"; import { prisma } from "@/lib/db"; -import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { stepUpMintSchema } from "@/lib/validations/step-up"; @@ -51,50 +55,25 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = stepUpMintSchema.safeParse(body); if (!parsed.success) return proofRequired(); - let proved = false; + // Every proof counts against the account's shared re-proof bucket and every + // refusal is audited. Without both, a stolen session could guess the account + // password or a TOTP code here as fast as it could send requests. + const ip = getClientIp(request); + const limited = await throttleReproof(user.id, ip, "passkey_enroll"); + if (limited) return limited; - if (parsed.data.method === "password") { - proved = Boolean( - user.passwordHash && - (await verifyPassword(user.passwordHash, parsed.data.password)), + const outcome = await verifyExistingFactorProof(user, parsed.data); + if (!outcome.ok) { + await recordReproofFailure( + user.id, + ip, + "passkey_enroll", + parsed.data.method, + outcome.reason, ); - } else if (parsed.data.method === "totp") { - const result = await verifyMfaFactor(user, "totp", parsed.data.code); - proved = result.ok; - } else { - const expectedType = - parsed.data.method === "passkey" - ? "authentication" - : "mfa_authentication"; - const challenge = await prisma.authChallenge.findUnique({ - where: { id: parsed.data.challengeId }, - select: { userId: true, type: true }, - }); - - if (challenge?.userId === user.id && challenge.type === expectedType) { - try { - if (parsed.data.method === "passkey") { - const result = await verifyAuthentication( - parsed.data.challengeId, - parsed.data.credential, - ); - proved = - result.verification.verified && result.passkey.userId === user.id; - } else { - proved = await verifyMfaAuthentication( - parsed.data.challengeId, - user.id, - parsed.data.credential, - ); - } - } catch { - proved = false; - } - } + return proofRequired(); } - if (!proved) return proofRequired(); - // A strong existing factor refreshes the session's MFA stamp. Password proof // deliberately clears it: registration is authorized by this single-use, // session-bound challenge but cannot upgrade password-only authentication. diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts index ae97c2792..9b133e899 100644 --- a/src/app/api/auth/password/route.ts +++ b/src/app/api/auth/password/route.ts @@ -14,7 +14,10 @@ import { returnAllZodIssues, safeJson, } from "@/lib/api-response"; -import { checkRateLimit } from "@/lib/rate-limit"; +import { + recordReproofFailure, + throttleReproof, +} from "@/lib/auth/existing-factor-proof"; import { apiHandler, requireFreshMfaIfEnrolled, @@ -35,14 +38,12 @@ export const POST = apiHandler(async (request: NextRequest) => { // contract. Mirrors the step-up on MFA-disable + recovery-code regen. const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS); - const rl = await checkRateLimit( - `auth:password:${user.id}`, - 5, - 15 * 60 * 1000, - ); - if (!rl.allowed) { - return apiError("Too many attempts. Please wait 15 minutes.", 429); - } + // The current-password check below is a guessing oracle, so it draws on the + // account's shared re-proof budget: a guess spent here is one the step-up + // mint and the enrollment proofs cannot spend again. + const ip = getClientIp(request); + const limited = await throttleReproof(user.id, ip, "password_change"); + if (limited) return limited; const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, @@ -62,6 +63,13 @@ export const POST = apiHandler(async (request: NextRequest) => { const currentValid = await verifyPassword(user.passwordHash, currentPassword); if (!currentValid) { + await recordReproofFailure( + user.id, + ip, + "password_change", + "password", + "bad_password", + ); return apiError("Current password is incorrect", 401); } diff --git a/src/app/api/auth/step-up/route.ts b/src/app/api/auth/step-up/route.ts index 2514d777a..42e564c41 100644 --- a/src/app/api/auth/step-up/route.ts +++ b/src/app/api/auth/step-up/route.ts @@ -40,16 +40,17 @@ import { } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; -import { prisma } from "@/lib/db"; import { checkAuthSurfaceRateLimit, checkRateLimit, rateLimitHeaders, } from "@/lib/rate-limit"; -import { verifyPassword } from "@/lib/auth/password"; -import { verifyAuthentication } from "@/lib/auth/passkey"; -import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn"; -import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor"; +import { + REPROOF_LIMIT, + REPROOF_WINDOW_MS, + reproofBucket, + verifyExistingFactorProof, +} from "@/lib/auth/existing-factor-proof"; import { mintStepUpElevation, isFreshFactorMethod, @@ -65,8 +66,8 @@ export const dynamic = "force-dynamic"; * against a user-supplied string — the same 5-per-15-minutes the password-change * route uses, for the same reason. */ -const MINT_LIMIT = 5; -const MINT_WINDOW_MS = 15 * 60 * 1000; +const MINT_LIMIT = REPROOF_LIMIT; +const MINT_WINDOW_MS = REPROOF_WINDOW_MS; export const POST = apiHandler(async (request: NextRequest) => { const auth = await requireBearerAuth(); @@ -91,8 +92,10 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } + // The account's shared re-proof bucket: a guess spent here is a guess the + // web enrollment surfaces cannot spend again, and the other way round. const userRl = await checkRateLimit( - `auth:step-up:${user.id}`, + reproofBucket(user.id), MINT_LIMIT, MINT_WINDOW_MS, ); @@ -122,78 +125,14 @@ export const POST = apiHandler(async (request: NextRequest) => { } const method: StepUpMethod = parsed.data.method; - let proved = false; + // The shared verifier: the same challenge ownership and ceremony checks, and + // the same confirmed-secret rule for TOTP, as every other re-proof surface. + // The replay guard and the accepted-step burn behave exactly as they do at + // login and at MFA-disable, so a code spent here cannot be replayed there. + const outcome = await verifyExistingFactorProof(user, parsed.data); + const proved = outcome.ok; // Audit-only detail. Never reaches the response — see the file header. - let failure = "invalid"; - - /** - * Resolve a WebAuthn challenge and confirm it belongs to THIS account before - * any verification runs. Both ceremony helpers resolve a challenge by id - * alone, so without this a challenge minted for another account could be - * carried into the ceremony. - */ - const challengeBelongsToCaller = async (id: string): Promise => { - const row = await prisma.authChallenge.findUnique({ - where: { id }, - select: { userId: true }, - }); - return Boolean(row && row.userId === user.id); - }; - - if (parsed.data.method === "password") { - if (!user.passwordHash) { - // An SSO-provisioned account has no password to re-prove. Same refusal as - // a wrong one: the account's credential shape is not a token holder's to - // enumerate. `/api/auth/step-up/options` is the honest discovery path — - // it 409s when there is no credential of the requested kind either. - failure = "no_password"; - } else { - proved = await verifyPassword(user.passwordHash, parsed.data.password); - if (!proved) failure = "bad_password"; - } - } else if (parsed.data.method === "totp") { - // The shared factor verifier, so the replay guard and the accepted-step - // burn behave exactly as they do at login and at MFA-disable. A code spent - // here cannot be replayed there. - const result = await verifyMfaFactor(user, "totp", parsed.data.code); - proved = result.ok; - if (!proved) failure = result.replay ? "totp_replay" : "bad_totp"; - } else if (parsed.data.method === "webauthn") { - if (!(await challengeBelongsToCaller(parsed.data.challengeId))) { - failure = "foreign_challenge"; - } else { - try { - // Scoped to the caller's own second-factor credentials by the verifier. - proved = await verifyMfaAuthentication( - parsed.data.challengeId, - user.id, - parsed.data.credential, - ); - if (!proved) failure = "bad_assertion"; - } catch { - // A malformed / expired challenge is a failed attempt, not a 500. - failure = "bad_assertion"; - } - } - } else { - if (!(await challengeBelongsToCaller(parsed.data.challengeId))) { - failure = "foreign_challenge"; - } else { - try { - const result = await verifyAuthentication( - parsed.data.challengeId, - parsed.data.credential, - ); - // Both halves matter: a verified assertion against SOMEONE ELSE'S - // passkey proves possession of a factor, just not of this account's. - proved = - result.verification.verified && result.passkey.userId === user.id; - if (!proved) failure = "bad_assertion"; - } catch { - failure = "bad_assertion"; - } - } - } + const failure = outcome.ok ? "" : outcome.reason; if (!proved) { await auditLog("auth.stepup.mint.failed", { diff --git a/src/components/settings/security-section/__tests__/factor-enrollment-reauth.test.tsx b/src/components/settings/security-section/__tests__/factor-enrollment-reauth.test.tsx new file mode 100644 index 000000000..19bc81883 --- /dev/null +++ b/src/components/settings/security-section/__tests__/factor-enrollment-reauth.test.tsx @@ -0,0 +1,63 @@ +/** + * TOTP and security-key enrollment ask for a fresh proof when the server says + * so (401 `auth.reproof.required`), and retry the SAME enrollment call with it. + * + * Source-oriented like the passkey reauth test next door: the repository keeps + * component tests free of a browser DOM harness. The behavioural half — that + * the server refuses without the proof — is pinned against Postgres in + * `tests/integration/credential-enrollment-proof.test.ts`. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { ApiError } from "@/lib/api/api-fetch"; +import { isReproofRequired } from "../existing-factor-reauth-dialog"; + +const read = (file: string) => + readFileSync(resolve(__dirname, "..", file), "utf8"); + +describe("isReproofRequired", () => { + it("recognises only the re-proof refusal", () => { + expect( + isReproofRequired( + new ApiError("x", 401, { errorCode: "auth.reproof.required" }), + ), + ).toBe(true); + expect( + isReproofRequired( + new ApiError("x", 401, { errorCode: "auth.reproof.failed" }), + ), + ).toBe(false); + expect( + isReproofRequired( + new ApiError("x", 401, { errorCode: "auth.stepup.required" }), + ), + ).toBe(false); + expect(isReproofRequired(new Error("x"))).toBe(false); + }); +}); + +describe.each([ + ["totp-card.tsx", '"/api/auth/me/mfa/totp/setup"', "beginSetup"], + [ + "security-keys-card.tsx", + '"/api/auth/me/mfa/webauthn/register/options"', + "add", + ], +])("%s", (file, endpoint, mutation) => { + const source = read(file); + + it("sends the collected proof on the enrollment call itself", () => { + expect(source).toContain(endpoint); + expect(source).toMatch( + new RegExp(`${endpoint.replace(/[/.]/g, "\\$&")},\\s*proof\\)`), + ); + }); + + it("opens the re-proof dialog on the server's cue and retries with the proof", () => { + expect(source).toContain("isReproofRequired(err)"); + expect(source).toContain("setReauthOpen(true)"); + expect(source).toContain(`onProof={(proof) => ${mutation}.mutate(proof)}`); + }); +}); diff --git a/src/components/settings/security-section/existing-factor-reauth-dialog.tsx b/src/components/settings/security-section/existing-factor-reauth-dialog.tsx new file mode 100644 index 000000000..63288ce37 --- /dev/null +++ b/src/components/settings/security-section/existing-factor-reauth-dialog.tsx @@ -0,0 +1,230 @@ +"use client"; + +import { useState } from "react"; +import { AlertTriangle, Loader2 } from "lucide-react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useTranslations } from "@/lib/i18n/context"; +import { ApiError, apiPost } from "@/lib/api/api-fetch"; +import { describePasskeyError } from "@/lib/passkey-errors"; + +export type ReauthMethod = "password" | "totp" | "passkey" | "webauthn"; + +/** The body the enrollment routes accept as a fresh existing-factor proof. */ +export type ExistingFactorProof = + | { method: "password"; password: string } + | { method: "totp"; code: string } + | { + method: "passkey" | "webauthn"; + challengeId: string; + credential: unknown; + }; + +/** + * The server answers an enrollment it will not take on the session alone with + * 401 `auth.reproof.required`. That is the cue to ask for a proof and retry, + * not an error to show. + */ +export function isReproofRequired(err: unknown): boolean { + return ( + err instanceof ApiError && + err.status === 401 && + err.meta?.errorCode === "auth.reproof.required" + ); +} + +/** + * Collect a fresh proof of a credential the account already holds, before a + * new factor is added. A passkey or security-key assertion begins at the + * cookie-only `/api/auth/passkey/register-options` re-proof arm, which issues + * an assertion challenge and nothing else; the resulting proof is handed to + * `onProof`, which retries the enrollment call with it. + */ +export function ExistingFactorReauthDialog({ + open, + onOpenChange, + methods, + pending, + error, + onProof, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + methods: ReauthMethod[]; + pending: boolean; + error: string | null; + onProof: (proof: ExistingFactorProof) => void; +}) { + const { t } = useTranslations(); + const [method, setMethod] = useState(methods[0] ?? "password"); + const [secret, setSecret] = useState(""); + const [assertionError, setAssertionError] = useState(null); + const [asserting, setAsserting] = useState(false); + const busy = pending || asserting; + const shown = assertionError ?? error; + + async function submit() { + setAssertionError(null); + if (method === "password") { + onProof({ method, password: secret }); + return; + } + if (method === "totp") { + onProof({ method, code: secret }); + return; + } + setAsserting(true); + try { + const webauthn = await import("@simplewebauthn/browser"); + const begun = await apiPost<{ + options: Parameters< + typeof webauthn.startAuthentication + >[0]["optionsJSON"]; + challengeId: string; + }>("/api/auth/passkey/register-options", { method }); + const credential = await webauthn.startAuthentication({ + optionsJSON: begun.options, + }); + onProof({ method, challengeId: begun.challengeId, credential }); + } catch (err) { + if (err instanceof ApiError) { + setAssertionError(err.message); + } else { + const { key, params } = describePasskeyError(err); + setAssertionError(t(key, params)); + } + } finally { + setAsserting(false); + } + } + + const labels: Record = { + password: t("settings.passkeyReauth.methods.password"), + totp: t("settings.passkeyReauth.methods.totp"), + passkey: t("settings.passkeyReauth.methods.passkey"), + webauthn: t("settings.passkeyReauth.methods.webauthn"), + }; + + return ( + { + if (busy) return; + if (!next) { + setSecret(""); + setAssertionError(null); + } + onOpenChange(next); + }} + > + + + + {t("settings.passkeyReauth.title")} + + + {t("settings.passkeyReauth.factorDescription")} + + + + {methods.length > 1 && ( +
+ {methods.map((m) => ( + + ))} +
+ )} + + {method === "password" && ( + setSecret(event.target.value)} + placeholder={t("settings.passkeyReauth.currentPassword")} + aria-label={t("settings.passkeyReauth.currentPassword")} + autoFocus + /> + )} + {method === "totp" && ( + + setSecret(event.target.value.replace(/\D/g, "")) + } + placeholder={t("settings.passkeyReauth.authenticatorPlaceholder")} + aria-label={t("settings.passkeyReauth.authenticatorCode")} + autoFocus + /> + )} + + {shown && ( +
+ + {shown} +
+ )} + + + + {t("common.cancel")} + + { + event.preventDefault(); + void submit(); + }} + > + {busy && ( + + )} + {t("settings.passkeyReauth.verifyAndContinue")} + + +
+
+ ); +} diff --git a/src/components/settings/security-section/index.tsx b/src/components/settings/security-section/index.tsx index 757b67717..ed7e8dbfe 100644 --- a/src/components/settings/security-section/index.tsx +++ b/src/components/settings/security-section/index.tsx @@ -63,7 +63,10 @@ export function SecuritySection() { recoveryCodesRemaining={status.recoveryCodesRemaining} /> - + diff --git a/src/components/settings/security-section/security-keys-card.tsx b/src/components/settings/security-section/security-keys-card.tsx index 3dcda31d9..deeaca438 100644 --- a/src/components/settings/security-section/security-keys-card.tsx +++ b/src/components/settings/security-section/security-keys-card.tsx @@ -33,6 +33,12 @@ import { queryKeys } from "@/lib/query-keys"; import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch"; import { describePasskeyError } from "@/lib/passkey-errors"; import { formatDate } from "@/lib/format"; +import { + ExistingFactorReauthDialog, + isReproofRequired, + type ExistingFactorProof, + type ReauthMethod, +} from "./existing-factor-reauth-dialog"; export interface WebauthnKeyInfo { id: string; @@ -74,20 +80,38 @@ export function describeStepUp( return fallback; } -export function SecurityKeysCard({ keys }: { keys: WebauthnKeyInfo[] }) { +export function SecurityKeysCard({ + keys, + totpEnabled = false, +}: { + keys: WebauthnKeyInfo[]; + totpEnabled?: boolean; +}) { const { t } = useTranslations(); const queryClient = useQueryClient(); const [error, setError] = useState(null); const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(""); + // Adding a key right after signing in goes straight through; later, the + // server asks for a fresh proof first (401 `auth.reproof.required`) and the + // options call is retried with it from the dialog. + const [reauthOpen, setReauthOpen] = useState(false); + const [reauthError, setReauthError] = useState(null); + const reauthMethods: ReauthMethod[] = [ + "password", + ...(totpEnabled ? (["totp"] as const) : []), + "passkey", + ...(keys.length > 0 ? (["webauthn"] as const) : []), + ]; + const add = useMutation({ - mutationFn: async () => { + mutationFn: async (proof?: ExistingFactorProof) => { const { startRegistration } = await import("@simplewebauthn/browser"); const { options, challengeId } = await apiPost<{ options: Parameters[0]["optionsJSON"]; challengeId: string; - }>("/api/auth/me/mfa/webauthn/register/options"); + }>("/api/auth/me/mfa/webauthn/register/options", proof); const credential = await startRegistration({ optionsJSON: options }); await apiPost("/api/auth/me/mfa/webauthn/register/verify", { challengeId, @@ -96,14 +120,28 @@ export function SecurityKeysCard({ keys }: { keys: WebauthnKeyInfo[] }) { }, onSuccess: () => { setError(null); + setReauthOpen(false); + setReauthError(null); queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() }); }, - onError: (err) => { + onError: (err, proof) => { + if (isReproofRequired(err)) { + setError(null); + setReauthError(null); + setReauthOpen(true); + return; + } + let message: string; if (err instanceof ApiError) { - setError(err.message || t("settings.security.keys.addFailed")); + message = err.message || t("settings.security.keys.addFailed"); } else { const { key, params } = describePasskeyError(err); - setError(t(key, params)); + message = t(key, params); + } + if (proof) { + setReauthError(message); + } else { + setError(message); } }, }); @@ -294,7 +332,7 @@ export function SecurityKeysCard({ keys }: { keys: WebauthnKeyInfo[] }) { type="button" variant="outline" className="min-h-11 sm:min-h-9" - onClick={() => add.mutate()} + onClick={() => add.mutate(undefined)} disabled={add.isPending} > {add.isPending ? ( @@ -305,6 +343,17 @@ export function SecurityKeysCard({ keys }: { keys: WebauthnKeyInfo[] }) { {t("settings.security.keys.add")} + { + setReauthOpen(open); + if (!open) setReauthError(null); + }} + methods={reauthMethods} + pending={add.isPending} + error={reauthError} + onProof={(proof) => add.mutate(proof)} + /> {error && (
(null); + const beginSetup = useMutation({ - mutationFn: async () => { - return apiPost("/api/auth/me/mfa/totp/setup"); + mutationFn: async (proof?: ExistingFactorProof) => { + return apiPost("/api/auth/me/mfa/totp/setup", proof); }, onSuccess: async (data) => { setError(null); + setReauthOpen(false); + setReauthError(null); setSetup(data); try { const QRCode = (await import("qrcode")).default; @@ -133,14 +146,21 @@ export function TotpCard({ setQrDataUrl(null); } }, - onError: (err) => - setError( - describeError( - err, - t("settings.security.totp.setupFailed"), - t("settings.security.stepUpRequired"), - ), - ), + onError: (err, proof) => { + if (isReproofRequired(err)) { + setError(null); + setReauthError(null); + setReauthOpen(true); + return; + } + const message = describeError( + err, + t("settings.security.totp.setupFailed"), + t("settings.security.stepUpRequired"), + ); + if (proof) setReauthError(message); + else setError(message); + }, }); const confirm = useMutation({ @@ -241,7 +261,7 @@ export function TotpCard({ type="button" data-testid="totp-setup-start" className="min-h-11 sm:min-h-9" - onClick={() => beginSetup.mutate()} + onClick={() => beginSetup.mutate(undefined)} disabled={beginSetup.isPending} > {beginSetup.isPending ? ( @@ -252,6 +272,17 @@ export function TotpCard({ {t("settings.security.totp.setUp")} )} + { + setReauthOpen(open); + if (!open) setReauthError(null); + }} + methods={["password", "passkey", "webauthn"]} + pending={beginSetup.isPending} + error={reauthError} + onProof={(proof) => beginSetup.mutate(proof)} + /> {/* ── Enable wizard ── */} {setup && ( diff --git a/src/lib/auth/existing-factor-proof.ts b/src/lib/auth/existing-factor-proof.ts new file mode 100644 index 000000000..baef55342 --- /dev/null +++ b/src/lib/auth/existing-factor-proof.ts @@ -0,0 +1,284 @@ +/** + * Re-proving a credential the account already holds. + * + * Several surfaces accept a fresh proof in the request body before they act: + * the Bearer step-up mint, the primary-passkey enrollment, TOTP enrollment and + * security-key enrollment. They used to verify it each in their own way, and + * the copies drifted — one checked who owned a WebAuthn challenge but not its + * type, one had no rate limit and wrote no audit row for a wrong password, so + * a stolen session could guess the password or a TOTP code there without + * limit and without trace. This module is the one verifier and the one + * throttle, so every surface refuses, counts and records a failed proof the + * same way. + * + * The throttle is ONE bucket per account shared by every surface: five proofs + * in fifteen minutes, the ceiling the step-up mint has used since it shipped + * (same key, so its counter carries straight over). A shared bucket means a + * guesser cannot multiply the budget by rotating between routes. + */ +import type { User } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { apiError } from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { verifyPassword } from "@/lib/auth/password"; +import { verifyAuthentication } from "@/lib/auth/passkey"; +import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn"; +import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor"; +import { + stepUpMintSchema, + type StepUpMintRequest, +} from "@/lib/validations/step-up"; + +export type ExistingFactorProof = StepUpMintRequest; + +export const REPROOF_LIMIT = 5; +export const REPROOF_WINDOW_MS = 15 * 60 * 1000; + +/** The per-account bucket every re-proof surface draws from. */ +export function reproofBucket(userId: string): string { + return `auth:step-up:${userId}`; +} + +/** + * Charge one attempt to the account's re-proof bucket. Returns the 429 to send + * when the bucket is empty (and writes the audit row), or null to proceed. + * Call it only when a proof is actually about to be verified, so a request that + * needs no proof does not spend one. + */ +export async function throttleReproof( + userId: string, + ipAddress: string | null, + stage: string, +): Promise { + const rl = await checkRateLimit( + reproofBucket(userId), + REPROOF_LIMIT, + REPROOF_WINDOW_MS, + ); + if (rl.allowed) return null; + await auditLog("auth.reproof.rate_limited", { + userId, + ipAddress, + details: { stage }, + }); + annotate({ + action: { name: "auth.reproof.rate_limited" }, + meta: { stage }, + }); + return apiError("Too many attempts. Please wait 15 minutes.", 429, { + headers: rateLimitHeaders(rl), + }); +} + +export type ProofOutcome = { ok: true } | { ok: false; reason: string }; + +/** + * Verify a proof against the account. Never throws for a bad proof: a + * malformed or expired WebAuthn challenge is a failed attempt, not a 500. + * + * A WebAuthn challenge must belong to this account AND be of the ceremony the + * method names, because both ceremony helpers resolve a challenge by id alone. + * A TOTP code is accepted only from a confirmed secret (`verifyMfaFactor`). + */ +export async function verifyExistingFactorProof( + user: Pick< + User, + | "id" + | "passwordHash" + | "totpSecretEncrypted" + | "totpLastStep" + | "totpConfirmedAt" + >, + proof: ExistingFactorProof, +): Promise { + if (proof.method === "password") { + if (!user.passwordHash) return { ok: false, reason: "no_password" }; + const ok = await verifyPassword(user.passwordHash, proof.password); + return ok ? { ok } : { ok: false, reason: "bad_password" }; + } + + if (proof.method === "totp") { + const result = await verifyMfaFactor(user, "totp", proof.code); + if (result.ok) return { ok: true }; + return { ok: false, reason: result.replay ? "totp_replay" : "bad_totp" }; + } + + const expectedType = + proof.method === "passkey" ? "authentication" : "mfa_authentication"; + const challenge = await prisma.authChallenge.findUnique({ + where: { id: proof.challengeId }, + select: { userId: true, type: true }, + }); + if (!challenge || challenge.userId !== user.id) { + return { ok: false, reason: "foreign_challenge" }; + } + if (challenge.type !== expectedType) { + return { ok: false, reason: "wrong_ceremony" }; + } + + try { + if (proof.method === "passkey") { + const result = await verifyAuthentication( + proof.challengeId, + proof.credential, + ); + // Both halves matter: a verified assertion against SOMEONE ELSE'S passkey + // proves possession of a factor, just not of this account's. + const ok = + result.verification.verified && result.passkey.userId === user.id; + return ok ? { ok } : { ok: false, reason: "bad_assertion" }; + } + const ok = await verifyMfaAuthentication( + proof.challengeId, + user.id, + proof.credential, + ); + return ok ? { ok } : { ok: false, reason: "bad_assertion" }; + } catch { + return { ok: false, reason: "bad_assertion" }; + } +} + +/** + * Record a refused proof. The machine reason goes to the audit row and the + * wide event; the wire carries none of it. + */ +export async function recordReproofFailure( + userId: string, + ipAddress: string | null, + stage: string, + method: ExistingFactorProof["method"], + reason: string, +): Promise { + await auditLog("auth.mfa.failed", { + userId, + ipAddress, + details: { stage, method, reason }, + }); + annotate({ + action: { name: "auth.reproof.failed" }, + meta: { stage, method, reason }, + }); +} + +/** No proof was presented, and the session carries none recent enough. */ +export const REPROOF_REQUIRED_CODE = "auth.reproof.required"; +/** A proof was presented and did not verify. The session is untouched. */ +export const REPROOF_FAILED_CODE = "auth.reproof.failed"; + +/** + * How recent a sign-in or a second-factor stamp must be to stand in for a + * proof in the body. The step-up window, so a fresh login is exactly as good + * here as it is on the fresh-factor routes. + */ +export const RECENT_PROOF_MAX_AGE_MS = 5 * 60 * 1000; + +/** + * The cookie-path gate in front of ADDING a sign-in credential or second + * factor to an account (TOTP enrollment, security-key enrollment). + * + * A live session on its own is not enough. If it were, whoever held a stolen + * session could enroll their own authenticator, and from then on hold a factor + * that satisfies every step-up gate, registers a passkey and survives the + * owner changing the password. So the caller must show that the person at the + * keyboard can authenticate as the account owner right now, in one of three + * ways: + * + * - the session completed a second factor or passkey sign-in within the last + * five minutes (`Session.mfaVerifiedAt`); + * - the session itself was created by a sign-in within the last five + * minutes (`Session.createdAt`, which no later write touches) — this is + * also what lets an SSO-only account with nothing else to re-prove enroll + * right after signing in; + * - the body carries a proof: `{ method: "password", password }`, + * `{ method: "totp", code }` or a passkey / security-key assertion, the + * same shapes the step-up mint accepts. + * + * Returns the refusal to send, or null to proceed. Proofs in the body are + * throttled and audited through the shared re-proof bucket. + */ +export async function checkCookieEnrollmentProof(args: { + user: User; + sessionId: string; + request: Request; + ipAddress: string | null; + stage: string; +}): Promise { + const { user, sessionId, request, ipAddress, stage } = args; + + const row = await prisma.session.findUnique({ + where: { id: sessionId }, + select: { mfaVerifiedAt: true, createdAt: true }, + }); + const now = Date.now(); + const recent = (d: Date | null | undefined) => + d != null && now - d.getTime() <= RECENT_PROOF_MAX_AGE_MS; + if (recent(row?.mfaVerifiedAt) || recent(row?.createdAt)) { + annotate({ meta: { enrollment_proof: "recent_session" } }); + return null; + } + + const proof = await readProof(request); + if (proof === "absent") { + annotate({ action: { name: "auth.reproof.required" }, meta: { stage } }); + return apiError("Confirm it is you before adding a sign-in method", 401, { + errorCode: REPROOF_REQUIRED_CODE, + }); + } + if (proof === "invalid") { + return apiError("Invalid verification", 422, { + errorCode: REPROOF_REQUIRED_CODE, + }); + } + + const limited = await throttleReproof(user.id, ipAddress, stage); + if (limited) return limited; + + const outcome = await verifyExistingFactorProof(user, proof); + if (!outcome.ok) { + await recordReproofFailure( + user.id, + ipAddress, + stage, + proof.method, + outcome.reason, + ); + return apiError("Verification failed", 401, { + errorCode: REPROOF_FAILED_CODE, + }); + } + annotate({ meta: { enrollment_proof: proof.method } }); + return null; +} + +async function readProof( + request: Request, +): Promise { + if (!request.headers.get("content-type")?.includes("application/json")) { + return "absent"; + } + let raw: string; + try { + raw = await request.text(); + } catch { + return "invalid"; + } + if (raw.trim() === "") return "absent"; + if (raw.length > 64 * 1024) return "invalid"; + let body: unknown; + try { + body = JSON.parse(raw); + } catch { + return "invalid"; + } + if ( + body === null || + (typeof body === "object" && Object.keys(body).length === 0) + ) { + return "absent"; + } + const parsed = stepUpMintSchema.safeParse(body); + return parsed.success ? parsed.data : "invalid"; +} diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 92602141d..259d519ea 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -60,6 +60,8 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "auth.refresh.invalid", "auth.refresh.reuse", "auth.refresh.revoked", + "auth.reproof.failed", + "auth.reproof.required", "auth.scope.insufficient", "auth.stepup.required", "auth.token.expired", diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 61f4f0743..a294e6eed 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -315,6 +315,17 @@ const successFlagResponse = z const MFA_MANAGEMENT_AUTH_NOTE = " Accepts a cookie session, or a Bearer token presenting a single-use elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused."; +const ENROLLMENT_PROOF_NOTE = + "\n\nAdding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof (a password-proved one is enough). On the cookie path the request passes when the session signed in, or completed a second factor, within the last five minutes; otherwise the body must carry a proof in the shape POST /api/auth/step-up takes (`password`, a `totp` code from an already confirmed authenticator, or a passkey / security-key assertion begun at POST /api/auth/passkey/register-options). Without one the answer is 401 `auth.reproof.required`; a proof that does not verify is 401 `auth.reproof.failed` and is audited. Proofs draw on the account's shared re-proof budget of five per fifteen minutes (429 once spent), the same budget as the step-up mint."; + +const enrollmentProofResponses = { + "401": { + description: + "No session or elevation; or, on the cookie path, `auth.reproof.required` (no recent sign-in and no proof in the body) or `auth.reproof.failed` (the proof did not verify).", + content: { "application/json": { schema: errorEnvelope } }, + }, +} as const; + // ── v1.30.34 step-up elevation (Bearer transport) ──────────────────── const stepUpOptionsResponse = z @@ -805,7 +816,7 @@ export const authPaths: NonNullable = { "Re-prove a factor and receive an opaque elevation that authorises exactly ONE second-factor-management call.\n\n" + "WHICH factor you re-prove decides WHAT the elevation reaches. `password` reaches the same routes a plain cookie session reaches. `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes — MFA disable, recovery-code regeneration, security-key removal — which is precisely the set of ceremonies for which the web marks a session second-factor-verified. The response carries `satisfiesFreshFactor` so a client can choose the right ceremony up front rather than discovering the refusal after spending a proof. A recovery code is NOT accepted here; an account that has lost its authenticator manages its second factor on the web.\n\n" + "The elevation is bound to the exact token that minted it (another token, including the same account's, cannot redeem it), single-use, and valid for five minutes — the same window the cookie path uses. Present it as `X-Step-Up: hle_…` alongside the normal `Authorization: Bearer` header. It is spent only when the target route is about to act, so a 429, a 422, or a wrong code does not burn it.\n\n" + - "Presenting the token alone mints nothing: the body must carry a fresh factor proof. Every failure — wrong password, no password set on an SSO-provisioned account, an assertion for another account, a stale challenge, a replayed TOTP step — returns the same 401 with the same prose, and is audited server-side. Rate-limited per account (5 / 15 min) and per source address.\n\n" + + "Presenting the token alone mints nothing: the body must carry a fresh factor proof. Every failure — wrong password, no password set on an SSO-provisioned account, an assertion for another account, a stale challenge, a replayed TOTP step — returns the same 401 with the same prose, and is audited server-side. Rate-limited per source address and per account (5 / 15 min); the per-account budget is shared with the web's credential-enrollment proofs. A `totp` proof counts only from an authenticator whose enrollment was confirmed: a code from a pending secret is refused like a wrong one.\n\n" + "Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE /api/auth/me/mfa/webauthn/{id}; DELETE /api/settings/account; DELETE /api/settings/data. The last three MFA routes, disable, and both erasure routes require a fresh-factor proof. GET /api/auth/me/mfa needs no elevation at all. Nothing else accepts one — admin endpoints stay cookie-only.\n\n" + "The two erasure routes joined the set so an account with a second factor can be deleted from a native app: their gate was cookie-only, which left an enrolled user unable to delete their own account from the app at all. They take the elevation the same way as the rest — fresh factor required, spent only when the erasure is about to run.", requestBody: { @@ -830,8 +841,13 @@ export const authPaths: NonNullable = { tags: ["Auth"], summary: "Begin TOTP enrollment (cookie session or step-up elevation)", description: - "Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is not active until /confirm." + - MFA_MANAGEMENT_AUTH_NOTE, + "Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is not active until /confirm, and a pending secret is not a factor anywhere: a code from it is refused by every route that verifies one." + + MFA_MANAGEMENT_AUTH_NOTE + + ENROLLMENT_PROOF_NOTE, + requestBody: { + required: false, + content: { "application/json": { schema: stepUpMintSchema } }, + }, responses: { "200": { description: "Pending secret created.", @@ -846,6 +862,7 @@ export const authPaths: NonNullable = { content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, + ...enrollmentProofResponses, }, }, }, @@ -953,7 +970,12 @@ export const authPaths: NonNullable = { summary: "Begin registering a security key as a second factor", description: "Returns SimpleWebAuthn creation options + a challenge id." + - MFA_MANAGEMENT_AUTH_NOTE, + MFA_MANAGEMENT_AUTH_NOTE + + ENROLLMENT_PROOF_NOTE, + requestBody: { + required: false, + content: { "application/json": { schema: stepUpMintSchema } }, + }, responses: { "200": { description: "Registration options issued.", @@ -967,6 +989,7 @@ export const authPaths: NonNullable = { }, }, ...stdResponses, + ...enrollmentProofResponses, }, }, }, @@ -1922,7 +1945,8 @@ export const authPaths: NonNullable = { '**Re-proof arm.** A body of exactly `{ method: "passkey" }` or `{ method: "webauthn" }` begins an assertion ceremony WITHOUT beginning enrollment, and answers with `reauth: true` alongside the options. Use it to obtain the assertion the second arm needs.\n\n' + "**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options issued.\n\n" + "Which factor you prove changes the session, and the direction is not the obvious one: a strong proof stamps the session second-factor-verified, while a PASSWORD proof deliberately CLEARS that stamp. Registering with a password is authorised by the single-use, session-bound challenge alone and must not silently upgrade password-only authentication into something the rest of the app reads as a second factor.\n\n" + - "Every refusal on the proof path is the same 401 with the same prose — a missing content type, an unparseable body, a body that is not a valid proof shape, and a wrong password are indistinguishable to the caller.", + "Every refusal on the proof path is the same 401 with the same prose — a missing content type, an unparseable body, a body that is not a valid proof shape, and a wrong password are indistinguishable to the caller. A `totp` proof counts only from an authenticator whose enrollment was confirmed.\n\n" + + "Each proof on the enrollment arm draws on the account's shared re-proof budget (five per fifteen minutes, the same budget as POST /api/auth/step-up), and every failed proof writes an `auth.mfa.failed` audit row. Once the budget is spent the answer is 429 until the window passes, even for a correct proof.", requestBody: { required: true, content: { diff --git a/src/lib/validations/step-up.ts b/src/lib/validations/step-up.ts index 96028cd6e..0c018a1b1 100644 --- a/src/lib/validations/step-up.ts +++ b/src/lib/validations/step-up.ts @@ -55,6 +55,8 @@ export const stepUpMintSchema = z "Re-prove a factor to mint a single-use, token-bound step-up elevation. `password` reaches the same routes a plain cookie session reaches; `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes (disable, recovery-code rotation, security-key removal), matching exactly the ceremonies for which the web stamps a session second-factor-verified.", }); +export type StepUpMintRequest = z.infer; + /** Which assertion ceremony to begin at `POST /api/auth/step-up/options`. */ export const stepUpOptionsSchema = z .object({ diff --git a/tests/integration/auth-password-change.test.ts b/tests/integration/auth-password-change.test.ts index 44bc9f7bb..90fa48f52 100644 --- a/tests/integration/auth-password-change.test.ts +++ b/tests/integration/auth-password-change.test.ts @@ -214,6 +214,30 @@ describe("POST /api/auth/password (real Postgres)", () => { ); }); + it("audits a wrong current password and shares the re-proof ceiling", async () => { + const { userId } = await seedUserWithSession(); + const prisma = getPrismaClient(); + const { POST } = await import("@/app/api/auth/password/route"); + + const statuses: number[] = []; + for (let i = 0; i < 6; i++) { + const res = await POST( + makeRequest({ + currentPassword: `WrongPasswordValue!${i}`, + newPassword: STRONG_NEW, + confirmPassword: STRONG_NEW, + }), + ); + statuses.push(res.status); + } + expect(statuses).toEqual([401, 401, 401, 401, 401, 429]); + expect( + await prisma.auditLog.count({ + where: { userId, action: "auth.mfa.failed" }, + }), + ).toBe(5); + }); + it("returns 422 when the new password matches the current password", async () => { await seedUserWithSession(); diff --git a/tests/integration/credential-enrollment-proof.test.ts b/tests/integration/credential-enrollment-proof.test.ts new file mode 100644 index 000000000..383b40a0c --- /dev/null +++ b/tests/integration/credential-enrollment-proof.test.ts @@ -0,0 +1,265 @@ +/** + * Adding a factor to an account needs a fresh proof, not just a live session. + * + * The chain this closes: a stolen browser session starts TOTP enrollment, reads + * a code from the secret it was just handed, and presents that code as "an + * existing second factor" — to mint a step-up, to register its own passkey, to + * pass the fresh-factor gates. Two rules break it, and both are pinned here + * against real Postgres: + * + * 1. a code from a secret that was never confirmed is not a factor anywhere; + * 2. the cookie arm of TOTP and security-key enrollment wants a sign-in or a + * second factor from the last five minutes, or a proof in the body. + */ +import { NextRequest } from "next/server"; +import * as OTPAuth from "otpauth"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-enrollment-proof-32-bytes-minimum-1234567890"; + +const { hashToken } = await import("@/lib/auth/hmac"); +const { hashPassword } = await import("@/lib/auth/password"); + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const USER_ID = "user-enroll-proof"; +const PASSWORD = "Correct horse battery staple!42"; +const HOUR = 60 * 60 * 1000; + +type RouteFn = (request: NextRequest) => Promise; + +function post(path: string, body?: unknown): NextRequest { + const headers: Record = {}; + for (const name of ["authorization", "x-step-up"]) { + const value = headerJar.get(name); + if (value) headers[name] = value; + } + if (body !== undefined) headers["content-type"] = "application/json"; + return new NextRequest(`http://localhost${path}`, { + method: "POST", + headers, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +async function totpSetup(body?: unknown) { + const { POST } = await import("@/app/api/auth/me/mfa/totp/setup/route"); + return (POST as unknown as RouteFn)( + post("/api/auth/me/mfa/totp/setup", body), + ); +} + +async function securityKeyOptions(body?: unknown) { + const { POST } = + await import("@/app/api/auth/me/mfa/webauthn/register/options/route"); + return (POST as unknown as RouteFn)( + post("/api/auth/me/mfa/webauthn/register/options", body), + ); +} + +async function stepUpMint(body: unknown) { + const { POST } = await import("@/app/api/auth/step-up/route"); + return (POST as unknown as RouteFn)(post("/api/auth/step-up", body)); +} + +async function startSession(opts: { + createdAgoMs: number; + mfaVerifiedAgoMs?: number; +}): Promise { + const now = Date.now(); + const session = await getPrismaClient().session.create({ + data: { + userId: USER_ID, + createdAt: new Date(now - opts.createdAgoMs), + expiresAt: new Date(now + HOUR), + mfaVerifiedAt: + opts.mfaVerifiedAgoMs === undefined + ? null + : new Date(now - opts.mfaVerifiedAgoMs), + }, + }); + cookieJar.set("healthlog_session", session.id); + return session.id; +} + +async function seedPendingTotp(): Promise { + const { generateTotpSecret } = await import("@/lib/auth/mfa/totp"); + const { encrypt } = await import("@/lib/crypto"); + const secret = generateTotpSecret(); + await getPrismaClient().user.update({ + where: { id: USER_ID }, + data: { + totpSecretEncrypted: encrypt(secret), + totpConfirmedAt: null, + totpLastStep: null, + }, + }); + return secret; +} + +function codeFor(secret: string): string { + return new OTPAuth.TOTP({ + issuer: "HealthLog", + label: "HealthLog", + algorithm: "SHA1", + digits: 6, + period: 30, + secret: OTPAuth.Secret.fromBase32(secret), + }).generate({ timestamp: Date.now() }); +} + +async function errorCode(res: Response): Promise { + const body = (await res.json()) as { meta?: { errorCode?: string } }; + return body.meta?.errorCode; +} + +async function storedSecret(): Promise { + const user = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: USER_ID }, + select: { totpSecretEncrypted: true }, + }); + return user.totpSecretEncrypted; +} + +beforeEach(async () => { + vi.clearAllMocks(); + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + process.env.APP_URL = "http://localhost:3000"; + process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000"; + await getPrismaClient().user.create({ + data: { + id: USER_ID, + username: "enroll-user", + email: "enroll@example.test", + role: "USER", + passwordHash: await hashPassword(PASSWORD), + }, + }); +}); + +describe("TOTP enrollment on the cookie arm", () => { + it("refuses a session that is neither fresh nor carrying a proof", async () => { + await startSession({ createdAgoMs: HOUR }); + const res = await totpSetup(); + expect(res.status).toBe(401); + expect(await errorCode(res)).toBe("auth.reproof.required"); + expect(await storedSecret()).toBeNull(); + }); + + it("enrolls with the account password in the body", async () => { + await startSession({ createdAgoMs: HOUR }); + const res = await totpSetup({ method: "password", password: PASSWORD }); + expect(res.status).toBe(200); + expect(await storedSecret()).not.toBeNull(); + }); + + it("enrolls without a body right after signing in", async () => { + await startSession({ createdAgoMs: 30_000 }); + const res = await totpSetup(); + expect(res.status).toBe(200); + }); + + it("enrolls without a body after a recent second factor", async () => { + await startSession({ createdAgoMs: HOUR, mfaVerifiedAgoMs: 60_000 }); + const res = await totpSetup(); + expect(res.status).toBe(200); + }); + + it("refuses and audits a wrong password", async () => { + await startSession({ createdAgoMs: HOUR }); + const res = await totpSetup({ method: "password", password: "nope" }); + expect(res.status).toBe(401); + expect(await errorCode(res)).toBe("auth.reproof.failed"); + expect(await storedSecret()).toBeNull(); + expect( + await getPrismaClient().auditLog.count({ + where: { userId: USER_ID, action: "auth.mfa.failed" }, + }), + ).toBe(1); + }); + + it("does not take a code from its own pending secret as the proof", async () => { + const secret = await seedPendingTotp(); + await startSession({ createdAgoMs: HOUR }); + const res = await totpSetup({ method: "totp", code: codeFor(secret) }); + expect(res.status).toBe(401); + expect(await errorCode(res)).toBe("auth.reproof.failed"); + }); + + it("cannot be guessed at past the shared re-proof ceiling", async () => { + await startSession({ createdAgoMs: HOUR }); + const statuses: number[] = []; + for (let i = 0; i < 6; i++) { + statuses.push( + (await totpSetup({ method: "password", password: `guess-${i}` })) + .status, + ); + } + expect(statuses).toEqual([401, 401, 401, 401, 401, 429]); + }); +}); + +describe("security-key enrollment on the cookie arm", () => { + it("refuses a stale session without a proof and creates no challenge", async () => { + await startSession({ createdAgoMs: HOUR }); + const res = await securityKeyOptions(); + expect(res.status).toBe(401); + expect(await errorCode(res)).toBe("auth.reproof.required"); + expect(await getPrismaClient().authChallenge.count()).toBe(0); + }); + + it("issues options once the password is re-proved", async () => { + await startSession({ createdAgoMs: HOUR }); + const res = await securityKeyOptions({ + method: "password", + password: PASSWORD, + }); + expect(res.status).toBe(200); + expect(await getPrismaClient().authChallenge.count()).toBe(1); + }); +}); + +describe("a pending TOTP secret on the Bearer arm", () => { + it("cannot mint a step-up elevation", async () => { + const secret = await seedPendingTotp(); + const raw = `hlk_enrollproof${"0".repeat(64 - "enrollproof".length)}`; + await getPrismaClient().apiToken.create({ + data: { + userId: USER_ID, + name: "phone", + tokenHash: hashToken(raw), + permissions: ["*"], + }, + }); + headerJar.set("authorization", `Bearer ${raw}`); + + const res = await stepUpMint({ method: "totp", code: codeFor(secret) }); + expect(res.status).toBe(401); + expect(await getPrismaClient().stepUpElevation.count()).toBe(0); + }); +}); diff --git a/tests/integration/passkey-register.test.ts b/tests/integration/passkey-register.test.ts index d1dd00921..a78ecf6e9 100644 --- a/tests/integration/passkey-register.test.ts +++ b/tests/integration/passkey-register.test.ts @@ -359,6 +359,61 @@ describe("passkey register-options existing-factor proof (real Postgres)", () => await expectNoCredential(); }); + it("refuses a code from a TOTP secret that was never confirmed", async () => { + const secret = await seedTotp(); + await getPrismaClient().user.update({ + where: { id: USER_ID }, + data: { totpConfirmedAt: null }, + }); + + const response = await registerOptions({ + method: "totp", + code: currentTotpCode(secret), + }); + + expect(response.status).toBe(401); + expect(await getPrismaClient().authChallenge.count()).toBe(0); + const session = await getPrismaClient().session.findFirstOrThrow({ + where: { userId: USER_ID }, + select: { mfaVerifiedAt: true }, + }); + expect(session.mfaVerifiedAt).toBeNull(); + }); + + it("audits every wrong proof and stops guessing after five", async () => { + const statuses: number[] = []; + for (let i = 0; i < 6; i++) { + const response = await registerOptions({ + method: "password", + password: `wrong-guess-${i}`, + }); + statuses.push(response.status); + } + + expect(statuses).toEqual([401, 401, 401, 401, 401, 429]); + const failed = await getPrismaClient().auditLog.findMany({ + where: { userId: USER_ID, action: "auth.mfa.failed" }, + select: { details: true }, + }); + expect(failed).toHaveLength(5); + const details = failed[0].details; + expect( + typeof details === "string" ? JSON.parse(details) : details, + ).toMatchObject({ + stage: "passkey_enroll", + method: "password", + reason: "bad_password", + }); + + // The bucket is spent, so even the right password waits it out. + const correct = await registerOptions({ + method: "password", + password: PASSWORD, + }); + expect(correct.status).toBe(429); + expect(await getPrismaClient().authChallenge.count()).toBe(0); + }); + it.each([ ["wildcard", ["*"]], ["narrow", ["medication:ingest"]],