diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 728e429f9..60b4595b0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -393,7 +393,7 @@ paths: "403": *a2 "409": *a7 "422": *a4 - "429": &a13 + "429": &a12 description: "Rate limit exceeded. This route shares one per-account bucket with the other single-record writes — `record-write:`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns their own allowance rather than the record owner's. Nothing was written. `meta.errorCode` is @@ -703,7 +703,7 @@ paths: application/json: schema: $ref: "#/components/schemas/CyclePrefsPatchEnvelope" - "400": &a15 + "400": &a14 description: The request body is not parseable JSON. Nothing was read and nothing was written. `meta.errorCode` names the surface that refused it (`.body.invalid_json`). This is a client-side serialisation fault, not a validation failure — a body that parses but fails the schema is the 422 beside this. @@ -895,7 +895,7 @@ paths: type: string format: binary "401": *a1 - "403": &a12 + "403": &a11 description: "Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections do not reach this surface, and a read grant on a request that writes — the @@ -1896,24 +1896,19 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": &a9 - description: "Not implemented yet: the contract is published ahead of the implementation on a release branch. - `meta.errorCode: not_implemented`." - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" /api/auth/claim: post: security: [] tags: - Auth summary: Claim a managed profile - description: "Turns the managed profile behind a handover link into the claimant's own account: sets the username, email - and password on the profile's record, clears the managed marker, settles each guardian's access by the - claimant's decision, withdraws pending MANAGE invitations, and resets the disclaimer, onboarding and AI consent - so the new owner gives them personally. No data moves. Signs the new owner in. Anonymous; the token travels in - the body, and every token failure answers the same 404." + description: "Turns the managed profile behind a handover link into the claimant's own account, in one transaction: sets + the username, email and password on the profile's record, clears the managed marker, withdraws pending + invitations, applies each guardian's proposed access, and resets the disclaimer, onboarding and AI consent so + the new owner gives them personally. The new owner then decides each guardian's access on first sign-in + (`/api/account/handover-decision`). No data moves. Signs the new owner in. Anonymous; the token travels in the + body, every token failure answers the same 404, and five attempts per fifteen minutes per source address are + allowed." requestBody: required: true content: @@ -1942,14 +1937,13 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "409": description: "The request carries a signed-in session (`meta.errorCode: auth.already_authenticated`), or the username or - email is taken." + email is taken (`meta.errorCode: profile_claim.taken`)." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a9 /api/auth/login: post: security: [] @@ -2000,7 +1994,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "409": &a10 + "409": &a9 description: 'Refused before anything was checked or spent: the request stated `clientTransport.protocol: "http"` on a non-loopback host while this server issues `Secure` session cookies, so the browser would drop the session. `meta.errorCode` = `auth.session.insecure_transport`, `meta.sessionCookieSecure` = `true`. No password was @@ -2035,7 +2029,7 @@ paths: schema: $ref: "#/components/schemas/MfaVerifyResponse" "401": *a1 - "409": *a10 + "409": *a9 "422": *a4 "429": *a5 /api/auth/oidc/native/token: @@ -2332,7 +2326,7 @@ paths: application/json: schema: $ref: "#/components/schemas/TotpSetupEnvelope" - "401": &a11 + "401": &a10 description: No session or elevation; or, on the cookie path, `auth.reproof.required` (no recent sign-in and no proof in the body) or `auth.reproof.failed` (the proof did not verify). content: @@ -2480,7 +2474,7 @@ paths: application/json: schema: $ref: "#/components/schemas/MfaWebauthnRegisterOptionsEnvelope" - "401": *a11 + "401": *a10 "422": *a4 "429": *a5 /api/auth/me/mfa/webauthn/register/verify: @@ -2628,7 +2622,7 @@ paths: schema: $ref: "#/components/schemas/MfaWebauthnVerifyResponse" "401": *a1 - "409": *a10 + "409": *a9 "422": *a4 "429": *a5 /api/auth/passkey/login-verify: @@ -2969,7 +2963,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "409": *a10 + "409": *a9 "429": *a5 /api/auth/passkeys/{id}: patch: @@ -3580,7 +3574,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a17 + "429": &a16 description: "Too many email-address changes, and the request asked for nothing else. The address change shares one per-account bucket across both profile routes — `profile-email:`, 10 requests per 3600 seconds — keyed on the ACTING account, so an account cannot collect a fresh allowance by probing a different address. @@ -4016,7 +4010,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesBatchResponse" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/measurements/series: @@ -4071,7 +4065,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesResponse" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/measurements: @@ -4270,7 +4264,7 @@ paths: schema: $ref: "#/components/schemas/ListMeasurementsResponse" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 post: @@ -4326,7 +4320,7 @@ paths: schema: $ref: "#/components/schemas/CreateMeasurementResponse" "401": *a1 - "403": *a12 + "403": *a11 "409": description: A measurement with this data already exists — the `(userId, type, source, externalId)` dedup index rejected the write. Never for a named sleep-stage body, which answers a re-post with 200 instead. @@ -4342,7 +4336,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a13 + "429": *a12 /api/measurements/{id}: get: tags: @@ -4363,7 +4357,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementResponse" "401": *a1 - "403": *a12 + "403": *a11 "404": description: Measurement not found (or owned by another user). content: @@ -4411,7 +4405,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMeasurementResponse" "401": *a1 - "403": *a12 + "403": *a11 "404": description: Measurement not found (or owned by another user). content: @@ -4447,7 +4441,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMeasurementResponse" "401": *a1 - "403": *a12 + "403": *a11 "404": description: Measurement not found (or owned by another user). content: @@ -4553,7 +4547,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsBulkDeleteResponse" "401": *a1 - "403": *a12 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -4582,7 +4576,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsRestoreResponse" "401": *a1 - "403": *a12 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -4613,7 +4607,7 @@ paths: schema: $ref: "#/components/schemas/SleepNightResponse" "401": *a1 - "403": &a14 + "403": &a13 description: >- Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a @@ -4654,7 +4648,7 @@ paths: schema: $ref: "#/components/schemas/SleepRhythmResponse" "401": *a1 - "403": *a14 + "403": *a13 "422": *a4 "429": *a5 /api/personal-records: @@ -4701,7 +4695,7 @@ paths: schema: $ref: "#/components/schemas/ListPersonalRecordsEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": description: "`metricType` or `limit` carried an unacceptable value. `meta.errorCode` = `personal_records.invalid_query`, with the offending path under `details.issues`." @@ -4756,7 +4750,7 @@ paths: schema: $ref: "#/components/schemas/ListWorkoutsResponse" "401": *a1 - "403": *a14 + "403": *a13 "422": *a4 "429": *a5 /api/workouts/{id}: @@ -4792,7 +4786,7 @@ paths: schema: $ref: "#/components/schemas/GetWorkoutResponse" "401": *a1 - "403": &a20 + "403": &a19 description: 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.' @@ -5015,7 +5009,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeAggregateEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": description: The query failed validation — a missing or unknown `scope`, or a `days` outside 1..365. Multi-issue envelope; an audit breadcrumb is filed under the RECORD as `medications.intake.list.validation-failed` with @@ -5140,7 +5134,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a13 + "429": *a12 /api/medications/{id}/glp1: get: tags: @@ -5165,7 +5159,7 @@ paths: schema: $ref: "#/components/schemas/Glp1DetailResponseEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": description: Medication not found (or owned by another account). content: @@ -5249,7 +5243,7 @@ paths: schema: $ref: "#/components/schemas/Glp1PostResponseEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": description: Medication not found (or owned by another account). content: @@ -5384,7 +5378,7 @@ paths: schema: $ref: "#/components/schemas/ReminderPhaseConfigEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": description: Medication not found (or owned by another account). content: @@ -5500,7 +5494,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationSideEffectsEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": description: Medication not found (or owned by another account). content: @@ -5539,7 +5533,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationSideEffectEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": description: Medication not found (or owned by another account). content: @@ -5584,7 +5578,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationSideEffectEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": description: No such entry for this medication and this record. An unknown id, another medication's entry and another record's entry are indistinguishable. @@ -5632,7 +5626,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationIntakeEventEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": description: No live event with that id under this medication and record. An unknown id and an already-tombstoned one are indistinguishable. @@ -5680,7 +5674,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationIntakeEventEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": description: No event with that id under this medication and record. content: @@ -5723,7 +5717,7 @@ paths: schema: $ref: "#/components/schemas/BulkDeleteMedicationIntakeEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": description: Medication not found (or owned by another account). content: @@ -5765,7 +5759,7 @@ paths: schema: $ref: "#/components/schemas/PurgeMedicationIntakeEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": description: Medication not found (or owned by another account). content: @@ -5810,7 +5804,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportAcceptedEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": description: Medication not found (or owned by another account). content: @@ -5890,7 +5884,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationsResponse" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 post: @@ -5922,7 +5916,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationResponse" "401": *a1 - "403": *a12 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -5942,7 +5936,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationCategoriesResponse" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 post: @@ -5969,7 +5963,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationCategoryResponse" "401": *a1 - "403": *a12 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -6196,7 +6190,7 @@ paths: schema: $ref: "#/components/schemas/MedicationListLayoutResponse" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 put: @@ -6230,7 +6224,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a16 + "422": &a15 description: Request validation failed. When the body carried a `baseUpdatedAt` that could not be parsed as an ISO-8601 timestamp — including an explicit `null` — `meta.errorCode` = `invalid_base_updated_at` and nothing was written. Omit the key entirely for the unconditional write; do not send `null` for it. @@ -6275,7 +6269,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationResponse" "401": *a1 - "403": *a12 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -6316,7 +6310,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationResponse" "401": *a1 - "403": *a12 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -6389,7 +6383,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationIntakeResponse" "401": *a1 - "403": *a12 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -6398,7 +6392,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 "422": *a4 - "429": *a13 + "429": *a12 get: tags: - Medications @@ -6463,7 +6457,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationIntakeEventsResponse" "401": *a1 - "403": *a12 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -6496,7 +6490,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationInventoryResponse" "401": *a1 - "403": *a12 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -6532,7 +6526,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationInventoryItemResponse" "401": *a1 - "403": *a12 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -6576,7 +6570,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationInventoryItemResponse" "401": *a1 - "403": *a12 + "403": *a11 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -6611,7 +6605,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationInventoryItemResponse" "401": *a1 - "403": *a12 + "403": *a11 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -6707,7 +6701,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationCadenceResponse" "401": *a1 - "403": *a12 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -6733,7 +6727,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationComplianceResponse" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/medications/{id}/compliance: @@ -6914,7 +6908,7 @@ paths: schema: $ref: "#/components/schemas/ListScheduleRevisionsResponse" "401": *a1 - "403": *a12 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -6952,7 +6946,7 @@ paths: schema: $ref: "#/components/schemas/CreateScheduleRevisionResponse" "401": *a1 - "403": *a12 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -6996,7 +6990,7 @@ paths: schema: $ref: "#/components/schemas/UpdateScheduleRevisionResponse" "401": *a1 - "403": *a12 + "403": *a11 "404": description: Medication or revision not found (or owned by another user). content: @@ -7037,7 +7031,7 @@ paths: schema: $ref: "#/components/schemas/DeleteScheduleRevisionResponse" "401": *a1 - "403": *a12 + "403": *a11 "404": description: Medication or revision not found (or owned by another user). content: @@ -7100,7 +7094,7 @@ paths: schema: $ref: "#/components/schemas/MedicationDoseHistoryImportQueuedEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "409": description: A dose-history import is already in progress. content: @@ -7162,7 +7156,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": description: No such job for this medication (or owned by another user). content: @@ -7196,7 +7190,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": description: No such job for this account (or owned by another user). content: @@ -7226,7 +7220,7 @@ paths: schema: $ref: "#/components/schemas/ProfileSummaryEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/ai/test: @@ -7431,7 +7425,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutCoachPrefsResponse" - "400": *a15 + "400": *a14 "401": *a1 "409": description: Coach preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read @@ -7441,7 +7435,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a16 + "422": *a15 "429": *a5 /api/auth/me/disable-coach: get: @@ -7479,7 +7473,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchDisableCoachResponse" - "400": *a15 + "400": *a14 "401": *a1 "422": *a4 "429": *a5 @@ -7634,7 +7628,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchModulesResponse" - "400": *a15 + "400": *a14 "401": *a1 "409": description: Module preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. @@ -7644,7 +7638,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a16 + "422": *a15 "429": *a5 /api/auth/me/source-priority: get: @@ -8595,7 +8589,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutSourcePriorityResponse" - "400": *a15 + "400": *a14 "401": *a1 "422": *a4 "429": *a5 @@ -8748,7 +8742,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a17 + "429": *a16 /api/auth/me/notification-prefs: get: tags: @@ -8789,7 +8783,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchNotificationPrefsResponse" - "400": *a15 + "400": *a14 "401": *a1 "409": description: Notification preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. @@ -8799,7 +8793,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a16 + "422": *a15 "429": *a5 /api/auth/me/report-selection: get: @@ -8841,7 +8835,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutReportSelectionResponseEnvelope" - "400": *a15 + "400": *a14 "401": *a1 "422": *a4 "429": *a5 @@ -9562,7 +9556,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "503": &a19 + "503": &a18 description: "The AI capability could not be resolved (its inputs could not be read) and the answer failed closed: `meta.errorCode` = `ai.unavailable`, `meta.reason` = `check_failed`. Retry later; no data depends on it." content: @@ -9970,7 +9964,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a16 + "422": *a15 "429": *a5 delete: tags: @@ -10145,7 +10139,7 @@ paths: schema: $ref: "#/components/schemas/CoachNudgeStatus" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/insights/coach/seen: @@ -10263,7 +10257,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a16 + "422": *a15 "429": *a5 /api/anamnesis/facts: get: @@ -10281,7 +10275,7 @@ paths: schema: $ref: "#/components/schemas/GetAnamnesisFactsResponse" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 post: @@ -10391,7 +10385,7 @@ paths: schema: $ref: "#/components/schemas/CorrectAnamnesisFactResponse" "401": *a1 - "404": &a18 + "404": &a17 description: No current revision with this id exists for the authenticated account. content: application/json: @@ -10427,7 +10421,7 @@ paths: schema: $ref: "#/components/schemas/RemoveAnamnesisFactResponse" "401": *a1 - "404": *a18 + "404": *a17 "409": description: Anamnesis fact changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -10787,7 +10781,7 @@ paths: schema: $ref: "#/components/schemas/CoachRemindersList" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 post: @@ -11079,7 +11073,13 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a9 + "501": &a32 + description: "Not implemented yet: the contract is published ahead of the implementation on a release branch. + `meta.errorCode: not_implemented`." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "503": description: No background worker is bound, so nothing would run it. content: @@ -11174,7 +11174,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSnapshotResponseEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/insights/comprehensive: @@ -11196,7 +11196,7 @@ paths: schema: $ref: "#/components/schemas/InsightsComprehensiveResponseEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/insights/cards: @@ -11217,7 +11217,7 @@ paths: schema: $ref: "#/components/schemas/InsightsCardsEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/insights/pregenerate: @@ -11352,7 +11352,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "503": *a19 + "503": *a18 post: tags: - Insights @@ -11554,7 +11554,7 @@ paths: schema: $ref: "#/components/schemas/CoachReadStripEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/insights/coach/seeded-question: @@ -11627,7 +11627,7 @@ paths: schema: $ref: "#/components/schemas/NarrativeEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/insights/glp1-timeline: @@ -11664,7 +11664,7 @@ paths: schema: $ref: "#/components/schemas/Glp1TimelineEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/insights/pulse/intraday: @@ -11700,7 +11700,7 @@ paths: schema: $ref: "#/components/schemas/IntradayPulseEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/insights/settings: @@ -11774,7 +11774,7 @@ paths: schema: $ref: "#/components/schemas/TargetsEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/analytics/range: @@ -11847,7 +11847,7 @@ paths: schema: $ref: "#/components/schemas/BloodPressureStatusResponseEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/insights/pulse-status: @@ -11879,7 +11879,7 @@ paths: schema: $ref: "#/components/schemas/PulseStatusResponseEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/insights/weight-status: @@ -11911,7 +11911,7 @@ paths: schema: $ref: "#/components/schemas/WeightStatusResponseEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/insights/bmi-status: @@ -11943,7 +11943,7 @@ paths: schema: $ref: "#/components/schemas/BmiStatusResponseEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/insights/mood-status: @@ -11975,7 +11975,7 @@ paths: schema: $ref: "#/components/schemas/MoodStatusResponseEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/insights/medication-compliance-status: @@ -12008,7 +12008,7 @@ paths: schema: $ref: "#/components/schemas/MedicationComplianceStatusResponseEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/insights/metric-status: @@ -12103,7 +12103,7 @@ paths: schema: $ref: "#/components/schemas/MetricStatusResponseEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/insights/biomarker-assessment: @@ -12148,7 +12148,7 @@ paths: schema: $ref: "#/components/schemas/BiomarkerAssessmentResponseEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/insights/derived: @@ -12260,7 +12260,7 @@ paths: schema: $ref: "#/components/schemas/DerivedMetricResponseEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/insights/derived/batch: @@ -12300,7 +12300,7 @@ paths: schema: $ref: "#/components/schemas/DerivedBatchResponseEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/insights/glp1-plateau: @@ -12320,7 +12320,7 @@ paths: schema: $ref: "#/components/schemas/InsightsGlp1PlateauResponseEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/insights/ecg: @@ -12342,7 +12342,7 @@ paths: schema: $ref: "#/components/schemas/EcgListResponseEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 post: @@ -12419,7 +12419,7 @@ paths: schema: $ref: "#/components/schemas/EcgDetailResponseEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": description: No ECG recording with that id for the authenticated user (existence sealed — a foreign id is indistinguishable from a missing one). @@ -12451,7 +12451,7 @@ paths: schema: $ref: "#/components/schemas/RhythmEventsResponseEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/insights/correlations: @@ -12473,7 +12473,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationDiscoveryResponseEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/insights/patterns: @@ -12491,7 +12491,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationPatternListResponseEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/insights/patterns/{id}: @@ -12521,7 +12521,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCorrelationPatternResponseEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": description: No current pattern with that id for this account. content: @@ -12546,7 +12546,7 @@ paths: schema: $ref: "#/components/schemas/InsightsHealthStatusEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/insights/breathing-screening: @@ -12565,7 +12565,7 @@ paths: schema: $ref: "#/components/schemas/InsightsBreathingScreeningEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/insights/labs-changes: @@ -12584,7 +12584,7 @@ paths: schema: $ref: "#/components/schemas/InsightsLabsChangesEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/mood/analytics: @@ -12607,7 +12607,7 @@ paths: schema: $ref: "#/components/schemas/MoodDailySeriesEnvelope" "401": *a1 - "403": *a20 + "403": *a19 "422": *a4 "429": *a5 /api/mood/insights: @@ -12629,7 +12629,7 @@ paths: schema: $ref: "#/components/schemas/MoodAggregatesEnvelope" "401": *a1 - "403": *a20 + "403": *a19 "422": *a4 "429": *a5 /api/mood/prognosis: @@ -12652,7 +12652,7 @@ paths: schema: $ref: "#/components/schemas/MoodPrognosisEnvelope" "401": *a1 - "403": *a14 + "403": *a13 "422": *a4 "429": *a5 /api/mood-entries/bulk: @@ -12762,7 +12762,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" "401": *a1 - "404": &a21 + "404": &a20 description: Not found / not owned by the caller. content: application/json: @@ -12797,7 +12797,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" "401": *a1 - "404": *a21 + "404": *a20 "422": *a4 "429": *a5 /api/mood/tags/{key}/hidden: @@ -12833,7 +12833,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "404": *a21 + "404": *a20 "422": *a4 "429": *a5 /api/mood/tags/groups: @@ -12886,7 +12886,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" "401": *a1 - "404": *a21 + "404": *a20 "422": *a4 "429": *a5 delete: @@ -12917,7 +12917,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" "401": *a1 - "404": *a21 + "404": *a20 "422": *a4 "429": *a5 /api/mood/tags/layout: @@ -12965,7 +12965,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a16 + "422": *a15 "429": *a5 /api/mood-entries: get: @@ -13048,7 +13048,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntryListEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 post: @@ -13081,8 +13081,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a12 - "409": &a22 + "403": *a11 + "409": &a21 description: A mood entry with the same `(date, moodLoggedAt)` instant already exists. `meta.errorCode` = `mood.duplicate_timestamp`. Only a request without `externalId` can answer this — with one, the write upserts in place. @@ -13091,7 +13091,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a13 + "429": *a12 /api/mood-entries/{id}: get: tags: @@ -13113,8 +13113,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a12 - "404": *a21 + "403": *a11 + "404": *a20 "422": *a4 "429": *a5 put: @@ -13148,9 +13148,9 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a12 - "404": *a21 - "409": *a22 + "403": *a11 + "404": *a20 + "409": *a21 "422": *a4 "429": *a5 delete: @@ -13175,8 +13175,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryDeleteEnvelope" "401": *a1 - "403": *a12 - "404": *a21 + "403": *a11 + "404": *a20 "422": *a4 "429": *a5 /api/mood-entries/restore: @@ -13205,7 +13205,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesRestoreEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -13234,7 +13234,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesBulkDeleteEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -13278,7 +13278,7 @@ paths: schema: $ref: "#/components/schemas/MoodLinkedContextEnvelope" "401": *a1 - "403": *a14 + "403": *a13 "422": *a4 "429": *a5 /api/settings/ntfy: @@ -13326,7 +13326,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutNtfySettingsResponse" - "400": &a23 + "400": &a22 description: Body is not parseable JSON. content: application/json: @@ -13339,13 +13339,13 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": &a24 + "413": &a23 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a25 + "415": &a24 description: Content-Type is not `application/json`. content: application/json: @@ -13405,7 +13405,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutTelegramSettingsResponse" - "400": *a23 + "400": *a22 "401": *a1 "403": description: The operator switched Telegram off for this instance and the body asked to enable it. No webhook was @@ -13420,8 +13420,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": *a24 - "415": *a25 + "413": *a23 + "415": *a24 "422": description: Body matched neither accepted shape; or `enabled` is true without both a bot token and a chat id; or the deployment's `APP_URL` is not a public HTTPS host on port 80/88/443/8443; or Telegram refused the webhook @@ -13475,10 +13475,10 @@ paths: application/json: schema: $ref: "#/components/schemas/PutWebhookSettingsResponse" - "400": *a23 + "400": *a22 "401": *a1 - "413": *a24 - "415": *a25 + "413": *a23 + "415": *a24 "422": description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = @@ -13505,7 +13505,7 @@ paths: schema: $ref: "#/components/schemas/GetReminderThresholdsResponse" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/settings/global-services: @@ -14035,7 +14035,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentLatestForKindEnvelope" - $ref: "#/components/schemas/ConsentLatestByKindEnvelope" - "400": &a26 + "400": &a25 description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`, `ai_extraction`. Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422." @@ -14095,7 +14095,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentRevokeForKindEnvelope" - $ref: "#/components/schemas/ConsentRevokeAllEnvelope" - "400": *a26 + "400": *a25 "401": *a1 "422": *a4 "429": *a5 @@ -14427,7 +14427,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderListEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 post: @@ -14452,7 +14452,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCreatedEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -14476,8 +14476,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 - "403": *a12 - "404": &a27 + "403": *a11 + "404": &a26 description: Measurement reminder not found / not owned. content: application/json: @@ -14514,8 +14514,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 - "403": *a12 - "404": *a27 + "403": *a11 + "404": *a26 "422": *a4 "429": *a5 delete: @@ -14537,8 +14537,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 - "403": *a12 - "404": *a27 + "403": *a11 + "404": *a26 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/satisfy: @@ -14562,8 +14562,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 - "403": *a12 - "404": *a27 + "403": *a11 + "404": *a26 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/complete: @@ -14590,8 +14590,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" "401": *a1 - "403": *a12 - "404": *a27 + "403": *a11 + "404": *a26 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/skip: @@ -14620,8 +14620,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSkipEnvelope" "401": *a1 - "403": *a12 - "404": *a27 + "403": *a11 + "404": *a26 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/snooze: @@ -14654,8 +14654,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSnoozeEnvelope" "401": *a1 - "403": *a12 - "404": *a27 + "403": *a11 + "404": *a26 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/history: @@ -14695,8 +14695,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderHistoryEnvelope" "401": *a1 - "403": *a12 - "404": *a27 + "403": *a11 + "404": *a26 "422": *a4 "429": *a5 /api/labs: @@ -14768,7 +14768,7 @@ paths: schema: $ref: "#/components/schemas/ListLabResultsEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 post: @@ -14795,7 +14795,7 @@ paths: schema: $ref: "#/components/schemas/CreateLabResultResponse" "401": *a1 - "403": *a12 + "403": *a11 "409": *a7 "422": description: Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue @@ -14807,7 +14807,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a13 + "429": *a12 /api/labs/{id}: get: tags: @@ -14828,8 +14828,8 @@ paths: schema: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 - "403": *a12 - "404": &a28 + "403": *a11 + "404": &a27 description: Lab result not found (or owned by another user). content: application/json: @@ -14863,8 +14863,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 - "403": *a12 - "404": *a28 + "403": *a11 + "404": *a27 "422": *a4 "429": *a5 delete: @@ -14886,8 +14886,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 - "403": *a12 - "404": *a28 + "403": *a11 + "404": *a27 "422": *a4 "429": *a5 /api/labs/restore: @@ -14913,7 +14913,7 @@ paths: schema: $ref: "#/components/schemas/RestoreLabResultsResponse" "401": *a1 - "403": *a12 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -15095,7 +15095,7 @@ paths: schema: $ref: "#/components/schemas/ListBiomarkersEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 post: @@ -15120,15 +15120,15 @@ paths: schema: $ref: "#/components/schemas/CreateBiomarkerResponse" "401": *a1 - "403": *a12 - "409": &a30 + "403": *a11 + "409": &a29 description: A biomarker with this name already exists for the caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a13 + "429": *a12 /api/biomarkers/{id}: get: tags: @@ -15149,8 +15149,8 @@ paths: schema: $ref: "#/components/schemas/GetBiomarkerResponse" "401": *a1 - "403": *a12 - "404": &a29 + "403": *a11 + "404": &a28 description: Biomarker not found (or owned by another user). content: application/json: @@ -15187,9 +15187,9 @@ paths: schema: $ref: "#/components/schemas/UpdateBiomarkerResponse" "401": *a1 - "403": *a12 - "404": *a29 - "409": *a30 + "403": *a11 + "404": *a28 + "409": *a29 "422": *a4 "429": *a5 delete: @@ -15212,7 +15212,7 @@ paths: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 - "404": *a29 + "404": *a28 "422": *a4 "429": *a5 /api/encounters: @@ -15270,7 +15270,7 @@ paths: schema: $ref: "#/components/schemas/ListEncountersEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 post: @@ -15296,10 +15296,10 @@ paths: schema: $ref: "#/components/schemas/CreateEncounterEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "409": *a7 "422": *a4 - "429": *a13 + "429": *a12 /api/encounters/procedures: get: tags: @@ -15330,7 +15330,7 @@ paths: schema: $ref: "#/components/schemas/ListProceduresEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/body-sites: @@ -15367,7 +15367,7 @@ paths: schema: $ref: "#/components/schemas/ListBodySitesEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/encounters/suggest: @@ -15397,7 +15397,7 @@ paths: schema: $ref: "#/components/schemas/SuggestEncounterEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/encounters/{id}: @@ -15420,8 +15420,8 @@ paths: schema: $ref: "#/components/schemas/GetEncounterEnvelope" "401": *a1 - "403": *a12 - "404": &a31 + "403": *a11 + "404": &a30 description: Visit not found (or owned by another user). content: application/json: @@ -15454,8 +15454,8 @@ paths: schema: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 - "403": *a12 - "404": *a31 + "403": *a11 + "404": *a30 "422": *a4 "429": *a5 delete: @@ -15479,8 +15479,8 @@ paths: schema: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 - "403": *a12 - "404": *a31 + "403": *a11 + "404": *a30 "422": *a4 "429": *a5 /api/encounters/{id}/restore: @@ -15505,8 +15505,8 @@ paths: schema: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 - "403": *a12 - "404": *a31 + "403": *a11 + "404": *a30 "422": *a4 "429": *a5 /api/encounters/{id}/links: @@ -15536,7 +15536,7 @@ paths: schema: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 - "404": *a31 + "404": *a30 "422": *a4 "429": *a5 delete: @@ -15565,7 +15565,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 - "404": *a31 + "404": *a30 "422": *a4 "429": *a5 /api/practitioners: @@ -15595,7 +15595,7 @@ paths: schema: $ref: "#/components/schemas/ListPractitionersEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 post: @@ -15619,7 +15619,7 @@ paths: schema: $ref: "#/components/schemas/CreatePractitionerEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -15643,8 +15643,8 @@ paths: schema: $ref: "#/components/schemas/GetPractitionerEnvelope" "401": *a1 - "403": *a12 - "404": &a32 + "403": *a11 + "404": &a31 description: Practitioner not found (or owned by another user). content: application/json: @@ -15677,8 +15677,8 @@ paths: schema: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 - "403": *a12 - "404": *a32 + "403": *a11 + "404": *a31 "422": *a4 "429": *a5 delete: @@ -15701,8 +15701,8 @@ paths: schema: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 - "403": *a12 - "404": *a32 + "403": *a11 + "404": *a31 "422": *a4 "429": *a5 /api/practitioners/{id}/restore: @@ -15727,8 +15727,8 @@ paths: schema: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 - "403": *a12 - "404": *a32 + "403": *a11 + "404": *a31 "422": *a4 "429": *a5 /api/vaccinations/custom: @@ -15748,7 +15748,7 @@ paths: "401": *a1 "422": *a4 "429": *a5 - "501": *a9 + "501": *a32 post: tags: - Records @@ -15776,8 +15776,8 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a13 - "501": *a9 + "429": *a12 + "501": *a32 /api/vaccinations/custom/{id}: patch: tags: @@ -15818,7 +15818,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a9 + "501": *a32 delete: tags: - Records @@ -15846,7 +15846,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a9 + "501": *a32 /api/vaccinations: get: tags: @@ -15887,7 +15887,7 @@ paths: schema: $ref: "#/components/schemas/ListVaccinationsEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 post: @@ -15914,10 +15914,10 @@ paths: schema: $ref: "#/components/schemas/CreateVaccinationEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "409": *a7 "422": *a4 - "429": *a13 + "429": *a12 /api/vaccinations/{id}: get: tags: @@ -15938,7 +15938,7 @@ paths: schema: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": &a33 description: Vaccination not found (or owned by another user). content: @@ -15973,7 +15973,7 @@ paths: schema: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": *a33 "422": *a4 "429": *a5 @@ -15998,7 +15998,7 @@ paths: schema: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": *a33 "422": *a4 "429": *a5 @@ -16023,7 +16023,7 @@ paths: schema: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": *a33 "422": *a4 "429": *a5 @@ -16118,7 +16118,7 @@ paths: schema: $ref: "#/components/schemas/VaccinationSuggestEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/vaccinations/{id}/links: @@ -16210,7 +16210,7 @@ paths: schema: $ref: "#/components/schemas/ListIllnessEpisodesEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 post: @@ -16236,7 +16236,7 @@ paths: schema: $ref: "#/components/schemas/CreateIllnessEpisodeEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -16260,7 +16260,7 @@ paths: schema: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": &a34 description: Illness episode not found (or owned by another user). content: @@ -16295,7 +16295,7 @@ paths: schema: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": *a34 "422": *a4 "429": *a5 @@ -16319,7 +16319,7 @@ paths: schema: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": *a34 "422": *a4 "429": *a5 @@ -16347,7 +16347,7 @@ paths: schema: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": *a34 "409": *a7 "422": *a4 @@ -16380,7 +16380,7 @@ paths: schema: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": *a34 "422": *a4 "429": *a5 @@ -16431,7 +16431,7 @@ paths: schema: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": *a34 "422": *a4 "429": *a5 @@ -16468,7 +16468,7 @@ paths: schema: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": *a34 "422": *a4 "429": *a5 @@ -16569,7 +16569,7 @@ paths: schema: $ref: "#/components/schemas/ListMentalHealthAssessmentsEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 post: @@ -16602,7 +16602,7 @@ paths: schema: $ref: "#/components/schemas/CreateMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -16629,7 +16629,7 @@ paths: schema: $ref: "#/components/schemas/GetMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/allergies: @@ -16662,7 +16662,7 @@ paths: schema: $ref: "#/components/schemas/ListAllergiesEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 post: @@ -16687,10 +16687,10 @@ paths: schema: $ref: "#/components/schemas/CreateAllergyEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "409": *a7 "422": *a4 - "429": *a13 + "429": *a12 /api/allergies/{id}: get: tags: @@ -16711,7 +16711,7 @@ paths: schema: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": &a35 description: Allergy not found (or owned by another user). content: @@ -16745,7 +16745,7 @@ paths: schema: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": *a35 "422": *a4 "429": *a5 @@ -16769,7 +16769,7 @@ paths: schema: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": *a35 "422": *a4 "429": *a5 @@ -16794,7 +16794,7 @@ paths: schema: $ref: "#/components/schemas/ListFamilyHistoryEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 post: @@ -16819,7 +16819,7 @@ paths: schema: $ref: "#/components/schemas/CreateFamilyHistoryEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -16843,7 +16843,7 @@ paths: schema: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": &a36 description: Family history entry not found (or owned by another user). content: @@ -16877,7 +16877,7 @@ paths: schema: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": *a36 "422": *a4 "429": *a5 @@ -16901,7 +16901,7 @@ paths: schema: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": *a36 "422": *a4 "429": *a5 @@ -17017,7 +17017,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentListEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 post: @@ -17300,7 +17300,7 @@ paths: schema: $ref: "#/components/schemas/DocumentUsageEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/documents/inbound/bulk: @@ -17347,7 +17347,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentDetailEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 patch: @@ -17466,7 +17466,7 @@ paths: type: string format: binary "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/documents/inbound/{id}/thumbnail: @@ -17495,7 +17495,7 @@ paths: type: string format: binary "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/documents/inbound/{id}/extract: @@ -17998,7 +17998,7 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricsEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 post: @@ -18051,7 +18051,7 @@ paths: schema: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 - "403": *a12 + "403": *a11 "404": &a41 description: Custom metric not found (or owned by another user). content: @@ -18153,7 +18153,7 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": *a41 "422": *a4 "429": *a5 @@ -18186,7 +18186,7 @@ paths: "404": *a41 "409": *a7 "422": *a4 - "429": *a13 + "429": *a12 /api/custom-metrics/{id}/entries/{entryId}: patch: tags: @@ -20314,7 +20314,7 @@ paths: schema: $ref: "#/components/schemas/DailyDigestEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/daily/digest/dismiss: @@ -20405,7 +20405,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSummaryEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/dashboard/widgets: @@ -20424,7 +20424,7 @@ paths: schema: $ref: "#/components/schemas/DashboardLayout" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 put: @@ -20458,7 +20458,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a16 + "422": *a15 "429": *a5 delete: tags: @@ -20477,15 +20477,45 @@ paths: "422": *a4 "429": *a5 /api/managed-profiles/{id}/handover: + get: + tags: + - Account sharing + summary: Whether a handover link is open + description: "The profile's open handover link, if any, and whether this instance allows creating one. Never returns the + link: only its hash is stored. Cookie-only, no step-up." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The status. + content: + application/json: + schema: + $ref: "#/components/schemas/ManagedProfileHandoverStatusEnvelope" + "401": *a1 + "404": + description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: + managed_profile.not_found`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 post: tags: - Account sharing summary: Create a handover link for a managed profile description: "Mints the one-time link that lets the person a managed profile describes take it over as their own - account. No data moves: claiming sets credentials on the profile's own record. Each guardian's access afterwards - follows the proposal recorded here unless the new owner changes it. Creating a new link withdraws an open one. - Pending MANAGE invitations on the profile are withdrawn when it is claimed. Cookie-only and step-up gated, like - every act on a managed profile. Refused on an instance that allows single sign-on only." + account. No data moves: claiming sets credentials on the profile's own record. Claiming applies each guardian's + proposal at once; the new owner then confirms or changes it on first sign-in, and until they do the proposal + holds. Creating a new link withdraws an open one. Pending invitations on the profile are withdrawn when it is + claimed. Cookie-only and step-up gated, like every act on a managed profile; ten an hour per caller. Refused on + an instance that allows single sign-on only." parameters: - in: path name: id @@ -20519,9 +20549,14 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a4 + "422": + description: "The body is invalid, or a proposal names a grant that is not an active guardian of this profile + (`meta.errorCode: managed_profile.handover.unknown_guardian`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "501": *a9 delete: tags: - Account sharing @@ -20551,7 +20586,58 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "501": *a9 + /api/account/handover-decision: + get: + tags: + - Account sharing + summary: The handover decision waiting for a new owner + description: "After claiming a managed profile, the new owner decides each former guardian's access. This answers that + decision, or `pending: null` when none is waiting. Refused while acting on another account." + responses: + "200": + description: The waiting decision, or null. + content: + application/json: + schema: + $ref: "#/components/schemas/PendingHandoverDecisionEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 + post: + tags: + - Account sharing + summary: Decide each former guardian's access + description: Records the new owner's final decision per former guardian. A changed level ends the guardian's grant and + writes a new one; `manage` can restore access a guardian held before the handover. After this, access changes + through the ordinary grant endpoints. Refused while acting on another account. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/HandoverDecisionRequest" + responses: + "200": + description: Decided. + content: + application/json: + schema: + $ref: "#/components/schemas/HandoverDecisionEnvelope" + "401": *a1 + "409": + description: "No decision is waiting (`meta.errorCode: managed_profile.handover.no_pending`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: "The body is invalid, or a decision names somebody who was not a guardian at the claim (`meta.errorCode: + managed_profile.handover.unknown_guardian`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": *a5 /api/auth/me: get: tags: @@ -21684,7 +21770,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/record-settings/integrations: @@ -21704,7 +21790,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsIntegrationsEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "422": *a4 "429": *a5 /api/record-settings/{family}: @@ -21739,7 +21825,7 @@ paths: schema: $ref: "#/components/schemas/GetRecordSettingsFamilyEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": description: Either the path named a family outside the closed set, or the record row itself is gone. The two are not distinguished, and the first is answered before the caller is authenticated at all. @@ -21780,7 +21866,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a12 + "403": *a11 "404": description: The path named a family outside the closed set. Answered before authentication. content: @@ -21893,7 +21979,7 @@ paths: schema: $ref: "#/components/schemas/AchievementsEnvelope" "401": *a1 - "403": *a14 + "403": *a13 "422": *a4 "429": *a5 /api/environment/preferences: @@ -21919,7 +22005,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentPreferencesEnvelope" "401": *a1 - "403": *a20 + "403": *a19 "422": *a4 "429": *a5 /api/environment: @@ -21937,7 +22023,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentOverviewEnvelope" "401": *a1 - "403": *a20 + "403": *a19 "422": *a4 "429": *a5 /api/environment/home: @@ -21974,7 +22060,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentHomeEnvelope" "401": *a1 - "403": *a20 + "403": *a19 "413": description: Body exceeds 4 KiB. content: @@ -22028,7 +22114,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentBackfillEnvelope" "401": *a1 - "403": *a20 + "403": *a19 "409": description: No home location is set, so there is nothing to resolve the days against. `meta.errorCode` = `environment.no_home`. @@ -22087,7 +22173,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentGeocodeEnvelope" "401": *a1 - "403": *a20 + "403": *a19 "422": description: "`q` was missing, empty after trimming, or longer than 120 characters. `meta.errorCode` = `environment.invalid`." @@ -22126,7 +22212,7 @@ paths: schema: $ref: "#/components/schemas/CreateEnvironmentTravelEnvelope" "401": *a1 - "403": *a20 + "403": *a19 "413": description: Body exceeds 4 KiB. content: @@ -22179,7 +22265,7 @@ paths: schema: $ref: "#/components/schemas/DeleteEnvironmentTravelEnvelope" "401": *a1 - "403": *a20 + "403": *a19 "404": description: No such override for this account. `meta.errorCode` = `environment.travel.not_found`. Also the answer for an id owned by somebody else — existence is not leaked. @@ -24000,47 +24086,42 @@ components: properties: token: type: string - description: The `hlp_` token from the handover link. Sent in the body, never in a URL. + minLength: 1 + maxLength: 80 required: - token additionalProperties: false + description: "`token` is the `hlp_` token from the handover link. Sent in the body, never in a URL." ProfileClaimRequest: type: object properties: token: type: string - description: The `hlp_` token from the handover link. Sent in the body, never in a URL. + minLength: 1 + maxLength: 80 username: type: string + minLength: 3 + maxLength: 30 + pattern: ^[a-zA-Z0-9_-]+$ email: type: string - description: "Required: the account needs a way back in." + maxLength: 254 + format: email + pattern: ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ password: type: string - guardians: - type: array - items: - type: object - properties: - grantId: - type: string - decision: - type: string - enum: - - end - - read - - manage - required: - - grantId - - decision - description: The new owner's decision per guardian. + minLength: 12 + maxLength: 1024 required: - token - username - email - password - - guardians additionalProperties: false + description: "`token` is the `hlp_` token from the handover link, in the body. `username` follows the registration rules + and may not start with `managed-`; `email` is required, because the account needs a way back in; `password` + follows the registration password policy." LoginPasswordRequest: type: object properties: @@ -32177,7 +32258,6 @@ components: properties: expiresInDays: default: 7 - description: How long the link stays valid. anyOf: - type: number const: 1 @@ -32186,30 +32266,58 @@ components: - type: number const: 14 proposals: + default: [] + maxItems: 50 type: array items: type: object properties: grantId: type: string - description: The guardian's MANAGE grant. + minLength: 1 + maxLength: 64 proposal: - $ref: "#/components/schemas/ManagedProfileHandoverProposal" + type: string + enum: + - end + - read + - manage required: - grantId - proposal - description: One proposal per active guardian. A guardian left out is proposed `read`. - required: - - proposals + additionalProperties: false additionalProperties: false - ManagedProfileHandoverProposal: - type: string - enum: - - end - - read - - manage - description: "What a guardian's access becomes after the handover: ended, view only, or still managing. The new owner - confirms or changes it when claiming." + description: "`expiresInDays` is 1, 7 (default) or 14. `proposals` names at most one entry per active guardian grant; a + guardian left out is proposed `read`, and a grant that is not an active guardian of this profile is refused with + 422." + HandoverDecisionRequest: + type: object + properties: + decisions: + maxItems: 50 + type: array + items: + type: object + properties: + grantId: + type: string + minLength: 1 + maxLength: 64 + access: + type: string + enum: + - end + - read + - manage + required: + - grantId + - access + additionalProperties: false + required: + - decisions + additionalProperties: false + description: The new owner's decision per former guardian, named by the guardian's grant id at the claim. A guardian + left out keeps the access the claim gave them. AccountGrantInvite: type: object properties: @@ -33573,12 +33681,13 @@ components: labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected, labs.update.qualitativeExpected, labs.update.referenceRangeInvalid, labs.update.sourceReferenceRangeInvalid; managed_profile — managed_profile.guardian.duplicate, - managed_profile.guardian.managed_invitee, managed_profile.guardian.required, managed_profile.not_found; - measurement — measurement.batch.invalid, measurement.batch.source_not_permitted, - measurement.batch.too_large, measurement.bulk-delete.invalid, measurement.create.invalid, - measurement.create.source_not_permitted, measurement.delete.too_large, measurement.duplicate_timestamp, - measurement.restore.invalid, measurement.update.server_owned_source; medication — - medication.intake.import.invalid_format; medications — medications.category.invalid, + managed_profile.guardian.managed_invitee, managed_profile.guardian.required, + managed_profile.handover.no_pending, managed_profile.handover.unknown_guardian, + managed_profile.not_found; measurement — measurement.batch.invalid, + measurement.batch.source_not_permitted, measurement.batch.too_large, measurement.bulk-delete.invalid, + measurement.create.invalid, measurement.create.source_not_permitted, measurement.delete.too_large, + measurement.duplicate_timestamp, measurement.restore.invalid, measurement.update.server_owned_source; + medication — medication.intake.import.invalid_format; medications — medications.category.invalid, medications.category.limitReached, medications.category.unknown, medications.course.currentOpen, medications.course.invalid, medications.course.invalidRange, medications.course.notFound, medications.course.oneShot, medications.course.overlap, medications.course.windowRequired, @@ -33599,6 +33708,7 @@ components: onboarding.tour.invalid; personal_records — personal_records.invalid_query; practitioner — practitioner.invalid; profile — profile.update.emailInUse, profile.update.emailRateLimited, profile.update.emailSecondFactorRequired, profile.update.invalidBody, profile.update.nothingSaved; + profile_claim — profile_claim.invalid, profile_claim.oidc_only_unsupported, profile_claim.taken; record_write — record_write.rate_limited; report-selection — report-selection.body.invalid_json, report-selection.body.invalid_shape, report-selection.leaves.unknown; route — route.retired; share-link — share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited, @@ -40952,7 +41062,10 @@ components: type: object properties: displayName: - type: string + anyOf: + - type: string + - type: "null" + description: The profile's name, as its guardians set it. expiresAt: type: string format: date-time @@ -40967,21 +41080,25 @@ components: displayName: type: string proposal: - type: string - enum: - - end - - read - - manage + $ref: "#/components/schemas/ProfileClaimGuardianAccess" required: - grantId - displayName - proposal additionalProperties: false + description: Each guardian and the access they keep after the claim, as proposed. The new owner confirms or changes it + after signing in. required: - displayName - expiresAt - guardians additionalProperties: false + ProfileClaimGuardianAccess: + type: string + enum: + - end + - read + - manage ProfileClaimEnvelope: type: object properties: @@ -76172,6 +76289,57 @@ components: - data - error additionalProperties: false + ManagedProfileHandoverStatusEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ManagedProfileHandoverStatus" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ManagedProfileHandoverStatus: + type: object + properties: + available: + type: boolean + description: False on an instance that allows single sign-on only, where a handover cannot be created. + open: + anyOf: + - type: object + properties: + id: + type: string + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + expiresAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + createdByYou: + type: boolean + required: + - id + - createdAt + - expiresAt + - createdByYou + additionalProperties: false + - type: "null" + description: "The profile's open link, or null. Never the link itself: only its hash is stored." + required: + - available + - open + additionalProperties: false ManagedProfileHandoverEnvelope: type: object properties: @@ -76233,6 +76401,105 @@ components: - data - error additionalProperties: false + PendingHandoverDecisionEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/PendingHandoverDecision" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + PendingHandoverDecision: + type: object + properties: + pending: + anyOf: + - type: object + properties: + claimedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + guardians: + type: array + items: + type: object + properties: + grantId: + type: string + description: The guardian's grant at the claim; what a decision names. + displayName: + type: string + proposal: + $ref: "#/components/schemas/ManagedProfileHandoverAccess" + current: + $ref: "#/components/schemas/ManagedProfileHandoverAccess" + decidable: + type: boolean + description: False when the access moved since the claim (the guardian stepped away, or it was changed under shared + access); such a row is shown and left alone. + required: + - grantId + - displayName + - proposal + - current + - decidable + additionalProperties: false + required: + - claimedAt + - guardians + additionalProperties: false + - type: "null" + required: + - pending + additionalProperties: false + ManagedProfileHandoverAccess: + type: string + enum: + - end + - read + - manage + description: "What a guardian's access becomes after the handover: ended (`end`), view only (`read`), or still managing + (`manage`)." + HandoverDecisionEnvelope: + type: object + properties: + data: + type: object + properties: + decided: + type: boolean + const: true + changed: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: How many guardians' access this changed. + required: + - decided + - changed + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false AccountPayloadEnvelope: type: object properties: diff --git a/e2e/v142-managed-profile-handover.spec.ts b/e2e/v142-managed-profile-handover.spec.ts new file mode 100644 index 000000000..fc722b35e --- /dev/null +++ b/e2e/v142-managed-profile-handover.spec.ts @@ -0,0 +1,258 @@ +/** + * v1.42 (#959) — a managed profile handed over to the person it describes, + * end to end in a browser: the Guardian mints the link, the person opens it in + * a browser that has never signed in, chooses a sign-in, lands on the decision + * about their former Guardian, and decides. + * + * The link is never mailed (by design: it is shown once and handed over in + * person or scanned). This journey "mails" it by reading it off the panel's + * `data-handover-url` and opening it in a second, cookie-less browser context, + * which is exactly the hop a person makes with a phone. + * + * Its own Guardian account, per project, seeded here: the shared + * `e2e-guardian` belongs to the managed-profile journey, which clears every + * profile that account looks after, and the two would delete each other's + * records mid-run. Every assertion addresses a stable `data-*` attribute or + * the database, never rendered copy. + */ +import AxeBuilder from "@axe-core/playwright"; +import { hash } from "@node-rs/argon2"; +import type { Page } from "@playwright/test"; +import pg from "pg"; + +import { expect, test } from "./setup/test"; + +const PASSWORD = "Handover!Journey-2042"; + +function connect(): pg.Pool { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("[handover-e2e] DATABASE_URL is not set"); + return new pg.Pool({ connectionString: url }); +} + +/** WCAG 2.1 AA on the screen as it stands. */ +async function expectNoAxeViolations(page: Page): Promise { + const result = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect(result.violations).toEqual([]); +} + +function names(project: string) { + const slug = project + .replace(/[^a-z0-9]/gi, "") + .toLowerCase() + .slice(0, 12); + return { + guardian: `e2e-hg-${slug}`, + claimant: `e2e-hc-${slug}`, + }; +} + +/** + * A Guardian with a password, signed in through the real login route, then + * given a second factor and a session that has just proved it — the state + * `requireFreshMfa` needs and the only state in which a handover can be made. + */ +async function seedGuardian( + pool: pg.Pool, + username: string, + claimant: string, +): Promise { + // Whatever a previous run left: the claimed account (no longer a managed + // profile, so it does not cascade with the guardian) and the guardian. + await pool.query(`DELETE FROM users WHERE username = $1 OR email = $2`, [ + claimant, + `${claimant}@healthlog.test`, + ]); + await pool.query( + `DELETE FROM users WHERE managed_profile_at IS NOT NULL AND id IN ( + SELECT g.grantor_id FROM account_grants g JOIN users u ON u.id = g.grantee_id + WHERE u.username = $1)`, + [username], + ); + await pool.query(`DELETE FROM users WHERE username = $1`, [username]); + const passwordHash = await hash(PASSWORD, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + }); + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed, + disclaimer_acknowledged_at, disclaimer_acknowledged_version) + VALUES ($1, $2, $3, $4, 'USER', NOW(), NOW(), NOW(), true, NOW(), '1')`, + [ + `c${Date.now().toString(36)}${username}`, + username, + `${username}@healthlog.test`, + passwordHash, + ], + ); + // The claim and login buckets for this address, so a local re-run inside the + // window meets the product rather than its rate limit. + await pool.query( + `DELETE FROM rate_limits WHERE key LIKE 'auth:claim%' OR key LIKE 'managed-profile:%'`, + ); +} + +async function stampSecondFactor(pool: pg.Pool, username: string) { + await pool.query( + `UPDATE users SET totp_confirmed_at = NOW() WHERE username = $1`, + [username], + ); + await pool.query( + `UPDATE sessions SET mfa_verified_at = NOW() + WHERE user_id = (SELECT id FROM users WHERE username = $1)`, + [username], + ); +} + +test.describe("managed profile handover", () => { + test("guardian hands over, the person claims and decides", async ({ + page, + browser, + baseURL, + }, testInfo) => { + test.setTimeout(120_000); + const { guardian, claimant } = names(testInfo.project.name); + const pool = connect(); + try { + await seedGuardian(pool, guardian, claimant); + + // ── The Guardian ───────────────────────────────────────────────── + const login = await page.request.post("/api/auth/login", { + data: { email: guardian, password: PASSWORD }, + }); + expect(login.status()).toBe(200); + await stampSecondFactor(pool, guardian); + + const created = await page.request.post("/api/managed-profiles", { + data: { + displayName: "Robin", + dateOfBirth: "2012-03-04", + locale: "en", + timezone: "Europe/Berlin", + gender: null, + }, + }); + expect(created.status()).toBe(201); + const profileId = ((await created.json()) as { data: { id: string } }) + .data.id; + + await page.goto("/settings/access"); + const row = page.locator( + `[data-slot="managed-profile-row"][data-managed-profile-id="${profileId}"]`, + ); + await expect(row).toBeVisible(); + await row + .locator('[data-slot="managed-profile-handover-toggle"]') + .click(); + + const form = row.locator('[data-slot="managed-profile-handover-form"]'); + await expect(form).toBeVisible(); + // Born 2012: the minor hint is shown, the choice stays the Guardian's. + await expect( + row.locator('[data-slot="managed-profile-handover-minor"]'), + ).toBeVisible(); + // The Guardian proposes to keep managing. + await form + .locator('[data-slot="managed-profile-handover-proposal"]') + .first() + .selectOption("manage"); + await form + .locator('[data-slot="managed-profile-handover-create"]') + .click(); + + const linkPanel = row.locator( + '[data-slot="managed-profile-handover-link"]', + ); + await expect(linkPanel).toBeVisible(); + await expect( + row.locator('[data-slot="managed-profile-handover-qr"]'), + ).toBeVisible(); + const url = await linkPanel.getAttribute("data-handover-url"); + expect(url).toMatch(/\/claim\/hlp_[0-9a-f]{64}$/); + await expectNoAxeViolations(page); + + // ── The person, on a browser that never signed in ──────────────── + const person = await browser.newContext({ baseURL }); + try { + const claimPage = await person.newPage(); + const path = new URL(url as string).pathname; + await claimPage.goto(path); + await expect(claimPage).toHaveURL(/\/auth\/claim\?token=hlp_/); + await expect( + claimPage.locator('[data-testid="claim-form-card"]'), + ).toBeVisible(); + await expect( + claimPage.locator( + '[data-slot="claim-guardian"][data-proposal="manage"]', + ), + ).toHaveCount(1); + + await expectNoAxeViolations(claimPage); + await claimPage.locator("#claim-username").fill(claimant); + await claimPage + .locator("#claim-email") + .fill(`${claimant}@healthlog.test`); + await claimPage.locator("#claim-password").fill(PASSWORD); + await claimPage.locator('[data-slot="claim-submit"]').click(); + + await expect(claimPage).toHaveURL(/\/onboarding\/handover$/, { + timeout: 30_000, + }); + const decision = claimPage.locator( + '[data-slot="handover-decision-guardian"][data-decidable="true"]', + ); + await expect(decision).toHaveCount(1); + await expect( + claimPage.locator('[data-slot="handover-notification-hint"]'), + ).toBeVisible(); + await expectNoAxeViolations(claimPage); + // The person decides: view only, not managing. + await decision + .locator('[data-slot="handover-decision-choice"]') + .selectOption("read"); + await claimPage + .locator('[data-slot="handover-decision-submit"]') + .click(); + await expect(claimPage).toHaveURL(/\/onboarding(\?|$)/, { + timeout: 30_000, + }); + + // The used link is dead for anyone after. + const reused = await person.request.post("/api/auth/claim/preview", { + data: { token: (url as string).split("/claim/")[1] }, + }); + // A signed-in browser is refused before the token is read; a fresh one + // would get the uniform 404. Either way, nothing is handed over twice. + expect([404, 409]).toContain(reused.status()); + } finally { + await person.close(); + } + + // ── What the database says ─────────────────────────────────────── + const account = await pool.query( + `SELECT managed_profile_at, username, email, password_hash IS NOT NULL AS has_password + FROM users WHERE id = $1`, + [profileId], + ); + expect(account.rows[0]).toMatchObject({ + managed_profile_at: null, + username: claimant, + has_password: true, + }); + const live = await pool.query( + `SELECT g.access FROM account_grants g JOIN users u ON u.id = g.grantee_id + WHERE g.grantor_id = $1 AND u.username = $2 AND g.revoked_at IS NULL`, + [profileId, guardian], + ); + expect(live.rows).toEqual([{ access: "READ" }]); + } finally { + await pool.end(); + } + }); +}); diff --git a/messages/de.json b/messages/de.json index 693b1062f..5dae1f00d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -515,17 +515,29 @@ "localhostNote": "Diese Seite läuft auf http://localhost, der Server setzt sein Anmelde-Cookie aber nur für https://. Die meisten Browser akzeptieren das auf localhost; wenn du nach der Anmeldung wieder hier landest, setze SESSION_COOKIE_SECURE=false." }, "claim": { - "title": "Dein Profil übernehmen", - "description": "{guardian} hat ein Gesundheitsprofil für dich geführt. Wähle deine Anmeldung, um es zu deinem eigenen Konto zu machen.", - "invalid": "Dieser Link ist ungültig, abgelaufen oder schon benutzt.", - "signOutFirst": "Melde dich zuerst ab, um dieses Profil zu übernehmen.", - "emailRequired": "Eine E-Mail-Adresse ist erforderlich.", - "guardiansTitle": "Wer Zugang behält", - "guardianProposal": "{name} schlägt vor: {proposal}", - "consentNote": "Den Hinweis und eine etwaige KI-Einwilligung bestätigst du nach der Übernahme selbst.", - "submit": "Profil übernehmen", - "submitting": "Wird übernommen …", - "success": "Das Profil ist jetzt dein Konto." + "title": "Übernimm deine Gesundheitsakte", + "titleNamed": "Willkommen, {name}", + "description": "Deine Gesundheitsakte wurde bisher für dich geführt. Wähle, wie du dich anmeldest, und sie wird dein eigenes Konto. Nichts wird kopiert oder verschoben.", + "loading": "Dein Link wird geprüft…", + "invalid": "Dieser Link funktioniert nicht mehr", + "invalidHint": "Er ist unbekannt, abgelaufen, zurückgezogen oder schon benutzt. Bitte die Person, die ihn dir gegeben hat, um einen neuen.", + "oidcOnly": "Dieser Server erlaubt nur Single Sign-on, deshalb lässt sich ein Profil hier noch nicht übernehmen.", + "signedInTitle": "Melde dich zuerst ab", + "signedInBody": "Du bist als {username} angemeldet. Dieser Link richtet eine Anmeldung für jemand anderen ein, melde dich also ab, bevor du ihn benutzt.", + "signOut": "Abmelden und weiter", + "guardiansTitle": "Wer vorerst Zugriff behält", + "guardiansHint": "Vorgeschlagen von den Personen, die deine Akte geführt haben. Du entscheidest direkt nach der Anmeldung.", + "usernameHint": "3 bis 30 Buchstaben, Ziffern, _ oder -.", + "usernameReserved": "Benutzernamen, die mit managed- beginnen, sind reserviert.", + "emailHint": "Pflicht, damit du wieder hineinkommst, wenn du dein Passwort vergisst.", + "consentNote": "Den Hinweis und jede KI-Nutzung bestätigst du selbst. Bis dahin geht nichts aus deiner Akte an einen KI-Anbieter.", + "expires": "Dieser Link gilt bis {date}.", + "submit": "Übernehmen", + "submitting": "Wird übernommen…", + "errorTaken": "Dieser Benutzername oder diese E-Mail-Adresse ist schon vergeben.", + "errorRateLimit": "Zu viele Versuche. Warte ein paar Minuten und versuche es erneut.", + "errorFailed": "Das hat nicht geklappt. Versuche es gleich noch einmal.", + "errorOffline": "Das hat den Server nicht erreicht. Prüfe deine Verbindung und versuche es erneut." } }, "dashboard": { @@ -7464,6 +7476,7 @@ "finalDelete": "Endgültig löschen", "deletedResult": "Gelöscht: {rows} Zeilen in {tables} Tabellen. Nutzerkonten und Anmelde-Zugangsdaten bleiben erhalten.", "authRegister": "Registrierung", + "authClaim": "Profil übernommen", "authLogin": "Anmeldung", "authLoginPasskey": "Passkey-Anmeldung", "authLoginPassword": "Passwort-Anmeldung", @@ -8387,7 +8400,17 @@ "loginRequired": "Bitte melde dich an, um Benachrichtigungen zu verwalten.", "someChannelsGloballyDisabled": "Ein oder mehrere Kanäle sind in den Einstellungen deaktiviert und erhalten keine Benachrichtigungen.", "reminderSettingsLink": "Erinnerungseinstellungen", - "channelsLink": "Kanäle" + "channelsLink": "Kanäle", + "handover": { + "claimedTitle": "{name} hat jetzt ein eigenes Konto", + "claimedEnd": "{name} hat die eigene Gesundheitsakte übernommen. Dein Zugriff ist beendet.", + "claimedRead": "{name} hat die eigene Gesundheitsakte übernommen. Du kannst sie weiter ansehen, aber nicht mehr ändern.", + "claimedManage": "{name} hat die eigene Gesundheitsakte übernommen. Du kannst sie weiter verwalten, bis {name} anders entscheidet.", + "changedTitle": "{name} hat deinen Zugriff geändert", + "changedEnd": "Dein Zugriff auf die Gesundheitsakte von {name} ist beendet.", + "changedRead": "Du kannst die Gesundheitsakte von {name} jetzt ansehen, aber nicht ändern.", + "changedManage": "Du kannst die Gesundheitsakte von {name} verwalten." + } }, "i18n": { "maintainershipBanner": { @@ -11606,29 +11629,44 @@ "rosterLoadError": "Es ließ sich nicht laden, wer sich um diese Akte kümmert.", "errorRateLimit": "Zu viele Profile in der letzten Stunde angelegt. Warte etwas und versuche es erneut.", "handover": { - "title": "Profil übergeben", - "description": "Gib {name} eine eigene Anmeldung. Die Daten bleiben, wo sie sind; du schlägst vor, welchen Zugang jede betreuende Person behält, und {name} entscheidet.", - "create": "Übergabelink erstellen", + "action": "Übergeben", + "title": "An {name} übergeben", + "description": "Gib {name} eine eigene Anmeldung. Nichts wird kopiert oder verschoben: Die Akte wird zum eigenen Konto.", + "guardiansLabel": "Zugriff nach der Übergabe", + "guardiansHint": "Dein Vorschlag. {name} sieht ihn und entscheidet nach der Anmeldung.", + "access": { + "end": "Kein Zugriff", + "read": "Nur ansehen", + "manage": "Verwalten" + }, "expiryLabel": "Link gültig für", "expiry1": "1 Tag", "expiry7": "7 Tage", "expiry14": "14 Tage", - "proposalLabel": "Vorgeschlagener Zugang danach", - "proposal": { - "end": "Zugang beenden", - "read": "Nur ansehen", - "manage": "Weiter verwalten" - }, - "linkTitle": "Übergabelink", - "linkOnce": "Dieser Link wird nur einmal angezeigt. Gib ihn {name} direkt oder lass den Code scannen.", - "copy": "Link kopieren", + "minorHint": "{name} ist unter 16. Es lohnt sich, den Lesezugriff zu behalten und den Schritt gemeinsam zu besprechen.", + "irreversible": "Sobald {name} den Link benutzt, lässt sich die Übergabe nicht rückgängig machen. Die Akte gehört dann {name}, und {name} entscheidet, wer Zugriff behält.", + "afterwards": "{name} bestätigt den Hinweis und jede KI-Einwilligung selbst. Erinnerungen für diese Akte erreichen dich nicht mehr.", + "replaceNote": "Ein neuer Link zieht den offenen zurück.", + "create": "Link erstellen", + "creating": "Wird erstellt…", + "linkOnce": "Dieser Link wird nur jetzt angezeigt. Gib ihn {name} persönlich oder über einen Weg, dem du vertraust, oder lass den Code scannen.", + "qrAlt": "QR-Code des Übergabelinks für {name}", + "urlLabel": "Übergabelink", + "copy": "Kopieren", "copied": "Link kopiert", - "pending": "Übergabelink offen bis {date}", + "copyError": "Der Link ließ sich nicht kopieren. Markiere ihn und kopiere ihn von Hand.", + "linkExpires": "Gültig bis {date}.", + "done": "Fertig", + "pending": "Ein Übergabelink ist bis {date} offen.", + "replace": "Neuer Link", "revoke": "Link zurückziehen", "revoked": "Link zurückgezogen", - "errorFailed": "Der Übergabelink konnte nicht erstellt werden.", - "oidcOnly": "Die Profilübergabe ist nicht verfügbar, solange dieser Server nur Single Sign-on erlaubt.", - "minorHint": "{name} ist jünger als 16. Den Lesezugang zu behalten, ist eine Überlegung wert." + "revokeFailed": "Der Link ließ sich nicht zurückziehen. Versuche es erneut.", + "errorFailed": "Der Übergabelink ließ sich nicht erstellen.", + "errorRosterChanged": "Inzwischen hat sich geändert, wer die Akte betreut. Schließe das hier und öffne es erneut.", + "oidcOnly": "Ein Profil zu übergeben ist nicht möglich, solange dieser Server nur Single Sign-on erlaubt.", + "loadError": "Der Stand der Übergabe ließ sich nicht laden.", + "entryHint": "Wenn {name} die Akte selbst führen möchte." } }, "toast": { @@ -11804,6 +11842,22 @@ }, "anamnesis": { "relativeNote": "Ein Eintrag zur Familienanamnese beschreibt die Angehörigen dieser Person. Trage ihn nur ein, wenn sie das hier festgehalten haben möchte." + }, + "handoverDecision": { + "title": "Wer Zugriff auf deine Akte behält", + "description": "Entscheide, was die Personen, die deine Akte geführt haben, ab jetzt dürfen.", + "proposed": "Vorgeschlagen: {access}", + "settled": "Bereits unter Geteilter Zugriff geändert", + "empty": "Niemand sonst hatte Zugriff auf deine Akte.", + "later": "Du kannst das jederzeit unter Einstellungen, Geteilter Zugriff ändern.", + "save": "Bestätigen", + "saving": "Wird gespeichert…", + "decideLater": "Später", + "errorFailed": "Deine Entscheidung ließ sich nicht speichern. Versuche es erneut.", + "loadError": "Wer deine Akte bisher betreut hat, ließ sich nicht laden.", + "notificationsTitle": "Richte deine eigenen Erinnerungen ein", + "notificationsBody": "Erinnerungen für diese Akte gingen bisher an die Personen, die sie geführt haben. Ab jetzt erreichen sie dich, sobald du einen Benachrichtigungskanal einrichtest.", + "notificationsAction": "Benachrichtigungen öffnen" } }, "encounters": { diff --git a/messages/en.json b/messages/en.json index a13020340..18a19ecce 100644 --- a/messages/en.json +++ b/messages/en.json @@ -515,17 +515,29 @@ "localhostNote": "This page is on http://localhost while the server sets its sign-in cookie for https:// only. Most browsers accept that on localhost; if signing in keeps returning here, set SESSION_COOKIE_SECURE=false." }, "claim": { - "title": "Take over your profile", - "description": "{guardian} has kept a health record for you. Choose your sign-in to make it your own account.", - "invalid": "This link is invalid, expired or already used.", - "signOutFirst": "Sign out first to take over this profile.", - "emailRequired": "An email address is required.", - "guardiansTitle": "Who keeps access", - "guardianProposal": "{name} proposes: {proposal}", - "consentNote": "You will confirm the disclaimer and any AI consent yourself after taking over.", - "submit": "Take over profile", + "title": "Take over your health record", + "titleNamed": "Welcome, {name}", + "description": "Your health record has been looked after for you until now. Choose how you sign in and it becomes your own account. Nothing is copied or moved.", + "loading": "Checking your link…", + "invalid": "This link no longer works", + "invalidHint": "It is unknown, expired, withdrawn or already used. Ask the person who sent it for a new one.", + "oidcOnly": "This server only allows single sign-on, so a profile cannot be taken over here yet.", + "signedInTitle": "Sign out first", + "signedInBody": "You are signed in as {username}. This link sets up a sign-in for someone else, so sign out before you use it.", + "signOut": "Sign out and continue", + "guardiansTitle": "Who keeps access for now", + "guardiansHint": "Proposed by the people who looked after your record. You decide right after signing in.", + "usernameHint": "3 to 30 letters, numbers, _ or -.", + "usernameReserved": "Usernames starting with managed- are reserved.", + "emailHint": "Required, so you can get back in if you forget your password.", + "consentNote": "You confirm the disclaimer and any use of AI yourself. Until you do, nothing from your record is sent to an AI provider.", + "expires": "This link is valid until {date}.", + "submit": "Take over", "submitting": "Taking over…", - "success": "The profile is now your account." + "errorTaken": "That username or email address is already in use.", + "errorRateLimit": "Too many attempts. Wait a few minutes and try again.", + "errorFailed": "That did not work. Try again in a moment.", + "errorOffline": "That did not reach the server. Check your connection and try again." } }, "dashboard": { @@ -7464,6 +7476,7 @@ "finalDelete": "Delete permanently", "deletedResult": "Deleted {rows} rows across {tables} tables. User accounts and sign-in credentials were kept.", "authRegister": "Registration", + "authClaim": "Profile taken over", "authLogin": "Login", "authLoginPasskey": "Passkey login", "authLoginPassword": "Password login", @@ -8387,7 +8400,17 @@ "loginRequired": "Please sign in to manage notifications.", "someChannelsGloballyDisabled": "One or more channels are turned off in Settings and won’t receive notifications.", "reminderSettingsLink": "Reminder settings", - "channelsLink": "Channels" + "channelsLink": "Channels", + "handover": { + "claimedTitle": "{name} now has their own account", + "claimedEnd": "{name} took over their health record. Your access has ended.", + "claimedRead": "{name} took over their health record. You can still view it, but no longer change it.", + "claimedManage": "{name} took over their health record. You can still manage it until they decide otherwise.", + "changedTitle": "{name} changed your access", + "changedEnd": "Your access to the health record of {name} has ended.", + "changedRead": "You can now view the health record of {name}, but not change it.", + "changedManage": "You can manage the health record of {name}." + } }, "i18n": { "maintainershipBanner": { @@ -11606,29 +11629,44 @@ "rosterLoadError": "Could not load who looks after this record.", "errorRateLimit": "Too many profiles created in the last hour. Wait a while and try again.", "handover": { - "title": "Hand over this profile", - "description": "Give {name} a sign-in of their own. The data stays where it is; you propose what access each guardian keeps, and {name} decides.", - "create": "Create handover link", + "action": "Hand over", + "title": "Hand over to {name}", + "description": "Give {name} a sign-in of their own. Nothing is copied or moved: the record becomes their account.", + "guardiansLabel": "Access after the handover", + "guardiansHint": "Your proposal. {name} sees it and decides after signing in.", + "access": { + "end": "No access", + "read": "View only", + "manage": "Manage" + }, "expiryLabel": "Link valid for", "expiry1": "1 day", "expiry7": "7 days", "expiry14": "14 days", - "proposalLabel": "Proposed access afterwards", - "proposal": { - "end": "End access", - "read": "View only", - "manage": "Keep managing" - }, - "linkTitle": "Handover link", - "linkOnce": "This link is shown only once. Hand it to {name} directly or let them scan the code.", - "copy": "Copy link", + "minorHint": "{name} is under 16. Keeping view access is worth considering, and so is talking the step through together.", + "irreversible": "Once {name} uses the link, the handover cannot be undone. The record is theirs, and they decide who keeps access.", + "afterwards": "{name} confirms the disclaimer and any AI consent personally. Reminders for this record stop reaching you.", + "replaceNote": "Creating a new link withdraws the open one.", + "create": "Create link", + "creating": "Creating…", + "linkOnce": "This link is shown only now. Give it to {name} in person or through a channel you trust, or let them scan the code.", + "qrAlt": "QR code of the handover link for {name}", + "urlLabel": "Handover link", + "copy": "Copy", "copied": "Link copied", - "pending": "Handover link open until {date}", + "copyError": "The link could not be copied. Select it and copy it by hand.", + "linkExpires": "Valid until {date}.", + "done": "Done", + "pending": "A handover link is open until {date}.", + "replace": "New link", "revoke": "Withdraw link", "revoked": "Link withdrawn", + "revokeFailed": "The link could not be withdrawn. Try again.", "errorFailed": "The handover link could not be created.", + "errorRosterChanged": "The list of guardians changed in the meantime. Close this and open it again.", "oidcOnly": "Handing over a profile is not available while this server allows single sign-on only.", - "minorHint": "{name} is under 16. Keeping view access is worth considering." + "loadError": "The handover status could not be loaded.", + "entryHint": "When {name} is ready to look after this record themselves." } }, "toast": { @@ -11804,6 +11842,22 @@ }, "anamnesis": { "relativeNote": "A family history entry describes this person's relatives. Add it only when they would want it recorded here." + }, + "handoverDecision": { + "title": "Who keeps access to your record", + "description": "Decide what the people who looked after your record may do from now on.", + "proposed": "Proposed: {access}", + "settled": "Already changed under Shared access", + "empty": "Nobody else had access to your record.", + "later": "You can change this any time under Settings, Shared access.", + "save": "Confirm", + "saving": "Saving…", + "decideLater": "Later", + "errorFailed": "Your decision could not be saved. Try again.", + "loadError": "Your former guardians could not be loaded.", + "notificationsTitle": "Set up your own reminders", + "notificationsBody": "Reminders for this record used to reach the people looking after it. From now on they reach you, as soon as you add a notification channel.", + "notificationsAction": "Open notification settings" } }, "encounters": { diff --git a/messages/es.json b/messages/es.json index 99b380ed8..d62c428d6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -515,17 +515,29 @@ "localhostNote": "Esta página está en http://localhost y el servidor solo establece su cookie de sesión para https://. La mayoría de los navegadores lo aceptan en localhost; si vuelves aquí tras iniciar sesión, pon SESSION_COOKIE_SECURE=false." }, "claim": { - "title": "Hazte cargo de tu perfil", - "description": "{guardian} ha llevado un registro de salud para ti. Elige tu acceso para convertirlo en tu propia cuenta.", - "invalid": "Este enlace no es válido, ha caducado o ya se ha usado.", - "signOutFirst": "Cierra sesión primero para hacerte cargo de este perfil.", - "emailRequired": "Se necesita una dirección de correo.", - "guardiansTitle": "Quién conserva el acceso", - "guardianProposal": "{name} propone: {proposal}", - "consentNote": "Después de hacerte cargo, confirmarás tú el aviso y cualquier consentimiento de IA.", - "submit": "Hacerme cargo del perfil", - "submitting": "Procesando…", - "success": "El perfil ya es tu cuenta." + "title": "Asume tu historial de salud", + "titleNamed": "Te damos la bienvenida, {name}", + "description": "Hasta ahora otras personas han llevado tu historial de salud por ti. Elige cómo iniciar sesión y pasará a ser tu propia cuenta. No se copia ni se mueve nada.", + "loading": "Comprobando tu enlace…", + "invalid": "Este enlace ya no funciona", + "invalidHint": "Es desconocido, ha caducado, se ha retirado o ya se ha usado. Pide uno nuevo a quien te lo envió.", + "oidcOnly": "Este servidor solo permite el inicio de sesión único, así que aquí todavía no se puede asumir un perfil.", + "signedInTitle": "Cierra la sesión primero", + "signedInBody": "Has iniciado sesión como {username}. Este enlace crea un acceso para otra persona, así que cierra la sesión antes de usarlo.", + "signOut": "Cerrar sesión y continuar", + "guardiansTitle": "Quién conserva el acceso por ahora", + "guardiansHint": "Lo proponen las personas que llevaron tu historial. Tú decides justo después de iniciar sesión.", + "usernameHint": "De 3 a 30 letras, números, _ o -.", + "usernameReserved": "Los nombres de usuario que empiezan por managed- están reservados.", + "emailHint": "Obligatorio, para que puedas volver a entrar si olvidas tu contraseña.", + "consentNote": "Confirmas tú mismo el aviso y cualquier uso de IA. Hasta entonces, nada de tu historial se envía a un proveedor de IA.", + "expires": "Este enlace es válido hasta el {date}.", + "submit": "Asumir", + "submitting": "Asumiendo…", + "errorTaken": "Ese nombre de usuario o correo electrónico ya está en uso.", + "errorRateLimit": "Demasiados intentos. Espera unos minutos y vuelve a intentarlo.", + "errorFailed": "No ha funcionado. Vuelve a intentarlo en un momento.", + "errorOffline": "No se llegó al servidor. Comprueba tu conexión y vuelve a intentarlo." } }, "dashboard": { @@ -7464,6 +7476,7 @@ "finalDelete": "Eliminar permanentemente", "deletedResult": "Eliminadas {rows} filas en {tables} tablas. Se conservaron las cuentas de usuario y las credenciales de inicio de sesión.", "authRegister": "Registro", + "authClaim": "Perfil asumido", "authLogin": "Iniciar sesión", "authLoginPasskey": "Inicio de sesión con passkey", "authLoginPassword": "Inicio de sesión con contraseña", @@ -8387,7 +8400,17 @@ "loginRequired": "Inicia sesión para gestionar las notificaciones.", "someChannelsGloballyDisabled": "Uno o más canales están desactivados en los ajustes y no recibirán notificaciones.", "reminderSettingsLink": "Ajustes de recordatorios", - "channelsLink": "Canales" + "channelsLink": "Canales", + "handover": { + "claimedTitle": "{name} ya tiene su propia cuenta", + "claimedEnd": "{name} ha asumido su historial de salud. Tu acceso ha terminado.", + "claimedRead": "{name} ha asumido su historial de salud. Puedes seguir viéndolo, pero ya no cambiarlo.", + "claimedManage": "{name} ha asumido su historial de salud. Puedes seguir gestionándolo hasta que decida otra cosa.", + "changedTitle": "{name} ha cambiado tu acceso", + "changedEnd": "Tu acceso al historial de salud de {name} ha terminado.", + "changedRead": "Ahora puedes ver el historial de salud de {name}, pero no cambiarlo.", + "changedManage": "Puedes gestionar el historial de salud de {name}." + } }, "i18n": { "maintainershipBanner": { @@ -11606,29 +11629,44 @@ "rosterLoadError": "No se pudo cargar quién se ocupa de este historial.", "errorRateLimit": "Demasiados perfiles creados en la última hora. Espera un poco y vuelve a intentarlo.", "handover": { - "title": "Entregar este perfil", - "description": "Dale a {name} su propio acceso. Los datos se quedan donde están; tú propones qué acceso conserva cada persona cuidadora y {name} decide.", - "create": "Crear enlace de entrega", + "action": "Entregar", + "title": "Entregar a {name}", + "description": "Da a {name} un acceso propio. No se copia ni se mueve nada: el historial pasa a ser su cuenta.", + "guardiansLabel": "Acceso después de la entrega", + "guardiansHint": "Tu propuesta. {name} la ve y decide después de iniciar sesión.", + "access": { + "end": "Sin acceso", + "read": "Solo ver", + "manage": "Gestionar" + }, "expiryLabel": "Enlace válido durante", "expiry1": "1 día", "expiry7": "7 días", "expiry14": "14 días", - "proposalLabel": "Acceso propuesto después", - "proposal": { - "end": "Terminar el acceso", - "read": "Solo ver", - "manage": "Seguir gestionando" - }, - "linkTitle": "Enlace de entrega", - "linkOnce": "Este enlace solo se muestra una vez. Dáselo a {name} directamente o deja que escanee el código.", - "copy": "Copiar enlace", + "minorHint": "{name} tiene menos de 16 años. Vale la pena conservar el acceso de lectura y hablar el paso juntos.", + "irreversible": "En cuanto {name} use el enlace, la entrega no se puede deshacer. El historial será suyo y decidirá quién conserva el acceso.", + "afterwards": "{name} confirma personalmente el aviso y cualquier consentimiento de IA. Los recordatorios de este historial dejarán de llegarte.", + "replaceNote": "Crear un enlace nuevo retira el que está abierto.", + "create": "Crear enlace", + "creating": "Creando…", + "linkOnce": "Este enlace solo se muestra ahora. Dáselo a {name} en persona o por un canal de confianza, o deja que escanee el código.", + "qrAlt": "Código QR del enlace de entrega para {name}", + "urlLabel": "Enlace de entrega", + "copy": "Copiar", "copied": "Enlace copiado", - "pending": "Enlace de entrega abierto hasta el {date}", + "copyError": "No se pudo copiar el enlace. Selecciónalo y cópialo a mano.", + "linkExpires": "Válido hasta el {date}.", + "done": "Listo", + "pending": "Hay un enlace de entrega abierto hasta el {date}.", + "replace": "Nuevo enlace", "revoke": "Retirar enlace", "revoked": "Enlace retirado", + "revokeFailed": "No se pudo retirar el enlace. Vuelve a intentarlo.", "errorFailed": "No se pudo crear el enlace de entrega.", - "oidcOnly": "La entrega de perfiles no está disponible mientras este servidor solo permita el inicio de sesión único.", - "minorHint": "{name} tiene menos de 16 años. Conviene plantearse mantener el acceso de lectura." + "errorRosterChanged": "Mientras tanto ha cambiado quién se ocupa del historial. Cierra esto y vuelve a abrirlo.", + "oidcOnly": "No se puede entregar un perfil mientras este servidor solo permita el inicio de sesión único.", + "loadError": "No se pudo cargar el estado de la entrega.", + "entryHint": "Cuando {name} quiera llevar este historial por su cuenta." } }, "toast": { @@ -11804,6 +11842,22 @@ }, "anamnesis": { "relativeNote": "Una entrada de antecedentes familiares describe a los familiares de esta persona. Añádela solo si querría que quedara registrada aquí." + }, + "handoverDecision": { + "title": "Quién conserva el acceso a tu historial", + "description": "Decide qué pueden hacer a partir de ahora las personas que llevaron tu historial.", + "proposed": "Propuesto: {access}", + "settled": "Ya cambiado en Acceso compartido", + "empty": "Nadie más tenía acceso a tu historial.", + "later": "Puedes cambiarlo cuando quieras en Ajustes, Acceso compartido.", + "save": "Confirmar", + "saving": "Guardando…", + "decideLater": "Más tarde", + "errorFailed": "No se pudo guardar tu decisión. Vuelve a intentarlo.", + "loadError": "No se pudo cargar quién llevaba tu historial.", + "notificationsTitle": "Configura tus propios recordatorios", + "notificationsBody": "Hasta ahora, los recordatorios de este historial llegaban a las personas que lo llevaban. A partir de ahora te llegan a ti, en cuanto añadas un canal de notificaciones.", + "notificationsAction": "Abrir notificaciones" } }, "encounters": { diff --git a/messages/fr.json b/messages/fr.json index 21d5bc238..0e919cfcd 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -515,17 +515,29 @@ "localhostNote": "Cette page est sur http://localhost alors que le serveur ne pose son cookie de connexion qu'en https://. La plupart des navigateurs l'acceptent sur localhost ; si tu reviens ici après la connexion, mets SESSION_COOKIE_SECURE=false." }, "claim": { - "title": "Reprendre ton profil", - "description": "{guardian} a tenu un dossier de santé pour toi. Choisis ton accès pour en faire ton propre compte.", - "invalid": "Ce lien est invalide, expiré ou déjà utilisé.", - "signOutFirst": "Déconnecte-toi d’abord pour reprendre ce profil.", - "emailRequired": "Une adresse e-mail est requise.", - "guardiansTitle": "Qui garde l’accès", - "guardianProposal": "{name} propose : {proposal}", - "consentNote": "Après la reprise, tu confirmeras toi-même l’avertissement et tout consentement IA.", - "submit": "Reprendre le profil", + "title": "Reprenez votre dossier de santé", + "titleNamed": "Bienvenue, {name}", + "description": "Jusqu'ici, votre dossier de santé a été tenu pour vous. Choisissez comment vous connecter et il deviendra votre propre compte. Rien n'est copié ni déplacé.", + "loading": "Vérification de votre lien…", + "invalid": "Ce lien ne fonctionne plus", + "invalidHint": "Il est inconnu, expiré, retiré ou déjà utilisé. Demandez-en un nouveau à la personne qui vous l'a envoyé.", + "oidcOnly": "Ce serveur n'autorise que l'authentification unique, un profil ne peut donc pas encore être repris ici.", + "signedInTitle": "Déconnectez-vous d'abord", + "signedInBody": "Vous êtes connecté en tant que {username}. Ce lien crée une connexion pour quelqu'un d'autre, déconnectez-vous donc avant de l'utiliser.", + "signOut": "Se déconnecter et continuer", + "guardiansTitle": "Qui garde l'accès pour l'instant", + "guardiansHint": "Proposé par les personnes qui ont tenu votre dossier. Vous décidez juste après la connexion.", + "usernameHint": "3 à 30 lettres, chiffres, _ ou -.", + "usernameReserved": "Les noms d'utilisateur commençant par managed- sont réservés.", + "emailHint": "Obligatoire, pour pouvoir revenir si vous oubliez votre mot de passe.", + "consentNote": "Vous confirmez vous-même l'avertissement et toute utilisation de l'IA. D'ici là, rien de votre dossier n'est envoyé à un fournisseur d'IA.", + "expires": "Ce lien est valable jusqu'au {date}.", + "submit": "Reprendre", "submitting": "Reprise en cours…", - "success": "Le profil est maintenant ton compte." + "errorTaken": "Ce nom d'utilisateur ou cette adresse e-mail est déjà utilisé.", + "errorRateLimit": "Trop de tentatives. Attendez quelques minutes puis réessayez.", + "errorFailed": "Cela n'a pas fonctionné. Réessayez dans un instant.", + "errorOffline": "Le serveur n'a pas été atteint. Vérifiez votre connexion et réessayez." } }, "dashboard": { @@ -7464,6 +7476,7 @@ "finalDelete": "Supprimer définitivement", "deletedResult": "{rows} lignes supprimées dans {tables} tables. Les comptes utilisateurs et les identifiants de connexion ont été conservés.", "authRegister": "Inscription", + "authClaim": "Profil repris", "authLogin": "Connexion", "authLoginPasskey": "Connexion par passkey", "authLoginPassword": "Connexion par mot de passe", @@ -8387,7 +8400,17 @@ "loginRequired": "Connecte-toi pour gérer les notifications.", "someChannelsGloballyDisabled": "Un ou plusieurs canaux sont désactivés dans les paramètres et ne recevront pas de notifications.", "reminderSettingsLink": "Réglages des rappels", - "channelsLink": "Canaux" + "channelsLink": "Canaux", + "handover": { + "claimedTitle": "{name} a maintenant son propre compte", + "claimedEnd": "{name} a repris son dossier de santé. Votre accès a pris fin.", + "claimedRead": "{name} a repris son dossier de santé. Vous pouvez encore le consulter, mais plus le modifier.", + "claimedManage": "{name} a repris son dossier de santé. Vous pouvez encore le gérer jusqu'à nouvelle décision.", + "changedTitle": "{name} a modifié votre accès", + "changedEnd": "Votre accès au dossier de santé de {name} a pris fin.", + "changedRead": "Vous pouvez désormais consulter le dossier de santé de {name}, sans le modifier.", + "changedManage": "Vous pouvez gérer le dossier de santé de {name}." + } }, "i18n": { "maintainershipBanner": { @@ -11606,29 +11629,44 @@ "rosterLoadError": "Impossible de charger qui s'occupe de ce dossier.", "errorRateLimit": "Trop de profils créés dans la dernière heure. Patientez un peu et réessayez.", "handover": { - "title": "Transmettre ce profil", - "description": "Donne à {name} son propre accès. Les données restent où elles sont ; tu proposes l’accès que garde chaque personne aidante, et {name} décide.", - "create": "Créer le lien de transmission", + "action": "Transmettre", + "title": "Transmettre à {name}", + "description": "Donnez à {name} sa propre connexion. Rien n'est copié ni déplacé : le dossier devient son compte.", + "guardiansLabel": "Accès après la transmission", + "guardiansHint": "Votre proposition. {name} la voit et décide après la connexion.", + "access": { + "end": "Aucun accès", + "read": "Lecture seule", + "manage": "Gérer" + }, "expiryLabel": "Lien valable pendant", "expiry1": "1 jour", "expiry7": "7 jours", "expiry14": "14 jours", - "proposalLabel": "Accès proposé ensuite", - "proposal": { - "end": "Mettre fin à l’accès", - "read": "Consultation seule", - "manage": "Continuer à gérer" - }, - "linkTitle": "Lien de transmission", - "linkOnce": "Ce lien n’est affiché qu’une seule fois. Remets-le directement à {name} ou laisse-le scanner le code.", - "copy": "Copier le lien", + "minorHint": "{name} a moins de 16 ans. Garder l'accès en lecture et parler de cette étape ensemble vaut la peine.", + "irreversible": "Dès que {name} utilise le lien, la transmission ne peut plus être annulée. Le dossier lui appartient et c'est à elle ou lui de décider qui garde l'accès.", + "afterwards": "{name} confirme personnellement l'avertissement et tout consentement à l'IA. Les rappels de ce dossier ne vous parviennent plus.", + "replaceNote": "Créer un nouveau lien retire celui qui est ouvert.", + "create": "Créer le lien", + "creating": "Création…", + "linkOnce": "Ce lien n'est affiché que maintenant. Donnez-le à {name} en personne ou par un canal de confiance, ou laissez scanner le code.", + "qrAlt": "Code QR du lien de transmission pour {name}", + "urlLabel": "Lien de transmission", + "copy": "Copier", "copied": "Lien copié", - "pending": "Lien de transmission ouvert jusqu’au {date}", + "copyError": "Le lien n'a pas pu être copié. Sélectionnez-le et copiez-le à la main.", + "linkExpires": "Valable jusqu'au {date}.", + "done": "Terminé", + "pending": "Un lien de transmission est ouvert jusqu'au {date}.", + "replace": "Nouveau lien", "revoke": "Retirer le lien", "revoked": "Lien retiré", - "errorFailed": "Le lien de transmission n’a pas pu être créé.", - "oidcOnly": "La transmission de profil n’est pas disponible tant que ce serveur n’autorise que l’authentification unique.", - "minorHint": "{name} a moins de 16 ans. Garder l’accès en lecture mérite réflexion." + "revokeFailed": "Le lien n'a pas pu être retiré. Réessayez.", + "errorFailed": "Le lien de transmission n'a pas pu être créé.", + "errorRosterChanged": "Entre-temps, les personnes qui s'occupent du dossier ont changé. Fermez ceci et rouvrez-le.", + "oidcOnly": "Transmettre un profil n'est pas possible tant que ce serveur n'autorise que l'authentification unique.", + "loadError": "L'état de la transmission n'a pas pu être chargé.", + "entryHint": "Quand {name} souhaite tenir ce dossier de façon autonome." } }, "toast": { @@ -11804,6 +11842,22 @@ }, "anamnesis": { "relativeNote": "Un antécédent familial décrit les proches de cette personne. Ne l'ajoutez que si elle voudrait le voir consigné ici." + }, + "handoverDecision": { + "title": "Qui garde l'accès à votre dossier", + "description": "Décidez ce que les personnes qui ont tenu votre dossier peuvent faire désormais.", + "proposed": "Proposé : {access}", + "settled": "Déjà modifié dans Accès partagé", + "empty": "Personne d'autre n'avait accès à votre dossier.", + "later": "Vous pouvez le modifier à tout moment dans Paramètres, Accès partagé.", + "save": "Confirmer", + "saving": "Enregistrement…", + "decideLater": "Plus tard", + "errorFailed": "Votre décision n'a pas pu être enregistrée. Réessayez.", + "loadError": "Les personnes qui tenaient votre dossier n'ont pas pu être chargées.", + "notificationsTitle": "Configurez vos propres rappels", + "notificationsBody": "Jusqu'ici, les rappels de ce dossier parvenaient aux personnes qui le tenaient. Désormais, ils vous parviennent dès que vous ajoutez un canal de notification.", + "notificationsAction": "Ouvrir les notifications" } }, "encounters": { diff --git a/messages/it.json b/messages/it.json index 9784f5ae8..f45e5f433 100644 --- a/messages/it.json +++ b/messages/it.json @@ -515,17 +515,29 @@ "localhostNote": "Questa pagina è su http://localhost mentre il server imposta il cookie di accesso solo per https://. La maggior parte dei browser lo accetta su localhost; se dopo l'accesso torni qui, imposta SESSION_COOKIE_SECURE=false." }, "claim": { - "title": "Prendi in mano il tuo profilo", - "description": "{guardian} ha tenuto un registro sanitario per te. Scegli il tuo accesso per renderlo il tuo account.", - "invalid": "Questo link non è valido, è scaduto o è già stato usato.", - "signOutFirst": "Esci prima per prendere in mano questo profilo.", - "emailRequired": "È necessario un indirizzo email.", - "guardiansTitle": "Chi mantiene l’accesso", - "guardianProposal": "{name} propone: {proposal}", - "consentNote": "Dopo la presa in carico confermerai tu l’avvertenza e l’eventuale consenso all’IA.", - "submit": "Prendi il profilo", + "title": "Prendi in mano la tua cartella sanitaria", + "titleNamed": "Ti diamo il benvenuto, {name}", + "description": "Finora la tua cartella sanitaria è stata tenuta per te. Scegli come accedere e diventerà il tuo account. Nulla viene copiato o spostato.", + "loading": "Controllo del link…", + "invalid": "Questo link non funziona più", + "invalidHint": "È sconosciuto, scaduto, ritirato o già usato. Chiedine uno nuovo a chi te l'ha inviato.", + "oidcOnly": "Questo server consente solo il single sign-on, quindi qui non è ancora possibile prendere in carico un profilo.", + "signedInTitle": "Prima esci", + "signedInBody": "Hai effettuato l'accesso come {username}. Questo link crea un accesso per un'altra persona, quindi esci prima di usarlo.", + "signOut": "Esci e continua", + "guardiansTitle": "Chi mantiene l'accesso per ora", + "guardiansHint": "Proposto dalle persone che hanno tenuto la tua cartella. Decidi subito dopo l'accesso.", + "usernameHint": "Da 3 a 30 lettere, numeri, _ o -.", + "usernameReserved": "I nomi utente che iniziano con managed- sono riservati.", + "emailHint": "Obbligatorio, così puoi rientrare se dimentichi la password.", + "consentNote": "L'avviso e qualsiasi uso dell'IA li confermi tu. Fino ad allora nulla della tua cartella viene inviato a un fornitore di IA.", + "expires": "Questo link è valido fino al {date}.", + "submit": "Prendi in carico", "submitting": "Presa in carico…", - "success": "Il profilo ora è il tuo account." + "errorTaken": "Questo nome utente o indirizzo email è già in uso.", + "errorRateLimit": "Troppi tentativi. Attendi qualche minuto e riprova.", + "errorFailed": "Non ha funzionato. Riprova tra un momento.", + "errorOffline": "Il server non è stato raggiunto. Controlla la connessione e riprova." } }, "dashboard": { @@ -7464,6 +7476,7 @@ "finalDelete": "Elimina definitivamente", "deletedResult": "Eliminate {rows} righe in {tables} tabelle. Account utente e credenziali di accesso conservati.", "authRegister": "Registrazione", + "authClaim": "Profilo preso in carico", "authLogin": "Accedi", "authLoginPasskey": "Accesso con passkey", "authLoginPassword": "Accesso con password", @@ -8387,7 +8400,17 @@ "loginRequired": "Accedi per gestire le notifiche.", "someChannelsGloballyDisabled": "Uno o più canali sono disattivati nelle impostazioni e non riceveranno notifiche.", "reminderSettingsLink": "Impostazioni promemoria", - "channelsLink": "Canali" + "channelsLink": "Canali", + "handover": { + "claimedTitle": "{name} ora ha un proprio account", + "claimedEnd": "{name} ha preso in carico la propria cartella sanitaria. Il tuo accesso è terminato.", + "claimedRead": "{name} ha preso in carico la propria cartella sanitaria. Puoi ancora vederla, ma non più modificarla.", + "claimedManage": "{name} ha preso in carico la propria cartella sanitaria. Puoi ancora gestirla finché non deciderà altrimenti.", + "changedTitle": "{name} ha cambiato il tuo accesso", + "changedEnd": "Il tuo accesso alla cartella sanitaria di {name} è terminato.", + "changedRead": "Ora puoi vedere la cartella sanitaria di {name}, ma non modificarla.", + "changedManage": "Puoi gestire la cartella sanitaria di {name}." + } }, "i18n": { "maintainershipBanner": { @@ -11606,29 +11629,44 @@ "rosterLoadError": "Non è stato possibile caricare chi si occupa di questa cartella.", "errorRateLimit": "Troppi profili creati nell'ultima ora. Aspetta un po' e riprova.", "handover": { - "title": "Consegna questo profilo", - "description": "Dai a {name} un accesso tutto suo. I dati restano dove sono; tu proponi quale accesso mantiene ogni persona che se ne occupa e {name} decide.", - "create": "Crea link di consegna", + "action": "Consegna", + "title": "Consegna a {name}", + "description": "Dai a {name} un accesso proprio. Nulla viene copiato o spostato: la cartella diventa il suo account.", + "guardiansLabel": "Accesso dopo la consegna", + "guardiansHint": "La tua proposta. {name} la vede e decide dopo l'accesso.", + "access": { + "end": "Nessun accesso", + "read": "Solo lettura", + "manage": "Gestire" + }, "expiryLabel": "Link valido per", "expiry1": "1 giorno", "expiry7": "7 giorni", "expiry14": "14 giorni", - "proposalLabel": "Accesso proposto dopo", - "proposal": { - "end": "Termina l’accesso", - "read": "Solo visualizzazione", - "manage": "Continua a gestire" - }, - "linkTitle": "Link di consegna", - "linkOnce": "Questo link viene mostrato una sola volta. Consegnalo direttamente a {name} o fagli scansionare il codice.", - "copy": "Copia link", + "minorHint": "{name} ha meno di 16 anni. Vale la pena mantenere l'accesso in lettura e parlare insieme di questo passo.", + "irreversible": "Appena {name} usa il link, la consegna non si può annullare. La cartella sarà sua e deciderà chi mantiene l'accesso.", + "afterwards": "{name} conferma di persona l'avviso e ogni consenso all'IA. I promemoria di questa cartella non ti raggiungeranno più.", + "replaceNote": "Creare un nuovo link ritira quello aperto.", + "create": "Crea link", + "creating": "Creazione…", + "linkOnce": "Questo link viene mostrato solo ora. Consegnalo a {name} di persona o tramite un canale di fiducia, oppure fai scansionare il codice.", + "qrAlt": "Codice QR del link di consegna per {name}", + "urlLabel": "Link di consegna", + "copy": "Copia", "copied": "Link copiato", - "pending": "Link di consegna aperto fino al {date}", + "copyError": "Impossibile copiare il link. Selezionalo e copialo a mano.", + "linkExpires": "Valido fino al {date}.", + "done": "Fatto", + "pending": "Un link di consegna è aperto fino al {date}.", + "replace": "Nuovo link", "revoke": "Ritira link", "revoked": "Link ritirato", + "revokeFailed": "Impossibile ritirare il link. Riprova.", "errorFailed": "Impossibile creare il link di consegna.", - "oidcOnly": "La consegna del profilo non è disponibile finché questo server consente solo l’accesso unico.", - "minorHint": "{name} ha meno di 16 anni. Vale la pena considerare di mantenere l’accesso in lettura." + "errorRosterChanged": "Nel frattempo è cambiato chi si occupa della cartella. Chiudi e riapri.", + "oidcOnly": "Non è possibile consegnare un profilo finché questo server consente solo il single sign-on.", + "loadError": "Impossibile caricare lo stato della consegna.", + "entryHint": "Quando {name} vuole tenere questa cartella in autonomia." } }, "toast": { @@ -11804,6 +11842,22 @@ }, "anamnesis": { "relativeNote": "Una voce di anamnesi familiare descrive i parenti di questa persona. Aggiungila solo se vorrebbe vederla registrata qui." + }, + "handoverDecision": { + "title": "Chi mantiene l'accesso alla tua cartella", + "description": "Decidi cosa possono fare d'ora in poi le persone che hanno tenuto la tua cartella.", + "proposed": "Proposto: {access}", + "settled": "Già modificato in Accesso condiviso", + "empty": "Nessun altro aveva accesso alla tua cartella.", + "later": "Puoi cambiarlo in qualsiasi momento in Impostazioni, Accesso condiviso.", + "save": "Conferma", + "saving": "Salvataggio…", + "decideLater": "Più tardi", + "errorFailed": "Impossibile salvare la tua decisione. Riprova.", + "loadError": "Impossibile caricare chi teneva la tua cartella.", + "notificationsTitle": "Imposta i tuoi promemoria", + "notificationsBody": "Finora i promemoria di questa cartella arrivavano alle persone che la tenevano. D'ora in poi arrivano a te, appena aggiungi un canale di notifica.", + "notificationsAction": "Apri le notifiche" } }, "encounters": { diff --git a/messages/ko.json b/messages/ko.json index aa0d855ec..d1984258e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -515,17 +515,29 @@ "localhostNote": "이 페이지는 http://localhost에 있지만 서버는 로그인 쿠키를 https://에만 설정합니다. 대부분의 브라우저는 localhost에서 이를 허용합니다. 로그인 후 계속 여기로 돌아온다면 SESSION_COOKIE_SECURE=false로 지정하세요." }, "claim": { - "title": "내 프로필 넘겨받기", - "description": "{guardian}님이 당신의 건강 기록을 관리해 왔습니다. 로그인 방법을 정해 내 계정으로 만드세요.", - "invalid": "이 링크는 유효하지 않거나 만료되었거나 이미 사용되었습니다.", - "signOutFirst": "이 프로필을 넘겨받으려면 먼저 로그아웃하세요.", - "emailRequired": "이메일 주소가 필요합니다.", - "guardiansTitle": "접근 권한을 유지할 사람", - "guardianProposal": "{name}님의 제안: {proposal}", - "consentNote": "넘겨받은 뒤에는 고지 사항과 AI 동의를 직접 다시 확인하게 됩니다.", - "submit": "프로필 넘겨받기", + "title": "내 건강 기록 넘겨받기", + "titleNamed": "{name} 님, 환영합니다", + "description": "지금까지는 다른 사람이 건강 기록을 대신 관리했습니다. 로그인 방법을 정하면 이 기록이 내 계정이 됩니다. 아무것도 복사되거나 옮겨지지 않습니다.", + "loading": "링크를 확인하는 중…", + "invalid": "이 링크는 더 이상 작동하지 않습니다", + "invalidHint": "알 수 없거나, 만료되었거나, 철회되었거나, 이미 사용된 링크입니다. 보낸 사람에게 새 링크를 요청하세요.", + "oidcOnly": "이 서버는 통합 로그인(SSO)만 허용하므로 아직 여기서 프로필을 넘겨받을 수 없습니다.", + "signedInTitle": "먼저 로그아웃하세요", + "signedInBody": "{username} 계정으로 로그인되어 있습니다. 이 링크는 다른 사람의 로그인을 만드는 링크이므로 사용하기 전에 로그아웃하세요.", + "signOut": "로그아웃하고 계속", + "guardiansTitle": "당분간 접근 권한을 유지하는 사람", + "guardiansHint": "기록을 관리해 온 사람들이 제안한 내용입니다. 로그인한 직후에 직접 결정합니다.", + "usernameHint": "영문자, 숫자, _ 또는 - 로 3~30자.", + "usernameReserved": "managed- 로 시작하는 사용자 이름은 예약되어 있습니다.", + "emailHint": "비밀번호를 잊었을 때 다시 들어올 수 있도록 필요합니다.", + "consentNote": "안내 사항과 AI 사용은 직접 확인합니다. 그 전까지는 기록의 어떤 내용도 AI 제공자에게 전송되지 않습니다.", + "expires": "이 링크는 {date}까지 유효합니다.", + "submit": "넘겨받기", "submitting": "넘겨받는 중…", - "success": "이제 이 프로필이 내 계정입니다." + "errorTaken": "이미 사용 중인 사용자 이름 또는 이메일 주소입니다.", + "errorRateLimit": "시도 횟수가 너무 많습니다. 몇 분 후 다시 시도하세요.", + "errorFailed": "처리하지 못했습니다. 잠시 후 다시 시도하세요.", + "errorOffline": "서버에 연결하지 못했습니다. 연결을 확인하고 다시 시도하세요." } }, "dashboard": { @@ -7440,6 +7452,7 @@ "finalDelete": "영구 삭제", "deletedResult": "테이블 {tables}개에서 행 {rows}개를 삭제했어요. 사용자 계정과 로그인 자격 증명은 남겼어요.", "authRegister": "회원가입", + "authClaim": "프로필 넘겨받음", "authLogin": "로그인", "authLoginPasskey": "패스키 로그인", "authLoginPassword": "비밀번호 로그인", @@ -8387,7 +8400,17 @@ "loginRequired": "알림을 관리하려면 로그인해 주세요.", "someChannelsGloballyDisabled": "설정에서 꺼둔 채널이 있어서 그 채널로는 알림을 받지 못해요.", "reminderSettingsLink": "알림 설정", - "channelsLink": "채널" + "channelsLink": "채널", + "handover": { + "claimedTitle": "{name} 님에게 이제 자신의 계정이 있습니다", + "claimedEnd": "{name} 님이 자신의 건강 기록을 넘겨받았습니다. 내 접근 권한은 종료되었습니다.", + "claimedRead": "{name} 님이 자신의 건강 기록을 넘겨받았습니다. 계속 볼 수는 있지만 더 이상 변경할 수 없습니다.", + "claimedManage": "{name} 님이 자신의 건강 기록을 넘겨받았습니다. 다르게 결정할 때까지 계속 관리할 수 있습니다.", + "changedTitle": "{name} 님이 내 접근 권한을 바꿨습니다", + "changedEnd": "{name} 님의 건강 기록에 대한 내 접근 권한이 종료되었습니다.", + "changedRead": "이제 {name} 님의 건강 기록을 볼 수는 있지만 변경할 수는 없습니다.", + "changedManage": "{name} 님의 건강 기록을 관리할 수 있습니다." + } }, "i18n": { "maintainershipBanner": { @@ -11606,29 +11629,44 @@ "rosterLoadError": "이 기록을 돌보는 사람을 불러오지 못했어요.", "errorRateLimit": "최근 한 시간 동안 만든 프로필이 너무 많아요. 잠시 기다렸다 다시 시도해 주세요.", "handover": { - "title": "이 프로필 넘겨주기", - "description": "{name}에게 자신의 로그인을 만들어 주세요. 데이터는 그대로 있고, 각 보호자가 유지할 접근 권한은 당신이 제안하며 {name}이(가) 결정합니다.", - "create": "넘겨주기 링크 만들기", + "action": "넘겨주기", + "title": "{name} 님에게 넘겨주기", + "description": "{name} 님에게 자신의 로그인을 만들어 주세요. 아무것도 복사되거나 옮겨지지 않고, 기록이 그대로 그 사람의 계정이 됩니다.", + "guardiansLabel": "넘겨준 뒤의 접근 권한", + "guardiansHint": "내 제안입니다. {name} 님이 로그인한 뒤 보고 결정합니다.", + "access": { + "end": "접근 없음", + "read": "보기만", + "manage": "관리" + }, "expiryLabel": "링크 유효 기간", "expiry1": "1일", "expiry7": "7일", "expiry14": "14일", - "proposalLabel": "이후 제안하는 접근 권한", - "proposal": { - "end": "접근 종료", - "read": "보기만", - "manage": "계속 관리" - }, - "linkTitle": "넘겨주기 링크", - "linkOnce": "이 링크는 한 번만 표시됩니다. {name}에게 직접 전달하거나 코드를 스캔하게 하세요.", - "copy": "링크 복사", + "minorHint": "{name} 님은 16세 미만입니다. 보기 권한을 유지하고 이 단계를 함께 이야기해 보는 것이 좋습니다.", + "irreversible": "{name} 님이 링크를 사용하면 넘겨주기를 되돌릴 수 없습니다. 기록은 그 사람의 것이 되고, 누가 접근 권한을 유지할지 직접 결정합니다.", + "afterwards": "{name} 님이 안내 사항과 AI 동의를 직접 확인합니다. 이 기록의 알림은 더 이상 나에게 오지 않습니다.", + "replaceNote": "새 링크를 만들면 열려 있는 링크는 철회됩니다.", + "create": "링크 만들기", + "creating": "만드는 중…", + "linkOnce": "이 링크는 지금만 표시됩니다. {name} 님에게 직접 또는 믿을 수 있는 방법으로 전달하거나 코드를 스캔하게 하세요.", + "qrAlt": "{name} 님을 위한 넘겨주기 링크 QR 코드", + "urlLabel": "넘겨주기 링크", + "copy": "복사", "copied": "링크를 복사했습니다", - "pending": "넘겨주기 링크 유효: {date}까지", + "copyError": "링크를 복사하지 못했습니다. 선택해서 직접 복사하세요.", + "linkExpires": "{date}까지 유효합니다.", + "done": "완료", + "pending": "{date}까지 넘겨주기 링크가 열려 있습니다.", + "replace": "새 링크", "revoke": "링크 철회", "revoked": "링크를 철회했습니다", + "revokeFailed": "링크를 철회하지 못했습니다. 다시 시도하세요.", "errorFailed": "넘겨주기 링크를 만들지 못했습니다.", - "oidcOnly": "이 서버가 통합 로그인만 허용하는 동안에는 프로필을 넘겨줄 수 없습니다.", - "minorHint": "{name}은(는) 16세 미만입니다. 보기 권한을 유지하는 것을 고려해 보세요." + "errorRosterChanged": "그 사이 기록을 돌보는 사람이 바뀌었습니다. 닫았다가 다시 여세요.", + "oidcOnly": "이 서버가 통합 로그인(SSO)만 허용하는 동안에는 프로필을 넘겨줄 수 없습니다.", + "loadError": "넘겨주기 상태를 불러오지 못했습니다.", + "entryHint": "{name} 님이 이 기록을 직접 관리할 준비가 되었을 때." } }, "toast": { @@ -11804,6 +11842,22 @@ }, "anamnesis": { "relativeNote": "가족력 항목은 이 사람의 친척에 대한 내용이에요. 여기 기록되기를 상대가 원할 때만 추가하세요." + }, + "handoverDecision": { + "title": "내 기록에 접근할 수 있는 사람", + "description": "기록을 관리해 온 사람들이 앞으로 무엇을 할 수 있을지 정하세요.", + "proposed": "제안: {access}", + "settled": "공유 접근에서 이미 변경됨", + "empty": "다른 사람은 내 기록에 접근할 수 없었습니다.", + "later": "설정, 공유 접근에서 언제든지 바꿀 수 있습니다.", + "save": "확인", + "saving": "저장하는 중…", + "decideLater": "나중에", + "errorFailed": "결정을 저장하지 못했습니다. 다시 시도하세요.", + "loadError": "기록을 관리해 온 사람들을 불러오지 못했습니다.", + "notificationsTitle": "내 알림 설정하기", + "notificationsBody": "지금까지 이 기록의 알림은 기록을 관리하던 사람들에게 갔습니다. 이제 알림 채널을 추가하면 바로 나에게 옵니다.", + "notificationsAction": "알림 설정 열기" } }, "encounters": { diff --git a/messages/pl.json b/messages/pl.json index 80234db49..20f2c3aa7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -515,17 +515,29 @@ "localhostNote": "Ta strona działa na http://localhost, a serwer ustawia ciasteczko logowania tylko dla https://. Większość przeglądarek akceptuje to na localhost; jeśli po zalogowaniu wracasz tutaj, ustaw SESSION_COOKIE_SECURE=false." }, "claim": { - "title": "Przejmij swój profil", - "description": "{guardian} prowadził(a) dla ciebie kartę zdrowia. Wybierz sposób logowania, aby stała się twoim własnym kontem.", - "invalid": "Ten link jest nieprawidłowy, wygasł lub został już użyty.", - "signOutFirst": "Najpierw się wyloguj, aby przejąć ten profil.", - "emailRequired": "Adres e-mail jest wymagany.", - "guardiansTitle": "Kto zachowa dostęp", - "guardianProposal": "{name} proponuje: {proposal}", - "consentNote": "Po przejęciu sam(a) potwierdzisz zastrzeżenie i ewentualną zgodę na AI.", - "submit": "Przejmij profil", + "title": "Przejmij swoją kartę zdrowia", + "titleNamed": "Witaj, {name}", + "description": "Do tej pory ktoś prowadził Twoją kartę zdrowia za Ciebie. Wybierz sposób logowania, a stanie się ona Twoim własnym kontem. Nic nie jest kopiowane ani przenoszone.", + "loading": "Sprawdzanie linku…", + "invalid": "Ten link już nie działa", + "invalidHint": "Jest nieznany, wygasł, został wycofany albo już użyty. Poproś osobę, która go wysłała, o nowy.", + "oidcOnly": "Ten serwer pozwala tylko na logowanie jednokrotne (SSO), więc profilu nie można tu jeszcze przejąć.", + "signedInTitle": "Najpierw się wyloguj", + "signedInBody": "Masz zalogowaną sesję jako {username}. Ten link tworzy logowanie dla kogoś innego, więc wyloguj się, zanim go użyjesz.", + "signOut": "Wyloguj i kontynuuj", + "guardiansTitle": "Kto na razie zachowuje dostęp", + "guardiansHint": "Zaproponowane przez osoby, które prowadziły Twoją kartę. Zdecydujesz zaraz po zalogowaniu.", + "usernameHint": "Od 3 do 30 liter, cyfr, _ lub -.", + "usernameReserved": "Nazwy użytkownika zaczynające się od managed- są zarezerwowane.", + "emailHint": "Wymagany, żebyś mógł wrócić, jeśli zapomnisz hasła.", + "consentNote": "Zastrzeżenie i każde użycie AI potwierdzasz sam. Do tego czasu nic z Twojej karty nie trafia do dostawcy AI.", + "expires": "Ten link jest ważny do {date}.", + "submit": "Przejmij", "submitting": "Przejmowanie…", - "success": "Profil jest teraz twoim kontem." + "errorTaken": "Ta nazwa użytkownika lub adres e-mail jest już zajęty.", + "errorRateLimit": "Zbyt wiele prób. Odczekaj kilka minut i spróbuj ponownie.", + "errorFailed": "Nie udało się. Spróbuj ponownie za chwilę.", + "errorOffline": "Nie udało się połączyć z serwerem. Sprawdź połączenie i spróbuj ponownie." } }, "dashboard": { @@ -7464,6 +7476,7 @@ "finalDelete": "Usuń trwale", "deletedResult": "Usunięto {rows} wierszy w {tables} tabelach. Konta użytkowników i dane logowania zostały zachowane.", "authRegister": "Rejestracja", + "authClaim": "Profil przejęty", "authLogin": "Zaloguj", "authLoginPasskey": "Logowanie kluczem passkey", "authLoginPassword": "Logowanie hasłem", @@ -8387,7 +8400,17 @@ "loginRequired": "Zaloguj się, aby zarządzać powiadomieniami.", "someChannelsGloballyDisabled": "Co najmniej jeden kanał jest wyłączony w ustawieniach i nie będzie otrzymywać powiadomień.", "reminderSettingsLink": "Ustawienia przypomnień", - "channelsLink": "Kanały" + "channelsLink": "Kanały", + "handover": { + "claimedTitle": "{name} ma teraz własne konto", + "claimedEnd": "{name} przejmuje swoją kartę zdrowia. Twój dostęp się zakończył.", + "claimedRead": "{name} przejmuje swoją kartę zdrowia. Nadal możesz ją oglądać, ale już nie zmieniać.", + "claimedManage": "{name} przejmuje swoją kartę zdrowia. Nadal możesz nią zarządzać, dopóki nie zapadnie inna decyzja.", + "changedTitle": "{name} zmienia Twój dostęp", + "changedEnd": "Twój dostęp do karty zdrowia osoby {name} się zakończył.", + "changedRead": "Możesz teraz oglądać kartę zdrowia osoby {name}, ale nie zmieniać jej.", + "changedManage": "Możesz zarządzać kartą zdrowia osoby {name}." + } }, "i18n": { "maintainershipBanner": { @@ -11606,29 +11629,44 @@ "rosterLoadError": "Nie udało się wczytać, kto opiekuje się tą kartą.", "errorRateLimit": "Zbyt wiele profili utworzonych w ostatniej godzinie. Odczekaj chwilę i spróbuj ponownie.", "handover": { - "title": "Przekaż ten profil", - "description": "Daj osobie {name} własne logowanie. Dane zostają tam, gdzie są; ty proponujesz, jaki dostęp zachowa każdy opiekun, a {name} decyduje.", - "create": "Utwórz link przekazania", + "action": "Przekaż", + "title": "Przekaż osobie {name}", + "description": "Daj osobie {name} własne logowanie. Nic nie jest kopiowane ani przenoszone: karta staje się jej kontem.", + "guardiansLabel": "Dostęp po przekazaniu", + "guardiansHint": "Twoja propozycja. {name} zobaczy ją i zdecyduje po zalogowaniu.", + "access": { + "end": "Brak dostępu", + "read": "Tylko podgląd", + "manage": "Zarządzanie" + }, "expiryLabel": "Link ważny przez", "expiry1": "1 dzień", "expiry7": "7 dni", "expiry14": "14 dni", - "proposalLabel": "Proponowany dostęp potem", - "proposal": { - "end": "Zakończ dostęp", - "read": "Tylko podgląd", - "manage": "Dalej zarządzaj" - }, - "linkTitle": "Link przekazania", - "linkOnce": "Ten link jest pokazywany tylko raz. Przekaż go osobie {name} bezpośrednio albo pozwól zeskanować kod.", - "copy": "Kopiuj link", + "minorHint": "{name} ma mniej niż 16 lat. Warto zachować dostęp do podglądu i omówić ten krok wspólnie.", + "irreversible": "Gdy {name} użyje linku, przekazania nie da się cofnąć. Karta będzie należeć do tej osoby i to ona zdecyduje, kto zachowa dostęp.", + "afterwards": "{name} osobiście potwierdzi zastrzeżenie i każdą zgodę na AI. Przypomnienia dotyczące tej karty przestaną do Ciebie trafiać.", + "replaceNote": "Utworzenie nowego linku wycofuje otwarty.", + "create": "Utwórz link", + "creating": "Tworzenie…", + "linkOnce": "Ten link jest widoczny tylko teraz. Przekaż go osobie {name} osobiście lub zaufanym kanałem albo pozwól zeskanować kod.", + "qrAlt": "Kod QR linku przekazania dla {name}", + "urlLabel": "Link przekazania", + "copy": "Kopiuj", "copied": "Link skopiowany", - "pending": "Link przekazania otwarty do {date}", + "copyError": "Nie udało się skopiować linku. Zaznacz go i skopiuj ręcznie.", + "linkExpires": "Ważny do {date}.", + "done": "Gotowe", + "pending": "Link przekazania jest otwarty do {date}.", + "replace": "Nowy link", "revoke": "Wycofaj link", "revoked": "Link wycofany", + "revokeFailed": "Nie udało się wycofać linku. Spróbuj ponownie.", "errorFailed": "Nie udało się utworzyć linku przekazania.", - "oidcOnly": "Przekazanie profilu jest niedostępne, dopóki ten serwer pozwala tylko na logowanie jednokrotne.", - "minorHint": "{name} ma mniej niż 16 lat. Warto rozważyć zachowanie dostępu do podglądu." + "errorRosterChanged": "W międzyczasie zmieniło się, kto opiekuje się kartą. Zamknij to i otwórz ponownie.", + "oidcOnly": "Przekazanie profilu nie jest możliwe, dopóki ten serwer pozwala tylko na logowanie jednokrotne (SSO).", + "loadError": "Nie udało się wczytać stanu przekazania.", + "entryHint": "Gdy {name} chce samodzielnie prowadzić tę kartę." } }, "toast": { @@ -11804,6 +11842,22 @@ }, "anamnesis": { "relativeNote": "Wpis w wywiadzie rodzinnym opisuje bliskich tej osoby. Dodaj go tylko wtedy, gdy chciałaby, żeby został tu zapisany." + }, + "handoverDecision": { + "title": "Kto zachowuje dostęp do Twojej karty", + "description": "Zdecyduj, co od teraz mogą osoby, które prowadziły Twoją kartę.", + "proposed": "Proponowane: {access}", + "settled": "Już zmienione w Udostępnionym dostępie", + "empty": "Nikt inny nie miał dostępu do Twojej karty.", + "later": "Możesz to zmienić w każdej chwili w Ustawieniach, Udostępniony dostęp.", + "save": "Potwierdź", + "saving": "Zapisywanie…", + "decideLater": "Później", + "errorFailed": "Nie udało się zapisać decyzji. Spróbuj ponownie.", + "loadError": "Nie udało się wczytać osób, które prowadziły Twoją kartę.", + "notificationsTitle": "Ustaw własne przypomnienia", + "notificationsBody": "Do tej pory przypomnienia dla tej karty trafiały do osób, które ją prowadziły. Od teraz trafiają do Ciebie, gdy tylko dodasz kanał powiadomień.", + "notificationsAction": "Otwórz powiadomienia" } }, "encounters": { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ec79f6d71..9b23caf54 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3918,17 +3918,22 @@ model ManagedProfileHandover { profileId String @map("profile_id") /// The guardian who minted the link. createdById String @map("created_by_id") - /// @pending-consumer(#959) HMAC-SHA256 of the token, unique. + /// HMAC-SHA256 of the token, unique; the claim and its preview look the + /// link up by it (`src/lib/managed-profiles/handover.ts`). tokenHash String @unique @map("token_hash") - /// @pending-consumer(#959) Per-guardian proposal (end / read / manage) the - /// new owner confirms or changes when claiming. + /// The handover's state, versioned and read fail-closed by + /// `parseHandoverState`: the issuing guardian's proposal per guardian + /// (end / read / manage), and after the claim what each guardian was given + /// and the new owner's final decision. The pending-decision read on the new + /// owner's account is answered from here. proposalsJson Json @map("proposals_json") createdAt DateTime @default(now()) @map("created_at") - /// @pending-consumer(#959) Link expiry (1, 7 or 14 days after minting). + /// Link expiry (1, 7 or 14 days after minting). expiresAt DateTime @map("expires_at") - /// @pending-consumer(#959) Set when the profile was claimed through the link. + /// Set when the profile was claimed through the link; the token is spent. usedAt DateTime? @map("used_at") - /// @pending-consumer(#959) Set when a guardian withdrew the link. + /// Set when a guardian withdrew the link, a newer link replaced it, or its + /// creator lost access. revokedAt DateTime? @map("revoked_at") profile User @relation("HandoverProfile", fields: [profileId], references: [id], onDelete: Cascade) diff --git a/src/__tests__/acting-account-boundary-guard.test.ts b/src/__tests__/acting-account-boundary-guard.test.ts index ad7666d33..107794519 100644 --- a/src/__tests__/acting-account-boundary-guard.test.ts +++ b/src/__tests__/acting-account-boundary-guard.test.ts @@ -229,6 +229,9 @@ describe("grant management is not delegable by construction", () => { "app/api/account/grants/[id]/route.ts", "app/api/account/grants/[id]/accept/route.ts", "app/api/account/grants/[id]/renounce/route.ts", + // v1.42 (#959) — the new owner's decision about former Guardians ends and + // writes grant rows, so it is a grant route like the four above. + "app/api/account/handover-decision/route.ts", ]; it("every grant route exists and resolves through bare requireAuth", () => { @@ -300,6 +303,20 @@ describe("ending a grant and clearing the switch are one act", () => { expect(body).toContain("clearActingSessions"); }); + it("ends a handover Guardian's row and clears their switch in the caller's transaction", () => { + // v1.42 (#959) — the one other place a grant ends: settling a Guardian's + // access when a managed profile is claimed or decided. It runs inside the + // claim's own transaction (it takes `tx`), so the guard here is that the + // cleanup is part of the same function as the ending. + const body = functionBody( + read("lib/sharing/grants.ts"), + "settleHandoverGuardianGrant", + ); + expect(body.length).toBeGreaterThan(0); + expect(body).toContain("revokedAt: input.now"); + expect(body).toContain("clearActingSessions(pair, tx)"); + }); + it("routes an ending through the cleanup, never through the bare transition", () => { for (const rel of [ "app/api/account/grants/[id]/route.ts", diff --git a/src/__tests__/i18n-reverse-coverage.test.ts b/src/__tests__/i18n-reverse-coverage.test.ts index 9ca8ae26e..92917bc04 100644 --- a/src/__tests__/i18n-reverse-coverage.test.ts +++ b/src/__tests__/i18n-reverse-coverage.test.ts @@ -78,9 +78,6 @@ const DYNAMIC_ALLOWLIST_PREFIXES = [ * before the release is tagged. It is NOT a place for keys nobody will wire. */ const RELEASE_PENDING_PREFIXES: Record = { - "auth.claim": "v1.42 managed-profile claim page (#959)", - "recordSharing.managed.handover": - "v1.42 managed-profile handover dialog (#959)", "settings.appleHealth.freshness": "v1.42 per-type HealthKit freshness list (#1173)", "insights.workouts.manual.editTitle": "v1.42 manual workout edit (#1162)", diff --git a/src/__tests__/proxy-claim-public.test.ts b/src/__tests__/proxy-claim-public.test.ts new file mode 100644 index 000000000..cb903b791 --- /dev/null +++ b/src/__tests__/proxy-claim-public.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * v1.42 (#959) — `/claim/` is the managed-profile handover link. + * + * The twin of `proxy-invite-public.test.ts`: the link is a shape-validated + * edge redirect onto `/auth/claim?token=…`, it carries no session, and the + * hop is uncacheable, unindexable and never leaked as a `Referer`. The two API + * routes behind the page authenticate by the token in the request body, so + * they pass the page gate; on a demo instance they are refused like every + * other mutation off the allowlist. + */ + +vi.mock("@/lib/process-type", () => ({ + shouldRunWeb: () => true, +})); + +import { proxy, PUBLIC_PATHS } from "../proxy"; + +const VALID_TOKEN = `hlp_${"a".repeat(64)}`; + +function makeRequest( + pathname: string, + init: { cookies?: Record; method?: string } = {}, +): NextRequest { + const cookieHeader = Object.entries(init.cookies ?? {}) + .map(([k, v]) => `${k}=${v}`) + .join("; "); + return new NextRequest(`http://localhost${pathname}`, { + method: init.method ?? "GET", + headers: cookieHeader ? { cookie: cookieHeader } : {}, + }); +} + +describe("proxy.ts /claim public allowlist", () => { + it("lists the landing and the anonymous claim routes as public", () => { + expect(PUBLIC_PATHS).toContain("/claim/"); + expect(PUBLIC_PATHS).toContain("/api/auth/claim"); + }); + + it("does not bounce an unauthenticated /claim/ to /auth/login", () => { + const res = proxy(makeRequest(`/claim/${VALID_TOKEN}`)); + expect(res.headers.get("location") ?? "").not.toMatch(/\/auth\/login/); + }); + + it("does not bounce a pending-onboarding session off the link", () => { + const res = proxy( + makeRequest(`/claim/${VALID_TOKEN}`, { + cookies: { healthlog_session: "sess-1", hl_onboarding: "pending" }, + }), + ); + expect(res.headers.get("location") ?? "").not.toMatch(/\/onboarding/); + }); + + it("lets the anonymous preview and claim reach their handlers", () => { + for (const path of ["/api/auth/claim", "/api/auth/claim/preview"]) { + const res = proxy(makeRequest(path, { method: "POST" })); + expect(res.status, path).not.toBe(401); + expect(res.headers.get("location"), path).toBeNull(); + } + }); +}); + +describe("proxy.ts /claim edge redirect", () => { + it("307s a shape-valid token onto the claim page", () => { + const res = proxy(makeRequest(`/claim/${VALID_TOKEN}`)); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toBe( + `http://localhost/auth/claim?token=${VALID_TOKEN}`, + ); + }); + + it.each([ + ["an uppercase hex body", `hlp_${"A".repeat(64)}`], + ["a short body", `hlp_${"a".repeat(63)}`], + ["a long body", `hlp_${"a".repeat(65)}`], + ["an invite token", `hlv_${"a".repeat(64)}`], + ["markup", "%3Cscript%3E"], + ])("drops %s rather than echoing it into the target", (_label, segment) => { + const res = proxy(makeRequest(`/claim/${segment}`)); + expect(res.headers.get("location")).toBe("http://localhost/auth/claim"); + }); + + it("hardens the token-in-path hop against cache, index and referrer leaks", () => { + const res = proxy(makeRequest(`/claim/${VALID_TOKEN}`)); + expect(res.headers.get("Cache-Control")).toBe( + "no-store, no-cache, must-revalidate", + ); + expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow"); + expect(res.headers.get("Referrer-Policy")).toBe("no-referrer"); + }); +}); + +describe("proxy.ts DEMO_MODE refuses the handover", () => { + const original = process.env.DEMO_MODE; + afterEach(() => { + if (original === undefined) delete process.env.DEMO_MODE; + else process.env.DEMO_MODE = original; + }); + + it.each([ + ["POST", "/api/auth/claim"], + ["POST", "/api/auth/claim/preview"], + ["POST", "/api/managed-profiles/p1/handover"], + ["DELETE", "/api/managed-profiles/p1/handover"], + ["POST", "/api/account/handover-decision"], + ])("403s %s %s on a demo instance", async (method, path) => { + process.env.DEMO_MODE = "true"; + const res = proxy( + makeRequest(path, { + method, + cookies: { healthlog_session: "sess-1" }, + }), + ); + expect(res.status).toBe(403); + }); + + it("leaves the same claim alone off demo mode", () => { + delete process.env.DEMO_MODE; + const res = proxy(makeRequest("/api/auth/claim", { method: "POST" })); + expect(res.status).not.toBe(403); + }); +}); diff --git a/src/__tests__/session-surface-guard.test.ts b/src/__tests__/session-surface-guard.test.ts index 324af37db..9cecc9448 100644 --- a/src/__tests__/session-surface-guard.test.ts +++ b/src/__tests__/session-surface-guard.test.ts @@ -85,6 +85,17 @@ describe("S1 — the direct session-resolution set is frozen", () => { // here twice over: it would throw 401 at the anonymous caller the route // exists for, and this route wants the ABSENCE of a session. "app/api/auth/register/route.ts": "refuses to register over a live session", + // v1.42 (#959) — the anonymous managed-profile claim and its preview. Same + // argument as registration: claiming signs the new owner in, so a Guardian + // opening their own handover link must be refused rather than signed out + // of their account and into the person's. The probe only asks whether a + // session EXISTS; the credential these routes act on is the one-time + // `hlp_` token in the body, and the probe is resolved in the route file + // itself (not inside `claimGate`) so both stay visible on this list. + "app/api/auth/claim/route.ts": + "refuses to claim a profile over a live session", + "app/api/auth/claim/preview/route.ts": + "refuses to preview a handover link over a live session", // Anonymous IdP callback. Probes for an already-valid session so a // replayed callback redirects instead of minting a second identity. "app/api/auth/oidc/callback/route.ts": @@ -244,6 +255,17 @@ describe("S2 — the URL-token authentication set is frozen", () => { // it authenticates nothing. "app/invite/[token]/page.tsx": "validates the invite token's shape and redirects; no lookup", + // v1.42 (#959) — the managed-profile handover link. The landing is the + // invite landing's twin: a shape check on the `hlp_` segment and a + // redirect, no lookup (the proxy normally answers it at the edge). + "app/claim/[token]/page.tsx": + "validates the handover token's shape and redirects; no lookup", + // The claim page reads the token off `?token=` and sends it in the BODY of + // the anonymous preview and claim requests, which are the only places a + // handover token is resolved. The page itself authenticates nothing and + // renders nothing about the profile before the preview answers. + "app/auth/claim/page.tsx": + "carries the handover token from the query string into the claim requests' body", }; /** @@ -283,6 +305,7 @@ describe("S2 — the URL-token authentication set is frozen", () => { "app/c/[token]/fhir/route.ts", "app/c/[token]/page.tsx", "app/c/[token]/report.pdf/route.ts", + "app/claim/[token]/page.tsx", "app/invite/[token]/page.tsx", ]); }); @@ -304,7 +327,10 @@ describe("S2 — the URL-token authentication set is frozen", () => { const byQuery = appFilesMatching(QUERY_CREDENTIAL); expect(byQuery.length).toBeGreaterThan(0); - expect(byQuery).toEqual(["app/api/withings/webhook/route.ts"]); + expect(byQuery).toEqual([ + "app/api/withings/webhook/route.ts", + "app/auth/claim/page.tsx", + ]); }); it("the three legs together equal the frozen allowlist", () => { diff --git a/src/app/api/account/handover-decision/route.ts b/src/app/api/account/handover-decision/route.ts new file mode 100644 index 000000000..5cb55276c --- /dev/null +++ b/src/app/api/account/handover-decision/route.ts @@ -0,0 +1,109 @@ +/** + * `/api/account/handover-decision` — the new owner's final word on each former + * Guardian after claiming a managed profile (v1.42, #959). + * + * The claim applies the access the issuing Guardian proposed, so the record + * is never without a decision. This is where the owner sees it and decides: + * + * - `GET` answers the decision that is waiting, or `pending: null`. + * - `POST` records it. A Guardian the body leaves out keeps what the claim + * gave them; after this, the ordinary Settings → Shared access panel is + * where access changes. + * + * A grant-management surface, resolved like the others (`/api/account/ + * grants/**`): bare `requireAuth()`, which refuses under an acting-account + * switch and refuses a narrow Bearer scope. The owner is the session user; no + * party to any grant comes from the body, only the claim-time grant id that + * names which former Guardian a decision is about. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { + decideHandover, + HandoverError, + readPendingHandoverDecision, +} from "@/lib/managed-profiles/handover"; +import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify"; +import { prisma } from "@/lib/db"; +import { handoverDecisionSchema } from "@/lib/validations/managed-profile-handover"; + +export const GET = apiHandler(async (_request: NextRequest) => { + const { user } = await requireAuth(); + const pending = await readPendingHandoverDecision(user.id); + annotate({ + action: { name: "managed_profile.handover.decision_read" }, + meta: { pending: pending !== null }, + }); + return apiSuccess({ + pending: pending + ? { + claimedAt: pending.claimedAt.toISOString(), + guardians: pending.guardians, + } + : null, + }); +}); + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + const parsed = handoverDecisionSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + let result; + try { + result = await decideHandover({ + userId: user.id, + decisions: parsed.data.decisions.map((d) => ({ + grantId: d.grantId, + access: d.access, + })), + }); + } catch (error) { + if (error instanceof HandoverError) { + if (error.code === "unknown_guardian") { + return apiError( + "A decision names somebody who was not a Guardian", + 422, + { + errorCode: "managed_profile.handover.unknown_guardian", + }, + ); + } + return apiError("There is no handover decision waiting", 409, { + errorCode: "managed_profile.handover.no_pending", + }); + } + throw error; + } + + if (result.changed.length > 0) { + const self = await prisma.user.findUnique({ + where: { id: user.id }, + select: { displayName: true, username: true }, + }); + notifyGuardiansOfHandover( + "changed", + self?.displayName?.trim() || self?.username || user.username, + result.changed, + ); + } + + annotate({ + action: { name: "managed_profile.handover.decided" }, + meta: { changed: result.changed.length }, + }); + return apiSuccess({ decided: true, changed: result.changed.length }); +}); diff --git a/src/app/api/auth/claim/__tests__/route.test.ts b/src/app/api/auth/claim/__tests__/route.test.ts new file mode 100644 index 000000000..b5b9722b1 --- /dev/null +++ b/src/app/api/auth/claim/__tests__/route.test.ts @@ -0,0 +1,266 @@ +/** + * v1.42 (#959) — the anonymous claim and its preview, at the route seam. + * + * The transaction itself is integration-tested + * (`tests/integration/managed-profile-handover.test.ts`); here the order of + * the refusals is pinned, because each one is a security property: + * + * - single sign-on only refuses before anything else; + * - a live session is refused BEFORE the rate limit (a state, not an + * attempt), and before any token is read; + * - the rate limit runs before the body is parsed; + * - an unusable token answers the same 404 on both routes, and the claim + * checks the link BEFORE probing whether a username or email is taken, so + * the route is not a name oracle for somebody without a live link; + * - nothing is hashed for a token that is refused. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: vi.fn() } }, +})); +vi.mock("@/lib/auth/session", () => ({ + createSession: vi.fn(), + getSession: vi.fn(), +})); +vi.mock("@/lib/auth/oidc", () => ({ isOidcOnly: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), + checkAuthSurfaceRateLimit: vi.fn(), + rateLimitHeaders: () => ({}), +})); +vi.mock("@/lib/auth/password", () => ({ + hashPassword: vi.fn(), + checkPasswordStrength: vi.fn(), +})); +vi.mock("@/lib/password-breach-check", () => ({ + checkPasswordBreachIfEnabled: vi.fn(), +})); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/auth/login-alert", () => ({ recordSignInDevice: vi.fn() })); +vi.mock("@/lib/i18n/server-locale", () => ({ + resolveServerLocale: vi.fn().mockResolvedValue("en"), +})); +vi.mock("@/lib/managed-profiles/handover", async () => { + const actual = await vi.importActual< + typeof import("@/lib/managed-profiles/handover") + >("@/lib/managed-profiles/handover"); + return { + HandoverError: actual.HandoverError, + previewHandover: vi.fn(), + claimManagedProfile: vi.fn(), + }; +}); +vi.mock("@/lib/managed-profiles/handover-notify", () => ({ + notifyGuardiansOfHandover: vi.fn(), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST as claim } from "../route"; +import { POST as preview } from "../preview/route"; +import { prisma } from "@/lib/db"; +import { createSession, getSession } from "@/lib/auth/session"; +import { isOidcOnly } from "@/lib/auth/oidc"; +import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; +import { checkPasswordStrength, hashPassword } from "@/lib/auth/password"; +import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check"; +import { + claimManagedProfile, + HandoverError, + previewHandover, +} from "@/lib/managed-profiles/handover"; +import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify"; + +const TOKEN = `hlp_${"a".repeat(64)}`; +const BODY = { + token: TOKEN, + username: "alex", + email: "alex@example.test", + password: "a long enough passphrase", +}; + +function post(path: string, body: unknown): NextRequest { + return new NextRequest(`http://localhost${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +const PREVIEW = { + displayName: "Alex", + expiresAt: new Date("2030-01-01T00:00:00Z"), + guardians: [{ grantId: "g1", displayName: "Sam", proposal: "read" as const }], +}; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(isOidcOnly).mockReturnValue(false); + vi.mocked(getSession).mockResolvedValue(null as never); + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: true, + ip: "203.0.113.7", + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); + vi.mocked(checkPasswordStrength).mockReturnValue({ + isAcceptable: true, + feedback: [], + } as never); + vi.mocked(checkPasswordBreachIfEnabled).mockResolvedValue(null as never); + vi.mocked(hashPassword).mockResolvedValue("argon2-hash"); + vi.mocked(previewHandover).mockResolvedValue(PREVIEW); + vi.mocked(claimManagedProfile).mockResolvedValue({ + profileId: "p1", + username: "alex", + displayName: "Alex", + guardians: [{ guardianId: "u-sam", access: "read" }], + }); +}); + +async function errorCode(res: Response): Promise { + const body = (await res.json()) as { meta?: { errorCode?: string } }; + return body.meta?.errorCode; +} + +describe.each([ + ["claim", claim, "/api/auth/claim", BODY], + ["preview", preview, "/api/auth/claim/preview", { token: TOKEN }], +] as const)( + "POST %s — refusals that read no token", + (_n, route, path, body) => { + it("refuses on a single-sign-on-only instance before anything else", async () => { + vi.mocked(isOidcOnly).mockReturnValue(true); + vi.mocked(getSession).mockResolvedValue({ user: { id: "u1" } } as never); + const res = await route(post(path, body)); + expect(res.status).toBe(403); + expect(await errorCode(res)).toBe("profile_claim.oidc_only_unsupported"); + expect(previewHandover).not.toHaveBeenCalled(); + }); + + it("refuses over a live session before spending the rate limit", async () => { + vi.mocked(getSession).mockResolvedValue({ user: { id: "u1" } } as never); + const res = await route(post(path, body)); + expect(res.status).toBe(409); + expect(await errorCode(res)).toBe("auth.already_authenticated"); + expect(checkAuthSurfaceRateLimit).not.toHaveBeenCalled(); + expect(previewHandover).not.toHaveBeenCalled(); + }); + + it("answers 429 from the per-address bucket before parsing the body", async () => { + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: false, + ip: "203.0.113.7", + } as never); + const res = await route(post(path, body)); + expect(res.status).toBe(429); + expect(previewHandover).not.toHaveBeenCalled(); + }); + + it("answers the same 404 for an unusable link", async () => { + vi.mocked(previewHandover).mockResolvedValue(null); + const res = await route(post(path, body)); + expect(res.status).toBe(404); + expect(await errorCode(res)).toBe("profile_claim.invalid"); + }); + }, +); + +describe("POST /api/auth/claim", () => { + it("checks the link before the username and email probe", async () => { + vi.mocked(previewHandover).mockResolvedValue(null); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: "x" } as never); + const res = await claim(post("/api/auth/claim", BODY)); + // A taken name would be a 409; the link is checked first, so 404. + expect(res.status).toBe(404); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + expect(hashPassword).not.toHaveBeenCalled(); + }); + + it("answers 409 profile_claim.taken for a name in use, before hashing", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + id: "x", + } as never); + const res = await claim(post("/api/auth/claim", BODY)); + expect(res.status).toBe(409); + expect(await errorCode(res)).toBe("profile_claim.taken"); + expect(hashPassword).not.toHaveBeenCalled(); + expect(claimManagedProfile).not.toHaveBeenCalled(); + }); + + it("refuses a username with the managed-profile prefix as 422", async () => { + const res = await claim( + post("/api/auth/claim", { ...BODY, username: "managed-alex" }), + ); + expect(res.status).toBe(422); + expect(previewHandover).not.toHaveBeenCalled(); + }); + + it("refuses an unknown field (strict body)", async () => { + const res = await claim( + post("/api/auth/claim", { ...BODY, userId: "someone-else" }), + ); + expect(res.status).toBe(422); + }); + + it("maps a race lost inside the transaction to the same 404", async () => { + vi.mocked(claimManagedProfile).mockRejectedValue( + new HandoverError("invalid"), + ); + const res = await claim(post("/api/auth/claim", BODY)); + expect(res.status).toBe(404); + expect(createSession).not.toHaveBeenCalled(); + }); + + it("maps a unique-constraint race to 409 taken", async () => { + vi.mocked(claimManagedProfile).mockRejectedValue( + new HandoverError("taken"), + ); + const res = await claim(post("/api/auth/claim", BODY)); + expect(res.status).toBe(409); + expect(await errorCode(res)).toBe("profile_claim.taken"); + }); + + it("signs the new owner in with onboarding owed and tells the guardians", async () => { + const res = await claim(post("/api/auth/claim", BODY)); + expect(res.status).toBe(201); + expect(claimManagedProfile).toHaveBeenCalledWith( + expect.objectContaining({ + rawToken: TOKEN, + username: "alex", + email: "alex@example.test", + passwordHash: "argon2-hash", + }), + ); + expect(createSession).toHaveBeenCalledWith("p1", true, "203.0.113.7", null); + expect(notifyGuardiansOfHandover).toHaveBeenCalledWith("claimed", "Alex", [ + { guardianId: "u-sam", access: "read" }, + ]); + const body = (await res.json()) as { data: unknown }; + expect(body.data).toEqual({ userId: "p1", username: "alex" }); + }); +}); + +describe("POST /api/auth/claim/preview", () => { + it("returns the minimal preview and nothing else", async () => { + const res = await preview( + post("/api/auth/claim/preview", { token: TOKEN }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: Record }; + expect(Object.keys(body.data).sort()).toEqual([ + "displayName", + "expiresAt", + "guardians", + ]); + expect(body.data.expiresAt).toBe("2030-01-01T00:00:00.000Z"); + }); +}); diff --git a/src/app/api/auth/claim/preview/route.ts b/src/app/api/auth/claim/preview/route.ts index 10b20afca..865f7fdfb 100644 --- a/src/app/api/auth/claim/preview/route.ts +++ b/src/app/api/auth/claim/preview/route.ts @@ -1,11 +1,50 @@ /** * `POST /api/auth/claim/preview` — what a handover link would hand over: the - * profile's display name and each guardian's proposal (v1.42, #959). - * Anonymous, token in the body, the same uniform 404 as the claim itself. + * profile's display name, the link's expiry and each Guardian's proposed + * access (v1.42, #959). No health data. * - * Contract stub: answers 501. + * Anonymous by design: the person opening the link has no credentials yet. + * The one-time `hlp_` token travels in the BODY, never in this request's URL, + * so it never reaches an access log or a wide event's `http.path`. Every + * unusable link — malformed, unknown, expired, used, withdrawn, a profile no + * longer managed, a creator who lost access — answers the same 404. */ -import { apiHandler } from "@/lib/api-handler"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { NextRequest } from "next/server"; -export const POST = apiHandler(async () => notImplemented()); +import { apiHandler } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { getSession } from "@/lib/auth/session"; +import { annotate } from "@/lib/logging/context"; +import { claimGate, claimInvalid } from "@/lib/managed-profiles/claim-gate"; +import { previewHandover } from "@/lib/managed-profiles/handover"; +import { claimPreviewSchema } from "@/lib/validations/managed-profile-handover"; + +export const POST = apiHandler(async (request: NextRequest) => { + // Refuses to preview over a live session — see `claimGate`. + const gate = await claimGate( + request, + "preview", + (await getSession()) !== null, + ); + if (gate.refusal) return gate.refusal; + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + const parsed = claimPreviewSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + const preview = await previewHandover(parsed.data.token); + annotate({ + action: { name: "profile_claim.preview" }, + meta: { found: preview !== null }, + }); + if (!preview) return claimInvalid(); + + return apiSuccess({ + displayName: preview.displayName, + expiresAt: preview.expiresAt.toISOString(), + guardians: preview.guardians, + }); +}); diff --git a/src/app/api/auth/claim/route.ts b/src/app/api/auth/claim/route.ts index 469d1257f..d432ce20b 100644 --- a/src/app/api/auth/claim/route.ts +++ b/src/app/api/auth/claim/route.ts @@ -1,12 +1,155 @@ /** * `POST /api/auth/claim` — claim a managed profile through its handover link - * (v1.42, #959). Anonymous by design: the person claiming has no credentials - * yet. The one-time `hlp_` token travels in the body, never in the URL of - * this request, and every failure answers the same 404. + * (v1.42, #959). The person the profile describes sets a username, an email + * and a password, and the profile becomes their own account. No data moves. * - * Contract stub: answers 501. + * Anonymous by design: the claimant has no credentials yet, and the one-time + * `hlp_` token in the BODY is what authorises the request. The order of the + * work is the registration route's, for the same reasons: + * + * 1. refusals that read no token (`claimGate`: SSO-only, live session, rate + * limit); + * 2. the body, strictly parsed, and the link checked; + * 3. the username and email collision probe, before any expensive work; + * 4. password strength, the breach corpus, the Argon2id hash — outside the + * lock, because none of it depends on the profile; + * 5. the claim itself, one transaction under the managed-profile lock + * (`claimManagedProfile`), where the token is consumed; + * 6. after the commit: the session, the device record, and a notification + * to each former Guardian on their own channels. + * + * A closed registration does not block a claim: no account is created, the + * link is the door. */ -import { apiHandler } from "@/lib/api-handler"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { NextRequest } from "next/server"; -export const POST = apiHandler(async () => notImplemented()); +import { apiHandler } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { recordSignInDevice } from "@/lib/auth/login-alert"; +import { checkPasswordStrength, hashPassword } from "@/lib/auth/password"; +import { createSession, getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { annotate } from "@/lib/logging/context"; +import { claimGate, claimInvalid } from "@/lib/managed-profiles/claim-gate"; +import { + claimManagedProfile, + HandoverError, + previewHandover, +} from "@/lib/managed-profiles/handover"; +import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify"; +import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check"; +import { claimSchema } from "@/lib/validations/managed-profile-handover"; + +const taken = () => + apiError("Username or email already taken", 409, { + errorCode: "profile_claim.taken", + }); + +export const POST = apiHandler(async (request: NextRequest) => { + // Refuses to claim over a live session — see `claimGate`. + const gate = await claimGate(request, "claim", (await getSession()) !== null); + if (gate.refusal) return gate.refusal; + const ip = gate.ip ?? "unknown"; + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + const parsed = claimSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + const { token, username, email, password } = parsed.data; + + // The link first. Without this the collision probe below would answer 409 + // to anybody, token or not, and the claim route would be a username and + // email oracle that registration being closed does not close. With it, only + // the holder of a live link learns that a name is taken. The claim re-checks + // everything under the lock; this read decides nothing on its own. + if (!(await previewHandover(token))) return claimInvalid(); + + // Before the strength check, the breach lookup and the hash, exactly as the + // registration route orders it (its comment carries the timing argument). + // The profile's own row never collides here: its username is the generated + // `managed-…` one, which no chosen name may start with, and it has no email. + const [existingEmail, existingUsername] = await Promise.all([ + prisma.user.findUnique({ where: { email }, select: { id: true } }), + prisma.user.findUnique({ where: { username }, select: { id: true } }), + ]); + if (existingEmail || existingUsername) return taken(); + + const locale = await resolveServerLocale({ request }); + const strength = checkPasswordStrength(password, [username, email], locale); + if (!strength.isAcceptable) { + return apiError( + strength.feedback[0] || "Password too weak (score < 3)", + 422, + ); + } + const breach = await checkPasswordBreachIfEnabled(password); + if (breach?.breached) { + return apiError( + getServerTranslator(locale).t("auth.passwordBreached"), + 422, + ); + } + const passwordHash = await hashPassword(password); + + let claimed; + try { + claimed = await claimManagedProfile({ + rawToken: token, + username, + email, + passwordHash, + ipAddress: ip, + }); + } catch (error) { + if (error instanceof HandoverError) { + annotate({ + action: { name: "profile_claim.refused" }, + meta: { reason: error.code }, + }); + return error.code === "taken" ? taken() : claimInvalid(); + } + throw error; + } + + // The new owner starts with onboarding owed: the claim reset it, because + // the disclaimer and the setup were a Guardian's answers, not theirs. + const ua = request.headers.get("user-agent"); + await createSession(claimed.profileId, true, ip, ua); + void recordSignInDevice({ + userId: claimed.profileId, + ip, + userAgent: ua, + alertOnNew: false, + }); + await auditLog("auth.claim", { + userId: claimed.profileId, + actorUserId: null, + ipAddress: ip, + details: { method: "password" }, + }); + + notifyGuardiansOfHandover( + "claimed", + claimed.displayName ?? claimed.username, + claimed.guardians, + ); + + annotate({ + action: { name: "profile_claim.complete" }, + meta: { guardians: claimed.guardians.length }, + }); + return apiSuccess( + { userId: claimed.profileId, username: claimed.username }, + 201, + ); +}); diff --git a/src/app/api/managed-profiles/[id]/handover/route.ts b/src/app/api/managed-profiles/[id]/handover/route.ts index f4862628e..3c702f200 100644 --- a/src/app/api/managed-profiles/[id]/handover/route.ts +++ b/src/app/api/managed-profiles/[id]/handover/route.ts @@ -1,21 +1,186 @@ /** - * `POST /api/managed-profiles/[id]/handover` — mint the one-time link that - * hands a managed profile over to the person it describes, with a proposal - * per guardian; `DELETE` withdraws the open link (v1.42, #959). Cookie-only - * like every act on a managed profile; minting is step-up gated, withdrawing - * is not. + * `/api/managed-profiles/[id]/handover` — the Guardian's side of handing a + * managed profile over to the person it describes (v1.42, #959). * - * Contract stub: authenticates as the finished route will, then answers 501. + * - `GET` reads whether a link is open, never the link itself (only its hash + * exists). Cookie-only, no step-up: it discloses to a Guardian what they + * already administer. + * - `POST` mints the one-time link with a proposal per Guardian. Step-up + * gated like every act that changes what a managed profile is + * (`requireFreshMfa`), and rate-limited like its siblings. + * - `DELETE` withdraws the open link. Cookie-only and NOT step-up gated: + * taking a link back must never be harder than handing it out. + * + * All three answer the same 404 for "no such profile", "not managed" and "not + * a Guardian of it", so the route is not an enumeration oracle. Bearer tokens + * cannot reach any of them: every gate here resolves the cookie session. */ -import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { NextRequest } from "next/server"; -export const POST = apiHandler(async () => { - await requireCookieAuth(); - return notImplemented(); -}); +import { + apiHandler, + MFA_STEP_UP_MAX_AGE_SECONDS, + requireCookieAuth, + requireFreshMfa, +} from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { buildHandoverUrl } from "@/lib/auth/handover-token"; +import { isOidcOnly } from "@/lib/auth/oidc"; +import { annotate } from "@/lib/logging/context"; +import { + createHandover, + HandoverError, + readHandoverStatus, + revokeHandover, +} from "@/lib/managed-profiles/handover"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { createHandoverSchema } from "@/lib/validations/managed-profile-handover"; -export const DELETE = apiHandler(async () => { - await requireCookieAuth(); - return notImplemented(); -}); +type RouteParams = { params: Promise<{ id: string }> }; + +const notFound = () => + apiError("Managed profile not found", 404, { + errorCode: "managed_profile.not_found", + }); + +/** + * v1.42 refuses the handover on a single-sign-on-only instance: the claim + * sets a password, and such an instance admits none. The SSO arm of the claim + * is a separate piece of work. + */ +const oidcOnlyRefusal = () => + apiError( + "Handing over a profile is not available while this server allows single sign-on only", + 403, + { errorCode: "profile_claim.oidc_only_unsupported" }, + ); + +/** Same ceiling as creating, editing and inviting: ten an hour per caller. */ +const CREATE_LIMIT = 10; +const CREATE_WINDOW_MS = 60 * 60 * 1000; + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireCookieAuth(); + const { id } = await params; + + const status = await readHandoverStatus({ + profileId: id, + guardianId: user.id, + }); + if (!status) return notFound(); + + annotate({ + action: { name: "managed_profile.handover.read" }, + meta: { profile_id: id, open: status.open !== null }, + }); + return apiSuccess({ + available: !isOidcOnly(), + open: status.open + ? { + id: status.open.id, + createdAt: status.open.createdAt.toISOString(), + expiresAt: status.open.expiresAt.toISOString(), + createdByYou: status.open.createdByCaller, + } + : null, + }); + }, +); + +export const POST = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS); + // Before the id is read and before any lock is taken, as the siblings do: + // the service locks on the id it is handed before deciding the caller is + // not a Guardian of it. + const rateLimit = await checkRateLimit( + `managed-profile:handover:${user.id}`, + CREATE_LIMIT, + CREATE_WINDOW_MS, + ); + if (!rateLimit.allowed) { + return apiError("Too many handover links, try again later", 429); + } + if (isOidcOnly()) return oidcOnlyRefusal(); + const { id } = await params; + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + const parsed = createHandoverSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + let created; + try { + created = await createHandover({ + profileId: id, + guardianId: user.id, + expiresInDays: parsed.data.expiresInDays, + proposals: parsed.data.proposals.map((p) => ({ + grantId: p.grantId, + proposal: p.proposal, + })), + }); + } catch (error) { + if (error instanceof HandoverError) { + if (error.code === "unknown_guardian") { + return apiError( + "A proposal names somebody who is not a Guardian", + 422, + { + errorCode: "managed_profile.handover.unknown_guardian", + }, + ); + } + return notFound(); + } + throw error; + } + + annotate({ + action: { name: "managed_profile.handover.create" }, + meta: { + profile_id: id, + expires_in_days: parsed.data.expiresInDays, + proposals: parsed.data.proposals.length, + }, + }); + return apiSuccess( + { + id: created.id, + token: created.token, + url: buildHandoverUrl(created.token, request.url), + expiresAt: created.expiresAt.toISOString(), + }, + 201, + ); + }, +); + +export const DELETE = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireCookieAuth(); + const { id } = await params; + + let result; + try { + result = await revokeHandover({ profileId: id, guardianId: user.id }); + } catch (error) { + if (error instanceof HandoverError) return notFound(); + throw error; + } + + annotate({ + action: { name: "managed_profile.handover.revoke" }, + meta: { profile_id: id, revoked: result.revoked }, + }); + return apiSuccess(result); + }, +); diff --git a/src/app/auth/claim/page.tsx b/src/app/auth/claim/page.tsx new file mode 100644 index 000000000..217302b9c --- /dev/null +++ b/src/app/auth/claim/page.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; + +import { ClaimProfile } from "@/components/auth/claim-profile"; + +/** + * v1.42 (#959) — `/auth/claim?token=hlp_…`, where the link a Guardian handed + * over (`/claim/`) lands after the edge redirect. + * + * A URL-credential surface by shape (`session-surface-guard.test.ts`, S2): the + * page reads the one-time token off the query string and passes it to the + * form, which sends it only in the BODY of the anonymous preview and claim + * requests. Nothing here resolves the token; those two routes do, behind one + * uniform 404 and a per-address rate limit. + */ +export default function ClaimProfilePage() { + const searchParams = useSearchParams(); + return ; +} diff --git a/src/app/claim/[token]/page.tsx b/src/app/claim/[token]/page.tsx new file mode 100644 index 000000000..a1fa4fff6 --- /dev/null +++ b/src/app/claim/[token]/page.tsx @@ -0,0 +1,34 @@ +import { redirect } from "next/navigation"; + +import { looksLikeHandoverToken } from "@/lib/auth/handover-token"; + +/** + * v1.42 (#959) — the managed-profile handover link landing. + * + * `https:///claim/` is the link a Guardian hands over. The + * proxy answers it at the edge with a 307 onto `/auth/claim?token=…` + * (`src/proxy.ts`), so in practice this page never renders; it stays as the + * fallback for any path that bypasses the proxy matcher, exactly like + * `src/app/invite/[token]/page.tsx`. + * + * Security, the invite landing's posture: + * - the `hlp_` shape is validated before the token is reflected into the + * redirect target, so a malformed segment lands on the bare claim page + * and the page never echoes attacker-controlled text; + * - no database read: a well-formed unknown token and a valid one redirect + * identically. Whether a token is real is only decided by the anonymous + * preview and claim routes, behind their uniform 404 and rate limit; + * - the token is never logged here (and `/claim/` is a registered secret + * path in `src/lib/logging/redact.ts`). + */ +export default async function ClaimLandingPage({ + params, +}: { + params: Promise<{ token: string }>; +}) { + const { token } = await params; + if (looksLikeHandoverToken(token)) { + redirect(`/auth/claim?token=${encodeURIComponent(token)}`); + } + redirect("/auth/claim"); +} diff --git a/src/app/onboarding/__tests__/page.test.tsx b/src/app/onboarding/__tests__/page.test.tsx index 64e546bd2..c9c8606fe 100644 --- a/src/app/onboarding/__tests__/page.test.tsx +++ b/src/app/onboarding/__tests__/page.test.tsx @@ -39,12 +39,19 @@ vi.mock("@/components/onboarding/onboarding-shell", () => ({ OnboardingShell: ({ children }: { children: ReactElement }) => children, })); +const pendingHandoverMock = vi.fn(async () => false); +vi.mock("@/lib/managed-profiles/handover", () => ({ + hasPendingHandoverDecision: () => pendingHandoverMock(), +})); + import OnboardingRootPage from "../page"; import { WelcomeScreen } from "@/components/onboarding/welcome-screen"; beforeEach(() => { redirectMock.mockClear(); loadMock.mockReset(); + pendingHandoverMock.mockReset(); + pendingHandoverMock.mockResolvedValue(false); }); function state( @@ -67,11 +74,15 @@ type ShellElement = ReactElement<{ children: ReactElement<{ variant: string }>; }>; -async function run(): Promise< - { redirect: string } | { element: ShellElement } -> { +async function run( + searchParams?: Record, +): Promise<{ redirect: string } | { element: ShellElement }> { try { - const element = (await OnboardingRootPage()) as ShellElement; + const element = (await OnboardingRootPage( + searchParams + ? { searchParams: Promise.resolve(searchParams) } + : undefined, + )) as ShellElement; return { element }; } catch (e) { const tagged = e as Error & { __redirect__?: string }; @@ -87,6 +98,27 @@ function welcomeVariant(element: ShellElement): string { } describe("", () => { + it("sends an account with a handover decision waiting to that screen first", async () => { + pendingHandoverMock.mockResolvedValue(true); + loadMock.mockResolvedValueOnce({ + userId: "u1", + userLocale: "en", + state: state(), + }); + expect(await run()).toEqual({ redirect: "/onboarding/handover" }); + }); + + it("lets the handover screen's own 'later' through to the welcome", async () => { + pendingHandoverMock.mockResolvedValue(true); + loadMock.mockResolvedValueOnce({ + userId: "u1", + userLocale: "en", + state: state(), + }); + const result = await run({ handover: "later" }); + expect("element" in result && welcomeVariant(result.element)).toBe("fresh"); + }); + it("redirects to /auth/login when there is no session", async () => { loadMock.mockResolvedValueOnce(null); expect(await run()).toEqual({ redirect: "/auth/login" }); diff --git a/src/app/onboarding/handover/page.tsx b/src/app/onboarding/handover/page.tsx new file mode 100644 index 000000000..7d29c8e9f --- /dev/null +++ b/src/app/onboarding/handover/page.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2 } from "lucide-react"; + +import { StepHeading } from "@/components/onboarding/step-heading"; +import { + HandoverDecisionForm, + HandoverNotificationHint, +} from "@/components/settings/access/handover-decision"; +import { Button } from "@/components/ui/button"; +import { Logo } from "@/components/ui/logo"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { useTranslations } from "@/lib/i18n/context"; +import { useHandoverDecision } from "@/lib/queries/use-handover-decision"; + +/** + * v1.42 (#959) — the first screen after claiming a managed profile. + * + * The claim applied the access each Guardian was proposed; here the new owner + * sees it and decides. It lives under `/onboarding/` because a claimed + * account starts with its setup owed and the proxy walls everything else off + * until that is done; the setup's front door sends a pending decision here + * first (`src/app/onboarding/page.tsx`). + * + * "Later" is honest: the proposal keeps holding, and the same decision waits + * at the top of Settings → Shared access until it is made. + */ +export default function HandoverDecisionPage() { + const { t } = useTranslations(); + const router = useRouter(); + const decision = useHandoverDecision(); + const pending = decision.data?.pending; + const nothingWaiting = decision.data !== undefined && pending === null; + + useEffect(() => { + if (nothingWaiting) router.replace("/onboarding"); + }, [nothingWaiting, router]); + + const continueSetup = (later: boolean) => + router.push(later ? "/onboarding?handover=later" : "/onboarding"); + + return ( +
+
+ +
+ + + + {decision.isError ? ( + void decision.refetch()} + /> + ) : !pending ? ( +

+

+ ) : ( + <> + + continueSetup(false)} + secondary={ + + } + /> + + )} +
+ ); +} diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx index 7acc07fe8..821a2029d 100644 --- a/src/app/onboarding/page.tsx +++ b/src/app/onboarding/page.tsx @@ -2,6 +2,7 @@ import { redirect } from "next/navigation"; import { OnboardingShell } from "@/components/onboarding/onboarding-shell"; import { WelcomeScreen } from "@/components/onboarding/welcome-screen"; +import { hasPendingHandoverDecision } from "@/lib/managed-profiles/handover"; import { loadOnboardingFlowState } from "@/lib/onboarding/load-flow-state"; import { isOnboardingSettled } from "@/lib/onboarding/needs"; import { resumeScreen } from "@/lib/onboarding/wizard-steps"; @@ -21,13 +22,24 @@ import { resumeScreen } from "@/lib/onboarding/wizard-steps"; * No session mirrors the proxy's own gate (`/onboarding` is a public path, * so the page has to say it too). */ -export default async function OnboardingRootPage() { +export default async function OnboardingRootPage(props?: { + searchParams?: Promise<{ handover?: string | string[] }>; +}) { const flow = await loadOnboardingFlowState(); if (!flow) { redirect("/auth/login"); } const { state, userLocale } = flow; + // v1.42 (#959) — an account that just claimed a managed profile decides its + // former Guardians' access first. `?handover=later` is that screen's own + // "later", which must not bounce straight back to it; the decision then + // waits in Settings → Shared access. + const handover = (await props?.searchParams)?.handover; + if (handover !== "later" && (await hasPendingHandoverDecision(flow.userId))) { + redirect("/onboarding/handover"); + } + const resume = resumeScreen(state); if (resume !== "welcome") { const finished = state.completedAt !== null && isOnboardingSettled(state); diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index 40b41064f..5335e3c6b 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -329,6 +329,8 @@ export function useAuthActionLabels(): Record { return useMemo( () => ({ "auth.register": t("admin.authRegister"), + // v1.42 (#959) — a managed profile taken over by its person. + "auth.claim": t("admin.authClaim"), "auth.login": t("admin.authLogin"), "auth.login.passkey": t("admin.authLoginPasskey"), "auth.login.password": t("admin.authLoginPassword"), diff --git a/src/components/auth/claim-profile.tsx b/src/components/auth/claim-profile.tsx new file mode 100644 index 000000000..3ddfb1589 --- /dev/null +++ b/src/components/auth/claim-profile.tsx @@ -0,0 +1,382 @@ +"use client"; + +import { useId, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; +import { KeyRound, Loader2, LogOut } from "lucide-react"; + +import { accessLabel } from "@/components/settings/access/managed-profile-handover"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Logo } from "@/components/ui/logo"; +import { PasswordStrength } from "@/components/ui/password-strength"; +import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth"; +import { ApiError, apiFetchRaw } from "@/lib/api/api-fetch"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { + useClaimProfile, + useProfileClaimPreview, +} from "@/lib/queries/use-profile-claim"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * v1.42 (#959) — the person a managed profile describes takes it over as + * their own account (`/auth/claim?token=hlp_…`). + * + * The page hands the token in from `?token=`; this sends it only in the BODY + * of the two anonymous requests behind it — the preview, then the claim — + * never in another URL, a query key or storage. + * + * What the person sees before choosing a sign-in is deliberately small: their + * record's name, who keeps which access for now, and until when the link + * holds. No health data. The decision about each Guardian comes right after + * the first sign-in, on the screen the claim lands on. + */ +export function ClaimProfile({ token }: { token: string | null }) { + const router = useRouter(); + const queryClient = useQueryClient(); + const { t } = useTranslations(); + const fmt = useFormatters(); + // A resolved account payload is the only positive evidence of a session; + // see the registration page for why `isAuthenticated` is not used here. + const { user } = useAuth(); + const preview = useProfileClaimPreview(token, !user); + const claim = useClaimProfile(); + + const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [signingOut, setSigningOut] = useState(false); + const errorId = useId(); + + const previewStatus = + preview.error instanceof ApiError ? preview.error.status : null; + const signedIn = Boolean(user) || previewStatus === 409; + const usernameReserved = username.toLowerCase().startsWith("managed-"); + + async function signOut() { + setSigningOut(true); + try { + await apiFetchRaw("/api/auth/logout", { method: "POST" }); + clearCachesForSessionEnd(queryClient); + // Back to this page with the link intact: the token only rides the URL. + router.replace( + token + ? `/auth/claim?token=${encodeURIComponent(token)}` + : "/auth/claim", + ); + router.refresh(); + } finally { + setSigningOut(false); + } + } + + function submit(event: React.FormEvent) { + event.preventDefault(); + if (!token || claim.isPending || usernameReserved) return; + claim.mutate( + { token, username, email, password }, + { + onSuccess: async () => { + queryClient.removeQueries({ + queryKey: queryKeys.profileClaimPreview(), + }); + await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); + router.push("/onboarding/handover"); + }, + }, + ); + } + + if (signedIn) { + return ( + +
+ + + ); + } + + if (!token || previewStatus === 404 || previewStatus === 422) { + return ( + +
+ + ); + } + + if (previewStatus === 403) { + return ( + +
+ + ); + } + + if (preview.isError) { + return ( + +
+ + + ); + } + + if (!preview.data) { + return ( + +

+

+
+ ); + } + + const data = preview.data; + const errorKey = claim.isError ? claimErrorKey(claim.error) : null; + const errorText = + claim.error instanceof ApiError && + claim.error.status === 422 && + claim.error.message + ? claim.error.message + : errorKey + ? t(errorKey) + : null; + + return ( + +
+ +
+

+ {t("auth.claim.guardiansTitle")} +

+ {data.guardians.length > 0 ? ( +
    + {data.guardians.map((guardian) => ( +
  • + + {guardian.displayName} + + + {accessLabel(t, guardian.proposal)} + +
  • + ))} +
+ ) : null} +

+ {t("auth.claim.guardiansHint")} +

+
+ +
+
+ + setUsername(e.target.value)} + required + minLength={3} + maxLength={30} + autoComplete="username" + autoCapitalize="none" + spellCheck={false} + aria-invalid={usernameReserved || undefined} + aria-describedby={`${errorId}-username`} + /> +

+ {usernameReserved + ? t("auth.claim.usernameReserved") + : t("auth.claim.usernameHint")} +

+
+
+ + setEmail(e.target.value)} + required + autoComplete="email" + autoCapitalize="none" + spellCheck={false} + aria-describedby={`${errorId}-email`} + /> +

+ {t("auth.claim.emailHint")} +

+
+
+ + setPassword(e.target.value)} + required + autoComplete="new-password" + placeholder={t("auth.passwordMinLength")} + /> + +
+ +

{t("auth.claim.consentNote")}

+

+ {t("auth.claim.expires", { + date: fmt.dateTime(new Date(data.expiresAt)), + })} +

+ + {errorText && ( + + )} + + +
+ + ); +} + +function ClaimCard({ + testId, + children, +}: { + testId: string; + children: React.ReactNode; +}) { + return ( +
+
+ {children} +
+
+ ); +} + +function Header({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( +
+
+ +
+

{title}

+

{description}

+
+ ); +} + +/** The message for a refused claim. Exported and pure so it can be pinned. */ +export function claimErrorKey(err: unknown): string { + if (!(err instanceof ApiError)) return "auth.claim.errorOffline"; + if (err.status === 404) return "auth.claim.invalid"; + if (err.status === 409) { + return err.meta?.errorCode === "auth.already_authenticated" + ? "auth.claim.signedInTitle" + : "auth.claim.errorTaken"; + } + if (err.status === 403) return "auth.claim.oidcOnly"; + if (err.status === 429) return "auth.claim.errorRateLimit"; + return "auth.claim.errorFailed"; +} diff --git a/src/components/layout/__tests__/auth-shell-public-paths.test.tsx b/src/components/layout/__tests__/auth-shell-public-paths.test.tsx index 98ff62794..b3b62810e 100644 --- a/src/components/layout/__tests__/auth-shell-public-paths.test.tsx +++ b/src/components/layout/__tests__/auth-shell-public-paths.test.tsx @@ -136,6 +136,7 @@ describe("AuthShell public paths agree with proxy.ts", () => { it("has page prefixes to check at all", () => { expect(PAGE_PREFIXES.length).toBeGreaterThan(0); expect(PAGE_PREFIXES).toContain("/invite/"); + expect(PAGE_PREFIXES).toContain("/claim/"); }); it.each(PAGE_PREFIXES)( @@ -152,6 +153,14 @@ describe("AuthShell public paths agree with proxy.ts", () => { expect(markup).toContain('data-slot="sentinel-page"'); }); + it("renders the handover landing and the claim page for a real token", () => { + // v1.42 (#959) — the managed-profile handover link and the page the edge + // redirects it onto. + const token = `hlp_${"b".repeat(64)}`; + expect(render(`/claim/${token}`)).toContain('data-slot="sentinel-page"'); + expect(render("/auth/claim")).toContain('data-slot="sentinel-page"'); + }); + it("still gates a protected route", () => { // Negative check — confirms the fixture would catch a real regression. const markup = render("/insights"); diff --git a/src/components/settings/access-section.tsx b/src/components/settings/access-section.tsx index bd2770182..c84b10dfe 100644 --- a/src/components/settings/access-section.tsx +++ b/src/components/settings/access-section.tsx @@ -1,6 +1,7 @@ "use client"; import { GrantInviteCard } from "@/components/settings/access/grant-invite-card"; +import { HandoverDecisionCard } from "@/components/settings/access/handover-decision"; import { GrantsGivenCard } from "@/components/settings/access/grants-given-card"; import { GrantsReceivedCard } from "@/components/settings/access/grants-received-card"; import { ManagedProfileCard } from "@/components/settings/access/managed-profile-card"; @@ -44,6 +45,10 @@ import { RecordActivityCard } from "@/components/settings/access/record-activity export function AccessSection() { return (
+ {/* v1.42 (#959) — only for an account that claimed a managed profile + and has not decided its former Guardians' access yet; it comes first + because it is the one open question on this page. */} + diff --git a/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx b/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx index 543561532..008ada8b1 100644 --- a/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx +++ b/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx @@ -141,6 +141,13 @@ vi.mock("@/lib/queries/use-managed-profiles", async (importOriginal) => { }; }); +// v1.42 (#959) — the handover decision card at the top of the section. It +// renders nothing without a pending decision, which is every case here. +vi.mock("@/lib/queries/use-handover-decision", () => ({ + useHandoverDecision: () => ({ data: { pending: null } }), + useDecideHandover: () => idle(), +})); + vi.mock("@/hooks/use-account-switch", () => ({ useAccountSwitch: () => ({ mutate: vi.fn(), isPending: false }), })); @@ -520,6 +527,24 @@ describe("ending a managed profile", () => { ); }); + // v1.42 (#959) — the handover, offered beside edit and delete. + it("offers the handover on the profile's own row once the roster answered", () => { + authRef.value = { accounts: [entry()], active: null }; + const html = renderGatedSection(); + expect(html).toMatch( + /data-slot="managed-profile-handover-toggle"[^>]*class="[^"]*min-h-11/, + ); + // Closed until somebody opens it: the panel reads its own status then. + expect(html).not.toContain('data-slot="managed-profile-handover"'); + }); + + it("withholds the handover until the roster has answered", () => { + authRef.value = { accounts: [entry()], active: null }; + rosterRef.value = "loading"; + const html = renderGatedSection(); + expect(html).not.toContain('data-slot="managed-profile-handover-toggle"'); + }); + it("withholds the control until the roster has answered", () => { // The confirm copy states how many people lose the record, and that number // comes from the roster. A control that existed before the read would put diff --git a/src/components/settings/access/__tests__/managed-profile-handover-errors.test.ts b/src/components/settings/access/__tests__/managed-profile-handover-errors.test.ts new file mode 100644 index 000000000..b4e4b479a --- /dev/null +++ b/src/components/settings/access/__tests__/managed-profile-handover-errors.test.ts @@ -0,0 +1,61 @@ +/** + * v1.42 (#959) — the sentences a refused handover mint and a refused claim + * map to. Pure, so each arm is pinned without driving a browser. + */ +import { describe, expect, it } from "vitest"; + +import { handoverCreateErrorKey } from "@/components/settings/access/managed-profile-handover"; +import { claimErrorKey } from "@/components/auth/claim-profile"; +import { ApiError } from "@/lib/api/api-fetch"; + +describe("handoverCreateErrorKey", () => { + it.each([ + [ + new ApiError("x", 401, { errorCode: "auth.stepup.required" }), + "recordSharing.managed.errorStepUp", + ], + [ + new ApiError("x", 401, { errorCode: "auth.stepup.mfa_not_enrolled" }), + "recordSharing.managed.errorMfaRequired", + ], + [ + new ApiError("x", 403, { + errorCode: "profile_claim.oidc_only_unsupported", + }), + "recordSharing.managed.handover.oidcOnly", + ], + [new ApiError("x", 404, {}), "recordSharing.actionError.profileNotFound"], + [ + new ApiError("x", 422, { + errorCode: "managed_profile.handover.unknown_guardian", + }), + "recordSharing.managed.handover.errorRosterChanged", + ], + [new ApiError("x", 422, {}), "recordSharing.managed.errorInvalid"], + [new ApiError("x", 429, {}), "recordSharing.managed.errorRateLimit"], + [new ApiError("x", 500, {}), "recordSharing.managed.handover.errorFailed"], + [new Error("offline"), "recordSharing.managed.errorOffline"], + ])("maps %o", (err, key) => { + expect(handoverCreateErrorKey(err)).toBe(key); + }); +}); + +describe("claimErrorKey", () => { + it.each([ + [new ApiError("x", 404, {}), "auth.claim.invalid"], + [ + new ApiError("x", 409, { errorCode: "profile_claim.taken" }), + "auth.claim.errorTaken", + ], + [ + new ApiError("x", 409, { errorCode: "auth.already_authenticated" }), + "auth.claim.signedInTitle", + ], + [new ApiError("x", 403, {}), "auth.claim.oidcOnly"], + [new ApiError("x", 429, {}), "auth.claim.errorRateLimit"], + [new ApiError("x", 500, {}), "auth.claim.errorFailed"], + [new Error("offline"), "auth.claim.errorOffline"], + ])("maps %o", (err, key) => { + expect(claimErrorKey(err)).toBe(key); + }); +}); diff --git a/src/components/settings/access/handover-decision.tsx b/src/components/settings/access/handover-decision.tsx new file mode 100644 index 000000000..0dc356357 --- /dev/null +++ b/src/components/settings/access/handover-decision.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { useState } from "react"; +import { BellRing, UserRoundCheck } from "lucide-react"; +import Link from "next/link"; + +import { accessLabel } from "@/components/settings/access/managed-profile-handover"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { ApiError } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { + OWNER_CHOOSABLE_HANDOVER_ACCESS, + type HandoverAccess, +} from "@/lib/managed-profiles/handover-access"; +import { + useDecideHandover, + useHandoverDecision, + type HandoverDecisionState, +} from "@/lib/queries/use-handover-decision"; + +type PendingDecision = NonNullable; + +/** + * v1.42 (#959) — the new owner's decision about each former Guardian. + * + * The claim already applied what the issuing Guardian proposed; this is where + * the person whose record it is sees that and has the final word. Each row is + * a Guardian, preselected at the access they hold now. A row whose access + * moved since the claim (the Guardian stepped away, or the owner already + * changed it under Shared access) is shown and left alone. + * + * Rendered in two places from one form: the screen the claim lands on, and a + * card at the top of Settings → Shared access that stays until the decision + * is made. + */ +export function HandoverDecisionForm({ + pending, + onDecided, + secondary, +}: { + pending: PendingDecision; + onDecided?: () => void; + /** An extra action before the primary one (the setup screen's "later"). */ + secondary?: React.ReactNode; +}) { + const { t } = useTranslations(); + const decide = useDecideHandover(); + const [choices, setChoices] = useState>({}); + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + if (decide.isPending) return; + decide.mutate( + pending.guardians + .filter((g) => g.decidable) + .map((g) => ({ + grantId: g.grantId, + access: choices[g.grantId] ?? g.current, + })), + { onSuccess: () => onDecided?.() }, + ); + }; + + return ( +
+ {pending.guardians.length === 0 ? ( +

{t("recordSharing.handoverDecision.empty")}

+ ) : ( +
    + {pending.guardians.map((guardian) => { + const id = `handover-decision-${guardian.grantId}`; + return ( +
  • +
    + +

    + {guardian.decidable + ? t("recordSharing.handoverDecision.proposed", { + access: accessLabel(t, guardian.proposal), + }) + : t("recordSharing.handoverDecision.settled")} +

    +
    + {guardian.decidable ? ( + + setChoices((current) => ({ + ...current, + [guardian.grantId]: e.target.value as HandoverAccess, + })) + } + > + {OWNER_CHOOSABLE_HANDOVER_ACCESS.map((level) => ( + + ))} + + ) : ( +

    + {accessLabel(t, guardian.current)} +

    + )} +
  • + ); + })} +
+ )} + +

{t("recordSharing.handoverDecision.later")}

+ + {decide.isError && ( +

+ {t( + decide.error instanceof ApiError + ? "recordSharing.handoverDecision.errorFailed" + : "recordSharing.managed.errorOffline", + )} +

+ )} + + + {secondary} + + +
+ ); +} + +/** + * The one-time pointer to the new owner's own notification channels. + * + * Reminders for a managed profile went to its Guardians. The claim ends that + * fan-out, and the record has no channel of its own yet, so without this a + * medication reminder would quietly stop reaching anybody. + */ +export function HandoverNotificationHint() { + const { t } = useTranslations(); + return ( +
+
+ ); +} + +/** + * Settings → Shared access: the decision, until it is made. Renders nothing + * for every account that has none waiting, which is nearly all of them. + */ +export function HandoverDecisionCard() { + const { t } = useTranslations(); + const decision = useHandoverDecision(); + const pending = decision.data?.pending; + if (!pending) return null; + + return ( + + + + + + ); +} diff --git a/src/components/settings/access/managed-profile-card.tsx b/src/components/settings/access/managed-profile-card.tsx index 1af38733f..932898daf 100644 --- a/src/components/settings/access/managed-profile-card.tsx +++ b/src/components/settings/access/managed-profile-card.tsx @@ -10,6 +10,7 @@ import { import { ManagedProfileCreateForm } from "@/components/settings/access/managed-profile-create-form"; import { ManagedProfileEditForm } from "@/components/settings/access/managed-profile-edit-form"; import { ManagedProfileGuardians } from "@/components/settings/access/managed-profile-guardians"; +import { ManagedProfileHandover } from "@/components/settings/access/managed-profile-handover"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { Button } from "@/components/ui/button"; @@ -134,6 +135,9 @@ function ManagedProfileRow({ // opens, so one shared flag would put every row's form on the wire the // moment any of them was opened. const [editing, setEditing] = useState(false); + // v1.42 (#959) — the handover panel, per row for the same reason: it reads + // the row's own handover status when it opens, and only then. + const [handingOver, setHandingOver] = useState(false); return (
  • )} + {/* v1.42 (#959) — the handover, last in the row and on a line of its + own: it is the weightiest and rarest act on a profile, and the row's + action strip beside the name has no room for a third control at + 390 px. Withheld until the roster answers, like the deletion: the + panel proposes an access level per Guardian and has nothing to + propose for before the roster is in hand. */} + {guardians.data && ( +
    +
    +
    +

    + {t("recordSharing.managed.handover.title", { name })} +

    +

    + {t("recordSharing.managed.handover.entryHint", { name })} +

    +
    + +
    + {handingOver && ( + setHandingOver(false)} + /> + )} +
    + )}
  • ); } diff --git a/src/components/settings/access/managed-profile-handover.tsx b/src/components/settings/access/managed-profile-handover.tsx new file mode 100644 index 000000000..934ed319e --- /dev/null +++ b/src/components/settings/access/managed-profile-handover.tsx @@ -0,0 +1,513 @@ +"use client"; + +import { useState } from "react"; +import { Check, Copy, Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { stepUpErrorKey } from "@/components/settings/access/grant-action-error"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { useAuth } from "@/hooks/use-auth"; +import { ApiError } from "@/lib/api/api-fetch"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { + DEFAULT_HANDOVER_ACCESS, + PROPOSABLE_HANDOVER_ACCESS, + isHandoverMinor, + type HandoverAccess, +} from "@/lib/managed-profiles/handover-access"; +import { + useCreateManagedProfileHandover, + useManagedProfile, + useManagedProfileHandover, + useRevokeManagedProfileHandover, + type ManagedProfileGuardian, +} from "@/lib/queries/use-managed-profiles"; +import { accountLabel } from "@/lib/sharing/account-access-view"; + +/** + * v1.42 (#959) — handing a managed profile over to the person it describes, + * from the Guardian's side. + * + * Three states, one panel: + * + * 1. **Propose.** How long the link lives and, per active Guardian, what + * their access becomes. The irreversibility is said here, before the + * link exists, because it is the last moment the Guardian decides + * anything: once the person claims the profile, it is theirs. + * 2. **The link, once.** Shown in the response that minted it and never + * again (only its hash is stored): the URL, a copy button and a QR code + * to scan from the Guardian's screen. Nothing is mailed; the Guardian + * hands it over in person or through a channel they trust. + * 3. **Open.** Afterwards the panel shows that a link is open and until + * when, with the way to withdraw it. Making a new link withdraws the old. + * + * The rules of which levels exist and which one is the default live in + * `handover-access.ts`; this panel only renders them. + */ +export function ManagedProfileHandover({ + profileId, + profileName, + roster, + onClose, +}: { + profileId: string; + profileName: string; + /** The resolved roster; the row withholds this panel until it arrives. */ + roster: ManagedProfileGuardian[]; + onClose: () => void; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const status = useManagedProfileHandover(profileId); + const create = useCreateManagedProfileHandover(); + const revoke = useRevokeManagedProfileHandover(); + const [replacing, setReplacing] = useState(false); + + const minted = + create.data && create.variables?.profileId === profileId + ? create.data + : null; + + if (status.isError) { + return ( + void status.refetch()} + /> + ); + } + if (!status.data) { + return ( +

    +

    + ); + } + + return ( +
    +

    + {t("recordSharing.managed.handover.description", { + name: profileName, + })} +

    + + {!status.data.available ? ( + <> +

    + {t("recordSharing.managed.handover.oidcOnly")} +

    + + + + + ) : minted ? ( + { + create.reset(); + setReplacing(false); + onClose(); + }} + /> + ) : status.data.open && !replacing ? ( +
    +

    + {t("recordSharing.managed.handover.pending", { + date: fmt.dateTime(new Date(status.data.open.expiresAt)), + })} +

    + {revoke.isError && revoke.variables === profileId && ( +

    + {t("recordSharing.managed.handover.revokeFailed")} +

    + )} + + + + +
    + ) : ( + { + setReplacing(false); + if (!status.data?.open) onClose(); + }} + /> + )} +
    + ); +} + +function HandoverProposalForm({ + profileId, + profileName, + roster, + replacing, + create, + onCancel, +}: { + profileId: string; + profileName: string; + roster: ManagedProfileGuardian[]; + replacing: boolean; + create: ReturnType; + onCancel: () => void; +}) { + const { t } = useTranslations(); + const { user } = useAuth(); + const profile = useManagedProfile(profileId); + const [expiresInDays, setExpiresInDays] = useState<1 | 7 | 14>(7); + const [proposals, setProposals] = useState>( + {}, + ); + + const active = roster.filter((g) => g.state === "ACTIVE"); + const minor = isHandoverMinor(profile.data?.dateOfBirth ?? null); + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + if (create.isPending) return; + create.mutate({ + profileId, + expiresInDays, + proposals: active.map((g) => ({ + grantId: g.grantId, + proposal: proposals[g.grantId] ?? DEFAULT_HANDOVER_ACCESS, + })), + }); + }; + + return ( +
    +
    + + {t("recordSharing.managed.handover.guardiansLabel")} + +

    + {t("recordSharing.managed.handover.guardiansHint", { + name: profileName, + })} +

    +
      + {active.map((guardian) => { + const id = `handover-proposal-${profileId}-${guardian.grantId}`; + const isSelf = guardian.account.id === user?.id; + return ( +
    • + + + setProposals((current) => ({ + ...current, + [guardian.grantId]: e.target.value as HandoverAccess, + })) + } + > + {PROPOSABLE_HANDOVER_ACCESS.map((level) => ( + + ))} + +
    • + ); + })} +
    +
    + +
    + + + setExpiresInDays(Number(e.target.value) as 1 | 7 | 14) + } + > + + + + +
    + + {minor && ( +

    + {t("recordSharing.managed.handover.minorHint", { name: profileName })} +

    + )} + +
    +

    + {t("recordSharing.managed.handover.irreversible", { + name: profileName, + })} +

    +

    + {t("recordSharing.managed.handover.afterwards", { + name: profileName, + })} +

    + {replacing &&

    {t("recordSharing.managed.handover.replaceNote")}

    } +
    + + {create.isError && ( +

    + {t(handoverCreateErrorKey(create.error))} +

    + )} + + + + + +
    + ); +} + +function HandoverLink({ + link, + profileName, + expiresLabel, + onDone, +}: { + link: { url: string; qrDataUrl: string }; + profileName: string; + expiresLabel: string; + onDone: () => void; +}) { + const { t } = useTranslations(); + const [copied, setCopied] = useState(false); + + async function copy() { + try { + await navigator.clipboard.writeText(link.url); + setCopied(true); + toastWrittenOutcome( + "success", + t("recordSharing.managed.handover.copied"), + ); + window.setTimeout(() => setCopied(false), 2_000); + } catch { + toast.error(t("recordSharing.managed.handover.copyError")); + } + } + + return ( +
    +

    + {t("recordSharing.managed.handover.linkOnce", { name: profileName })} +

    +
    + {/* The QR quiet zone stays white in both themes: a scanner needs the + contrast, and UI-STANDARDS §4 names this exemption. */} + {/* eslint-disable-next-line @next/next/no-img-element -- a data: URL minted client-side; nothing for the image optimiser to do */} + {t("recordSharing.managed.handover.qrAlt", +
    + +
    + e.currentTarget.select()} + /> + +
    +

    + {t("recordSharing.managed.handover.linkExpires", { + date: expiresLabel, + })} +

    +
    +
    + + + +
    + ); +} + +/** The label of a handover level, as literal keys the i18n guards can see. */ +export function accessLabel( + t: (key: string, params?: Record) => string, + access: HandoverAccess, +): string { + switch (access) { + case "end": + return t("recordSharing.managed.handover.access.end"); + case "read": + return t("recordSharing.managed.handover.access.read"); + case "manage": + return t("recordSharing.managed.handover.access.manage"); + } +} + +/** The message for a refused mint. Exported and pure so it can be pinned. */ +export function handoverCreateErrorKey(err: unknown): string { + if (!(err instanceof ApiError)) return "recordSharing.managed.errorOffline"; + if (err.status === 401) return stepUpErrorKey(err); + if (err.status === 403) return "recordSharing.managed.handover.oidcOnly"; + if (err.status === 404) return "recordSharing.actionError.profileNotFound"; + if (err.status === 422) { + return err.meta?.errorCode === "managed_profile.handover.unknown_guardian" + ? "recordSharing.managed.handover.errorRosterChanged" + : "recordSharing.managed.errorInvalid"; + } + if (err.status === 429) return "recordSharing.managed.errorRateLimit"; + return "recordSharing.managed.handover.errorFailed"; +} diff --git a/src/components/settings/security-activity-card.tsx b/src/components/settings/security-activity-card.tsx index 1091e9fc7..7395a0a2e 100644 --- a/src/components/settings/security-activity-card.tsx +++ b/src/components/settings/security-activity-card.tsx @@ -35,7 +35,12 @@ function actionLabelKey(action: string): string { return "settings.security.action.newDevice"; if (action === "auth.login.failed") return "settings.security.action.loginFailed"; - if (action.startsWith("auth.login") || action === "auth.register") + // v1.42 (#959) — claiming a managed profile is the account's first sign-in. + if ( + action.startsWith("auth.login") || + action === "auth.register" || + action === "auth.claim" + ) return "settings.security.action.login"; if (action.startsWith("auth.password")) return "settings.security.action.passwordChange"; diff --git a/src/lib/__tests__/secret-shape.test.ts b/src/lib/__tests__/secret-shape.test.ts index 3dfe578b8..b3c32b5fb 100644 --- a/src/lib/__tests__/secret-shape.test.ts +++ b/src/lib/__tests__/secret-shape.test.ts @@ -8,6 +8,7 @@ describe("looksSecretShaped", () => { ["a refresh token", "hlr_xyz789"], ["a share-link token", "see hls_def456"], ["an invite token", "hlv_0a1b2c3d"], + ["a managed-profile handover token", "hlp_0a1b2c3d"], ["an elevation token", "hle_0a1b2c3d"], ["a native sign-in handoff code", "hlh_Ab9-xYz_12"], ["an MCP authorization code", "hlac_eyJ0eXAi"], diff --git a/src/lib/auth/__tests__/oidc.test.ts b/src/lib/auth/__tests__/oidc.test.ts index 233129fc6..dbcadf1a1 100644 --- a/src/lib/auth/__tests__/oidc.test.ts +++ b/src/lib/auth/__tests__/oidc.test.ts @@ -470,6 +470,15 @@ describe("deriveUniqueUsername", () => { expect(username.length).toBeLessThanOrEqual(30); }); + it("never derives a name with the managed-profile prefix", async () => { + const exists = vi.fn().mockResolvedValue(false); + const username = await deriveUniqueUsername( + "Managed-managed-alex@example.com", + exists, + ); + expect(username).toBe("alex"); + }); + it("appends a numeric suffix on collision", async () => { const exists = vi .fn() diff --git a/src/lib/auth/handover-token.ts b/src/lib/auth/handover-token.ts new file mode 100644 index 000000000..e5356f4d4 --- /dev/null +++ b/src/lib/auth/handover-token.ts @@ -0,0 +1,84 @@ +/** + * v1.42 (#959) — managed-profile handover tokens. + * + * A Guardian mints one of these to hand a managed profile over to the person + * it describes. The raw token (`hlp_<64 hex>`) is shown exactly once, in the + * response that minted it; only its HMAC-SHA256 hash under + * `API_TOKEN_HMAC_KEY` is stored, the same scheme as `ApiToken`, + * `ClinicianShareLink` and the registration invite (`invite-token.ts`). A + * database leak therefore never yields a usable link, and the keyed hash makes + * the unique-index lookup the timing-safe comparison: without the key nobody + * can construct a preimage to probe the index byte by byte. + * + * The shape gate below is restated in `src/proxy.ts` (the `/claim/` edge + * redirect), because that module must not pull in the Prisma client this one + * does not need either. Keep the two in step. + */ +import { randomBytes, timingSafeEqual } from "node:crypto"; + +import { hashToken } from "@/lib/auth/hmac"; + +/** Raw-token prefix — distinct from every other `hl*_` credential. */ +export const HANDOVER_TOKEN_PREFIX = "hlp_"; + +const HANDOVER_TOKEN_SHAPE = /^hlp_[0-9a-f]{64}$/; + +/** Generate a fresh raw handover token. 32 random bytes → 64 hex chars. */ +export function generateHandoverToken(): string { + return `${HANDOVER_TOKEN_PREFIX}${randomBytes(32).toString("hex")}`; +} + +/** Cheap shape gate before paying the HMAC and the database lookup. */ +export function looksLikeHandoverToken(value: string): boolean { + return HANDOVER_TOKEN_SHAPE.test(value); +} + +/** The stored form of a raw token. */ +export function hashHandoverToken(rawToken: string): string { + return hashToken(rawToken); +} + +/** + * Compare a stored hash with the hash of a presented token in constant time. + * + * The row was found through the unique index on exactly this hash, so the two + * are equal whenever this runs. It is kept anyway: it costs nothing, and it is + * what keeps a later change to the lookup (a prefix scan, a cache) from + * turning into a byte-by-byte comparison without anybody deciding that. + */ +export function handoverHashMatches( + stored: string, + presented: string, +): boolean { + const a = Buffer.from(stored, "utf8"); + const b = Buffer.from(presented, "utf8"); + return a.length === b.length && timingSafeEqual(a, b); +} + +/** + * The link a Guardian hands over: `/claim/`. + * + * Origin precedence matches `buildInviteUrl`: the operator-configured + * `APP_URL` / `NEXT_PUBLIC_APP_URL` win over the request origin, which behind + * a reverse proxy may be an internal hostname. + */ +export function buildHandoverUrl(rawToken: string, requestUrl: string): string { + const candidates = [ + process.env.APP_URL, + process.env.NEXT_PUBLIC_APP_URL, + requestUrl, + ] + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)); + + let origin = "http://localhost:3000"; + for (const candidate of candidates) { + try { + origin = new URL(candidate).origin; + break; + } catch { + // try the next candidate + } + } + return `${origin}/claim/${encodeURIComponent(rawToken)}`; +} diff --git a/src/lib/auth/oidc.ts b/src/lib/auth/oidc.ts index ebcd17374..3282bd95e 100644 --- a/src/lib/auth/oidc.ts +++ b/src/lib/auth/oidc.ts @@ -393,6 +393,11 @@ export async function deriveUniqueUsername( ): Promise { const localPart = email.split("@")[0] ?? "user"; let base = localPart.replace(/[^a-zA-Z0-9_-]/g, ""); + // v1.42 (#959) — `managed-` is the generated name of a profile nobody signs + // into, and no self-named account may carry it (`accountUsernameSchema`). + while (base.toLowerCase().startsWith("managed-")) { + base = base.slice("managed-".length); + } if (base.length < 3) base = `${base}user`.slice(0, 3).padEnd(3, "0"); base = base.slice(0, 26); // leave room for a numeric suffix up to 30 chars diff --git a/src/lib/consent/withdrawal.ts b/src/lib/consent/withdrawal.ts index eaf220da3..0ec9b9dd4 100644 --- a/src/lib/consent/withdrawal.ts +++ b/src/lib/consent/withdrawal.ts @@ -112,38 +112,57 @@ export async function withdrawConsent( now: Date = new Date(), purge: typeof purgeRegenerableAiText = purgeRegenerableAiText, ): Promise { - return prisma.$transaction(async (tx) => { - const revoked: ConsentWithdrawal["revoked"] = []; - for (const kind of kinds) { - // The partial unique index keeps at most one active row per (user, - // kind), so one conditional update revokes "the" active receipt; a - // concurrent revoke finds nothing left to match and writes nothing. - const updated = await tx.consentReceipt.updateMany({ - where: { userId, kind, revokedAt: null }, - data: { revokedAt: now }, - }); - if (updated.count === 0) continue; - const receipt = await tx.consentReceipt.findFirst({ - where: { userId, kind, revokedAt: now }, - orderBy: { createdAt: "desc" }, - }); - if (receipt) revoked.push({ kind, receipt }); - } + return prisma.$transaction((tx) => + withdrawConsentInTransaction(tx, userId, kinds, now, purge), + ); +} - const touchedAnalysis = revoked.some(({ kind }) => - INSIGHTS_CONSENT_KINDS.includes(kind), - ); - if (!touchedAnalysis) return { revoked, purged: null }; +/** + * The same withdrawal inside a caller's transaction. + * + * v1.42 (#959) — a managed profile's AI consent was given by its Guardian on + * the person's behalf. When the person claims the profile, every active + * receipt is withdrawn in the claim's own transaction, so the new owner starts + * from no consent and gives it themselves, and the text written under the + * Guardian's consent goes with it exactly as on any other withdrawal. + */ +export async function withdrawConsentInTransaction( + tx: Prisma.TransactionClient, + userId: string, + kinds: readonly ConsentKind[], + now: Date = new Date(), + purge: typeof purgeRegenerableAiText = purgeRegenerableAiText, +): Promise { + const revoked: ConsentWithdrawal["revoked"] = []; + for (const kind of kinds) { + // The partial unique index keeps at most one active row per (user, + // kind), so one conditional update revokes "the" active receipt; a + // concurrent revoke finds nothing left to match and writes nothing. + const updated = await tx.consentReceipt.updateMany({ + where: { userId, kind, revokedAt: null }, + data: { revokedAt: now }, + }); + if (updated.count === 0) continue; + const receipt = await tx.consentReceipt.findFirst({ + where: { userId, kind, revokedAt: now }, + orderBy: { createdAt: "desc" }, + }); + if (receipt) revoked.push({ kind, receipt }); + } - const stillCovered = await tx.consentReceipt.count({ - where: { - userId, - revokedAt: null, - kind: { in: [...INSIGHTS_CONSENT_KINDS] }, - }, - }); - if (stillCovered > 0) return { revoked, purged: null }; + const touchedAnalysis = revoked.some(({ kind }) => + INSIGHTS_CONSENT_KINDS.includes(kind), + ); + if (!touchedAnalysis) return { revoked, purged: null }; - return { revoked, purged: await purge(tx, userId) }; + const stillCovered = await tx.consentReceipt.count({ + where: { + userId, + revokedAt: null, + kind: { in: [...INSIGHTS_CONSENT_KINDS] }, + }, }); + if (stillCovered > 0) return { revoked, purged: null }; + + return { revoked, purged: await purge(tx, userId) }; } diff --git a/src/lib/logging/__tests__/redact.test.ts b/src/lib/logging/__tests__/redact.test.ts index 57cd1f30c..bfe2d415e 100644 --- a/src/lib/logging/__tests__/redact.test.ts +++ b/src/lib/logging/__tests__/redact.test.ts @@ -119,6 +119,7 @@ describe("redactSecrets", () => { it.each([ ["an invite token", `hlv_${"0a".repeat(32)}`], + ["a managed-profile handover token", `hlp_${"0a".repeat(32)}`], ["a session secret", `hls_${"ab".repeat(32)}`], ["an elevation", `hle_${"cd".repeat(32)}`], ["a native sign-in handoff code", `hlh_${"Ab9_-".repeat(9)}Ab`], @@ -216,6 +217,40 @@ describe("redactSecrets", () => { // v1.11.0 (Epic C, C6) — the raw `hls_` clinician share token rides as // the trailing path segment of `/c/`. It must be scrubbed from // `http.path` / `http.route` before the Wide Event leaves the process. + // v1.42 (#959) — the handover link `/claim/`. + it("registers the handover-link prefix in PATH_SECRET_PATHS", () => { + expect(PATH_SECRET_PATHS.map((entry) => entry.prefix)).toContain( + "/claim/", + ); + }); + + it("redacts any segment on the handover-link path, token-shaped or not", () => { + expect(redactSecrets(`/claim/hlp_${"ab".repeat(32)}`)).toBe( + "/claim/[REDACTED]", + ); + expect(redactSecrets("/claim/not-yet-a-token")).toBe("/claim/[REDACTED]"); + }); + + it("redacts the handover token in the claim page's query string", () => { + expect(redactSecrets(`/auth/claim?token=hlp_${"ab".repeat(32)}`)).toBe( + "/auth/claim?token=[REDACTED]", + ); + }); + + it("redacts the handover token in an absolute URL by its shape", () => { + const token = `hlp_${"cd".repeat(32)}`; + expect( + redactSecrets(`https://app.healthlog.dev/claim/${token}`), + ).not.toContain(token); + }); + + it("keeps the claim API routes' own path segments readable", () => { + expect(redactSecrets("/api/auth/claim/preview")).toBe( + "/api/auth/claim/preview", + ); + expect(redactSecrets("/api/auth/claim")).toBe("/api/auth/claim"); + }); + it("registers the clinician share-view prefix in PATH_SECRET_PATHS", () => { expect(PATH_SECRET_PATHS.map((entry) => entry.prefix)).toContain("/c/"); }); diff --git a/src/lib/logging/redact.ts b/src/lib/logging/redact.ts index 51871e5d3..ef4bbf2ef 100644 --- a/src/lib/logging/redact.ts +++ b/src/lib/logging/redact.ts @@ -37,7 +37,16 @@ * Add a new entry whenever a route exposes a secret in its URL path * (rather than headers, body, or query). Tested in `redact.test.ts`. */ -export const PATH_SECRET_PATHS: ReadonlyArray<{ prefix: string }> = [ +export const PATH_SECRET_PATHS: ReadonlyArray<{ + prefix: string; + /** + * Match the prefix only where a path STARTS with it, not inside a longer + * path. Needed when the prefix is also the tail of an unrelated route: + * `/claim/` is both the handover link and the end of `/api/auth/claim/`, + * whose `preview` segment is a route name, not a secret. + */ + pathStart?: true; +}> = [ // Withings webhook entrypoint: `WITHINGS_WEBHOOK_SECRET` travels as // the trailing path segment (v1.4.25 W17a). Without this rule the // secret lands in every Wide Event's `http.path` / `http.route`. @@ -51,17 +60,26 @@ export const PATH_SECRET_PATHS: ReadonlyArray<{ prefix: string }> = [ // the trailing path segment (mirrors Withings). Without this rule the // secret lands in every Wide Event's `http.path` / `http.route`. { prefix: "/api/whoop/webhook/" }, + // v1.42 (#959) — managed-profile handover link. The raw `hlp_` token rides + // as the trailing path segment of `/claim/` until the proxy turns it + // into `/auth/claim?token=…` (scrubbed by the `token=` query rule below); + // it is a one-time credential that turns a profile into an account. + // `pathStart`, because `/api/auth/claim/preview` ends in the same prefix + // and its last segment is a route name. A full URL carrying the token is + // still scrubbed, by the `hlp_` rule in `redactSecrets`. + { prefix: "/claim/", pathStart: true }, ]; function redactPathSegments(input: string): string { let out = input; - for (const { prefix } of PATH_SECRET_PATHS) { + for (const { prefix, pathStart } of PATH_SECRET_PATHS) { // Match `` where `` is one or more // non-`/` non-`?` chars, then preserve anything after (next path // segment, query string, hash). Capture group 1 is the optional // trailing context starting with `/`, `?`, or `#`. const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const re = new RegExp(`${escaped}[^/?#\\s]+([/?#][^\\s]*)?`, "g"); + const start = pathStart ? "(? { return `${prefix}[REDACTED]${tail ?? ""}`; }); @@ -85,12 +103,13 @@ export function redactSecrets(input: string): string { .replace(/(^|[^A-Za-z0-9])sk-(?:ant-)?[A-Za-z0-9_-]{8,}/g, "$1[REDACTED]") // HealthLog hex-bodied credentials: `hlk_` access token, `hlr_` refresh // token, `hls_` session cookie secret (v1.30.32) and clinician share - // token, `hle_` step-up elevation (v1.30.34), `hlv_` registration invite. + // token, `hle_` step-up elevation (v1.30.34), `hlv_` registration invite, + // `hlp_` managed-profile handover link (v1.42). // Same boundary-aware shape as the `sk-` rule. The // idempotency replay-cache already rejects bodies containing // these prefixes (CLAUDE.md headless-client-API patterns); this // is the matching egress guard for log/error surfaces. - .replace(/(^|[^A-Za-z0-9])hl[kresv]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]") + .replace(/(^|[^A-Za-z0-9])hl[kpresv]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]") // v1.22.0 — the MCP OAuth bridge artifacts: the authorization code // (`hlac_`), refresh token (`hlrt_`), and DCR client id (`hlc_`). All // are signed, base64url-bodied, and credential-bearing, so they are diff --git a/src/lib/managed-profiles/__tests__/handover-pure.test.ts b/src/lib/managed-profiles/__tests__/handover-pure.test.ts new file mode 100644 index 000000000..fec1779b5 --- /dev/null +++ b/src/lib/managed-profiles/__tests__/handover-pure.test.ts @@ -0,0 +1,194 @@ +/** + * v1.42 (#959) — the pure parts of the managed-profile handover: the token + * shape, the access policy, the request schemas and the stored-state reader. + */ +import { beforeAll, describe, expect, it } from "vitest"; + +import { + buildHandoverUrl, + generateHandoverToken, + handoverHashMatches, + hashHandoverToken, + looksLikeHandoverToken, +} from "@/lib/auth/handover-token"; +import { + DEFAULT_HANDOVER_ACCESS, + grantAccessFor, + handoverAccessOf, + isHandoverMinor, +} from "@/lib/managed-profiles/handover-access"; +import { parseHandoverState } from "@/lib/managed-profiles/handover"; +import { + claimSchema, + createHandoverSchema, + handoverDecisionSchema, +} from "@/lib/validations/managed-profile-handover"; +import { registerSchema } from "@/lib/validations/auth"; + +beforeAll(() => { + process.env.API_TOKEN_HMAC_KEY ??= "x".repeat(48); +}); + +describe("handover token", () => { + it("mints hlp_ plus 64 lowercase hex characters, fresh every time", () => { + const a = generateHandoverToken(); + const b = generateHandoverToken(); + expect(a).toMatch(/^hlp_[0-9a-f]{64}$/); + expect(looksLikeHandoverToken(a)).toBe(true); + expect(a).not.toBe(b); + }); + + it.each([ + `hlv_${"a".repeat(64)}`, + `hlp_${"A".repeat(64)}`, + `hlp_${"a".repeat(63)}`, + `hlp_${"a".repeat(65)}`, + "", + ])("refuses %s at the shape gate", (value) => { + expect(looksLikeHandoverToken(value)).toBe(false); + }); + + it("stores a keyed hash, never the token, and compares it in constant time", () => { + const token = generateHandoverToken(); + const hash = hashHandoverToken(token); + expect(hash).not.toContain(token.slice(4)); + expect(handoverHashMatches(hash, hashHandoverToken(token))).toBe(true); + expect( + handoverHashMatches(hash, hashHandoverToken(generateHandoverToken())), + ).toBe(false); + expect(handoverHashMatches(hash, "short")).toBe(false); + }); + + it("builds the link on the configured origin, not the request's", () => { + const previous = process.env.APP_URL; + process.env.APP_URL = "https://health.example.org/some/path"; + try { + expect(buildHandoverUrl("hlp_x", "http://internal:3000/api/x")).toBe( + "https://health.example.org/claim/hlp_x", + ); + } finally { + if (previous === undefined) delete process.env.APP_URL; + else process.env.APP_URL = previous; + } + }); +}); + +describe("handover access policy", () => { + it("proposes view-only when nobody says otherwise", () => { + expect(DEFAULT_HANDOVER_ACCESS).toBe("read"); + }); + + it("maps each level to a grant level, and end to none", () => { + expect(grantAccessFor("end")).toBeNull(); + expect(grantAccessFor("read")).toBe("READ"); + expect(grantAccessFor("manage")).toBe("MANAGE"); + expect(handoverAccessOf(null)).toBe("end"); + expect(handoverAccessOf({ access: "WRITE" })).toBe("read"); + expect(handoverAccessOf({ access: "MANAGE" })).toBe("manage"); + }); + + it("flags a minor by the birthday, not the birth year", () => { + const today = new Date("2026-10-08T12:00:00Z"); + expect(isHandoverMinor("2010-10-09", today)).toBe(true); + expect(isHandoverMinor("2010-10-08", today)).toBe(false); + expect(isHandoverMinor(null, today)).toBe(false); + expect(isHandoverMinor("not-a-date", today)).toBe(false); + }); +}); + +describe("handover request schemas", () => { + it("defaults the link to seven days and refuses other lifetimes", () => { + expect(createHandoverSchema.parse({}).expiresInDays).toBe(7); + expect(createHandoverSchema.safeParse({ expiresInDays: 30 }).success).toBe( + false, + ); + }); + + it("refuses a guardian named twice and unknown fields", () => { + const twice = createHandoverSchema.safeParse({ + proposals: [ + { grantId: "g1", proposal: "read" }, + { grantId: "g1", proposal: "end" }, + ], + }); + expect(twice.success).toBe(false); + expect( + createHandoverSchema.safeParse({ proposals: [], profileId: "p" }).success, + ).toBe(false); + expect( + handoverDecisionSchema.safeParse({ + decisions: [ + { grantId: "g1", access: "read" }, + { grantId: "g1", access: "manage" }, + ], + }).success, + ).toBe(false); + }); + + it("requires an email and refuses the managed- prefix on a claim", () => { + const base = { + token: "hlp_x", + username: "alex", + email: "alex@example.test", + password: "a long enough passphrase", + }; + expect(claimSchema.safeParse(base).success).toBe(true); + expect(claimSchema.safeParse({ ...base, email: undefined }).success).toBe( + false, + ); + expect( + claimSchema.safeParse({ ...base, username: "Managed-alex" }).success, + ).toBe(false); + }); + + it("refuses the managed- prefix on registration too", () => { + expect( + registerSchema.safeParse({ + email: "a@example.test", + username: "managed-0123", + password: "a long enough passphrase", + }).success, + ).toBe(false); + }); +}); + +describe("parseHandoverState", () => { + const proposals = [{ grantId: "g1", guardianId: "u1", proposal: "read" }]; + + it("reads the shape it writes", () => { + const state = parseHandoverState({ + version: 1, + proposals, + claim: { + claimedAt: "2026-10-08T00:00:00.000Z", + decidedAt: null, + guardians: [ + { + grantId: "g1", + guardianId: "u1", + proposal: "read", + applied: "read", + currentGrantId: "g2", + }, + ], + }, + }); + expect(state?.claim?.guardians[0].currentGrantId).toBe("g2"); + }); + + it.each([ + ["a later version", { version: 2, proposals }], + [ + "an unknown level", + { version: 1, proposals: [{ ...proposals[0], proposal: "write" }] }, + ], + [ + "a missing guardian", + { version: 1, proposals: [{ grantId: "g1", proposal: "read" }] }, + ], + ["an array", [proposals]], + ["null", null], + ])("refuses %s rather than guessing", (_label, value) => { + expect(parseHandoverState(value)).toBeNull(); + }); +}); diff --git a/src/lib/managed-profiles/claim-gate.ts b/src/lib/managed-profiles/claim-gate.ts new file mode 100644 index 000000000..3c1a4ec47 --- /dev/null +++ b/src/lib/managed-profiles/claim-gate.ts @@ -0,0 +1,88 @@ +/** + * v1.42 (#959) — the refusals both anonymous claim routes give before they + * look at a token, in one place so the preview and the claim cannot disagree. + * + * Order matters and is the register route's order: + * + * 1. A single-sign-on-only instance admits no password, and the claim sets + * one, so the handover is refused outright in v1.42. + * 2. A signed-in caller is refused with 409. Claiming creates a session for + * the new owner, and a Guardian testing their own link would otherwise be + * signed out of their account and into the person's. Refused before the + * rate limit: it is a state, not an attempt, and spending the bucket on it + * would lock out the person the link is for, who may share the address. + * 3. The per-address rate limit, the anonymous auth-surface pattern: a + * misconfigured proxy trust chain collapses every caller into one tight + * bucket rather than one free-for-all. + */ +import { NextResponse } from "next/server"; + +import { apiError } from "@/lib/api-response"; +import { isOidcOnly } from "@/lib/auth/oidc"; +import { annotate } from "@/lib/logging/context"; +import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; + +/** The one answer for every unusable link. Not an oracle. */ +export function claimInvalid() { + return apiError("This link is invalid, expired or already used", 404, { + errorCode: "profile_claim.invalid", + }); +} + +export async function claimGate( + request: Request, + surface: "preview" | "claim", + /** + * Whether the request carries a live session. Resolved by the ROUTE with + * `getSession()` and handed in, rather than resolved here: a session read + * inside a `lib/` wrapper would take both routes off the direct + * session-resolution list in `session-surface-guard.test.ts`, which is + * exactly the disappearance that guard exists to catch. + */ + signedIn: boolean, +): Promise<{ refusal: Response } | { refusal: null; ip: string | null }> { + if (isOidcOnly()) { + return { + refusal: apiError( + "Taking over a profile is not available while this server allows single sign-on only", + 403, + { errorCode: "profile_claim.oidc_only_unsupported" }, + ), + }; + } + + if (signedIn) { + annotate({ + action: { name: `profile_claim.${surface}.refused_authenticated` }, + }); + return { + refusal: apiError("Already signed in — sign out first", 409, { + errorCode: "auth.already_authenticated", + }), + }; + } + + const rl = + surface === "preview" + ? await checkAuthSurfaceRateLimit( + request, + "auth:claim-preview", + 20, + 15 * 60 * 1000, + ) + : await checkAuthSurfaceRateLimit( + request, + "auth:claim", + 5, + 15 * 60 * 1000, + ); + if (!rl.allowed) { + return { + refusal: NextResponse.json( + { data: null, error: "Too many attempts. Please try again later." }, + { status: 429, headers: rateLimitHeaders(rl) }, + ), + }; + } + return { refusal: null, ip: rl.ip }; +} diff --git a/src/lib/managed-profiles/handover-access.ts b/src/lib/managed-profiles/handover-access.ts new file mode 100644 index 000000000..2f0a178e5 --- /dev/null +++ b/src/lib/managed-profiles/handover-access.ts @@ -0,0 +1,109 @@ +/** + * v1.42 (#959) — what happens to each Guardian's access when a managed profile + * is handed over. Every rule about that choice lives in this file, and only + * here, so that changing the policy is one edit: + * + * - the three levels a Guardian can end up with; + * - the level a Guardian is proposed when nobody says otherwise; + * - which levels the issuing Guardian may propose, and which the new owner + * may then pick; + * - what each level means as an `AccountGrant` row. + * + * The flow it serves (option C): the Guardian who mints the link proposes a + * level per Guardian. Claiming applies the proposals at once, so the record is + * never left without a decision. The new owner then sees each Guardian's access + * and decides finally; until they do, the proposed level is what holds. + * + * No Prisma and no server imports: the settings dialog, the claim page and the + * decision screen read the same constants the server enforces. + */ + +/** `end` removes the Guardian; `read` leaves view-only; `manage` keeps all. */ +export const HANDOVER_ACCESS_LEVELS = ["end", "read", "manage"] as const; + +export type HandoverAccess = (typeof HANDOVER_ACCESS_LEVELS)[number]; + +/** + * The proposal a Guardian gets when the issuing Guardian named none for them, + * including somebody who became a Guardian after the link was minted. + * + * View-only rather than either extreme: it keeps the people who looked after + * the record able to see it (continuity of care), and it takes away every + * act on it, so nothing is done in the new owner's name until they decide. + */ +export const DEFAULT_HANDOVER_ACCESS: HandoverAccess = "read"; + +/** The levels the issuing Guardian may propose. */ +export const PROPOSABLE_HANDOVER_ACCESS: readonly HandoverAccess[] = [ + ...HANDOVER_ACCESS_LEVELS, +]; + +/** + * The levels the new owner may choose for a Guardian on the decision screen. + * + * Every level, independent of the proposal: a proposal can never bind the + * person whose record it is. Raising a Guardian back to `manage` re-creates + * access that Guardian already held and accepted, so it needs no new + * acceptance from them — and it can only reach the level they held, because + * every Guardian held `manage`. + */ +export const OWNER_CHOOSABLE_HANDOVER_ACCESS: readonly HandoverAccess[] = [ + ...HANDOVER_ACCESS_LEVELS, +]; + +/** The grant level a handover access maps to, or null for `end`. */ +export function grantAccessFor( + access: HandoverAccess, +): "READ" | "MANAGE" | null { + switch (access) { + case "end": + return null; + case "read": + return "READ"; + case "manage": + return "MANAGE"; + } +} + +/** + * The handover level a live grant reads as. + * + * WRITE cannot arise from a handover, but a grant the owner re-shaped through + * the ordinary sharing panel can be anything; it reads as `read` here because + * this vocabulary has nothing between view and manage, and the decision screen + * shows such a row as already settled rather than offering to change it. + */ +export function handoverAccessOf( + grant: { access: "READ" | "WRITE" | "MANAGE" } | null, +): HandoverAccess { + if (grant === null) return "end"; + return grant.access === "MANAGE" ? "manage" : "read"; +} + +/** The age below which the Guardian dialog shows the minor hint. */ +export const HANDOVER_MINOR_AGE = 16; + +/** + * Whether a date of birth (`yyyy-MM-dd`) is under {@link HANDOVER_MINOR_AGE} + * on `today`. A hint only: there is no age gate, because the age of majority + * differs between countries and the Guardian knows the person. + */ +export function isHandoverMinor( + dateOfBirth: string | null, + today: Date = new Date(), +): boolean { + if (!dateOfBirth) return false; + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateOfBirth); + if (!match) return false; + const [year, month, day] = [ + Number(match[1]), + Number(match[2]), + Number(match[3]), + ]; + let age = today.getUTCFullYear() - year; + const beforeBirthday = + today.getUTCMonth() + 1 < month || + (today.getUTCMonth() + 1 === month && today.getUTCDate() < day); + if (beforeBirthday) age -= 1; + return age < HANDOVER_MINOR_AGE; +} diff --git a/src/lib/managed-profiles/handover-notify.ts b/src/lib/managed-profiles/handover-notify.ts new file mode 100644 index 000000000..24b7c7426 --- /dev/null +++ b/src/lib/managed-profiles/handover-notify.ts @@ -0,0 +1,62 @@ +/** + * v1.42 (#959) — telling each former Guardian what a handover did to their + * access. + * + * Addressed to the Guardian's OWN account and channels. The managed-record + * fan-out (`delivery-identity.ts`) is closed to this event on purpose, and it + * no longer applies anyway: the record stopped being managed in the claim. + * + * Fire-and-forget after the commit. A notification that cannot be delivered + * must never undo a handover that already happened, and nothing here waits on + * a provider. + */ +import { getEvent } from "@/lib/logging/context"; +import type { HandoverAccess } from "@/lib/managed-profiles/handover-access"; +import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised"; + +/** The sentence for an access level, as literal keys the i18n guards can see. */ +function accessMessageKey( + kind: "claimed" | "changed", + access: HandoverAccess, +): string { + if (kind === "claimed") { + switch (access) { + case "end": + return "notifications.handover.claimedEnd"; + case "read": + return "notifications.handover.claimedRead"; + case "manage": + return "notifications.handover.claimedManage"; + } + } + switch (access) { + case "end": + return "notifications.handover.changedEnd"; + case "read": + return "notifications.handover.changedRead"; + case "manage": + return "notifications.handover.changedManage"; + } +} + +export function notifyGuardiansOfHandover( + kind: "claimed" | "changed", + recordName: string, + guardians: { guardianId: string; access: HandoverAccess }[], +): void { + for (const guardian of guardians) { + void dispatchLocalisedNotification({ + userId: guardian.guardianId, + titleKey: + kind === "claimed" + ? "notifications.handover.claimedTitle" + : "notifications.handover.changedTitle", + messageKey: accessMessageKey(kind, guardian.access), + params: { name: recordName }, + }).catch((err: unknown) => { + getEvent()?.addWarning( + `handover notification failed: ${err instanceof Error ? err.message : String(err)}`, + ); + }); + } +} diff --git a/src/lib/managed-profiles/handover.ts b/src/lib/managed-profiles/handover.ts new file mode 100644 index 000000000..b9a83affa --- /dev/null +++ b/src/lib/managed-profiles/handover.ts @@ -0,0 +1,877 @@ +/** + * v1.42 (#959) — handing a managed profile over to the person it describes. + * + * A managed profile is a `User` row nobody signs into, kept by its Guardians. + * The handover turns it into that person's own account. No data moves: every + * reading already hangs off the profile's own id. What changes is who can sign + * in to it and who else keeps access. + * + * The flow, in the order it happens: + * + * 1. A Guardian, with a fresh second factor, mints a one-time link and + * proposes a level per Guardian (`handover-access.ts`). Only the HMAC of + * the token is stored; at most one link per profile is open, and minting + * a new one withdraws the old. + * 2. The person opens the link, sees a minimal preview (the profile's name, + * the link's expiry, each Guardian's proposed access — no health data), + * and chooses a username, an email and a password. + * 3. The claim runs as ONE transaction under the managed-profile lock: the + * token is consumed, the credentials are set, the marker is cleared, + * every pending invitation the record has offered is withdrawn, each + * Guardian's access is set to its proposal, the disclaimer, onboarding + * and AI consent are reset so the new owner gives them personally, and + * every step is audited. A failure anywhere leaves the profile managed + * and the link unused. + * 4. On first sign-in the new owner sees each Guardian's access and decides + * finally. Until they do, the proposal is what holds. + * + * Every refusal on the anonymous side is the same `invalid`, so the preview + * and the claim cannot be used to learn whether a token, a profile or a + * Guardian exists. + */ +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { + generateHandoverToken, + handoverHashMatches, + hashHandoverToken, + looksLikeHandoverToken, +} from "@/lib/auth/handover-token"; +import { withdrawConsentInTransaction } from "@/lib/consent/withdrawal"; +import { consentKindEnum } from "@/lib/validations/consent"; +import { + activeGuardianWhere, + clearManagedProfileMarker, + withManagedProfileLock, +} from "@/lib/managed-profiles/lifecycle"; +import { + DEFAULT_HANDOVER_ACCESS, + HANDOVER_ACCESS_LEVELS, + handoverAccessOf, + type HandoverAccess, +} from "@/lib/managed-profiles/handover-access"; +import { + isGrantActive, + settleHandoverGuardianGrant, + withdrawPendingGrantsOfRecord, +} from "@/lib/sharing/grants"; +import { isP2002 } from "@/lib/prisma-errors"; +import type { Prisma } from "@/generated/prisma/client"; + +type Transaction = Prisma.TransactionClient; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export type HandoverErrorCode = + /** No such managed profile, or the caller is not one of its Guardians. */ + | "not_found" + /** A proposal names a grant that is not an active Guardian of the profile. */ + | "unknown_guardian" + /** Anonymous side: no usable link behind the token. Every cause. */ + | "invalid" + /** The username or email is already taken. */ + | "taken" + /** The new owner has no handover decision waiting. */ + | "no_pending"; + +export class HandoverError extends Error { + constructor(readonly code: HandoverErrorCode) { + super(`managed profile handover refused: ${code}`); + this.name = "HandoverError"; + } +} + +// ── Stored state ──────────────────────────────────────────────────────────── + +/** One Guardian's proposal, as the issuing Guardian recorded it. */ +interface StoredProposal { + /** The Guardian's MANAGE grant when the link was minted. */ + grantId: string; + guardianId: string; + proposal: HandoverAccess; +} + +/** One Guardian's access across the claim and the owner's decision. */ +interface StoredClaimEntry { + /** The Guardian's MANAGE grant at the moment of the claim. */ + grantId: string; + guardianId: string; + proposal: HandoverAccess; + /** What the claim set. */ + applied: HandoverAccess; + /** The live grant the handover last wrote or kept, or null for `end`. */ + currentGrantId: string | null; + /** The new owner's final choice, once made. */ + decided?: HandoverAccess; +} + +/** + * Everything `proposals_json` holds. Versioned, because the column outlives + * the code that wrote it: a row from a later shape that this code cannot read + * is treated as no handover at all, never guessed at. + */ +interface HandoverState { + version: 1; + proposals: StoredProposal[]; + claim?: { + claimedAt: string; + guardians: StoredClaimEntry[]; + decidedAt: string | null; + }; +} + +function isAccess(value: unknown): value is HandoverAccess { + return ( + typeof value === "string" && + (HANDOVER_ACCESS_LEVELS as readonly string[]).includes(value) + ); +} + +function isString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +/** Read the column, refusing anything it does not fully understand. */ +export function parseHandoverState(value: unknown): HandoverState | null { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const raw = value as Record; + if (raw.version !== 1 || !Array.isArray(raw.proposals)) return null; + + const proposals: StoredProposal[] = []; + for (const entry of raw.proposals) { + const e = entry as Record | null; + if (!e || !isString(e.grantId) || !isString(e.guardianId)) return null; + if (!isAccess(e.proposal)) return null; + proposals.push({ + grantId: e.grantId, + guardianId: e.guardianId, + proposal: e.proposal, + }); + } + + if (raw.claim === undefined) return { version: 1, proposals }; + const claim = raw.claim as Record | null; + if (!claim || !isString(claim.claimedAt) || !Array.isArray(claim.guardians)) { + return null; + } + if (claim.decidedAt !== null && !isString(claim.decidedAt)) return null; + const guardians: StoredClaimEntry[] = []; + for (const entry of claim.guardians) { + const e = entry as Record | null; + if (!e || !isString(e.grantId) || !isString(e.guardianId)) return null; + if (!isAccess(e.proposal) || !isAccess(e.applied)) return null; + if (e.currentGrantId !== null && !isString(e.currentGrantId)) return null; + if (e.decided !== undefined && !isAccess(e.decided)) return null; + guardians.push({ + grantId: e.grantId, + guardianId: e.guardianId, + proposal: e.proposal, + applied: e.applied, + currentGrantId: e.currentGrantId, + ...(e.decided !== undefined ? { decided: e.decided } : {}), + }); + } + return { + version: 1, + proposals, + claim: { + claimedAt: claim.claimedAt, + guardians, + decidedAt: claim.decidedAt, + }, + }; +} + +function toJson(state: HandoverState): Prisma.InputJsonValue { + return state as unknown as Prisma.InputJsonValue; +} + +/** The proposal for a Guardian grant, or the default when none was named. */ +function proposalFor(state: HandoverState, grantId: string): HandoverAccess { + return ( + state.proposals.find((entry) => entry.grantId === grantId)?.proposal ?? + DEFAULT_HANDOVER_ACCESS + ); +} + +async function isActiveGuardian( + db: Pick, + profileId: string, + guardianId: string, + now: Date, +): Promise { + const grant = await db.accountGrant.findFirst({ + where: { + grantorId: profileId, + granteeId: guardianId, + ...activeGuardianWhere(now), + }, + select: { id: true }, + }); + return grant !== null; +} + +function nameOf(user: { displayName: string | null; username: string }) { + return user.displayName?.trim() || user.username; +} + +// ── Guardian side ─────────────────────────────────────────────────────────── + +export interface CreatedHandover { + id: string; + /** The raw token. Returned once, here, and never stored. */ + token: string; + expiresAt: Date; +} + +/** + * Mint the one-time link. Any active Guardian may, not only the profile's + * creator: Guardians of one record hold the same standing. + * + * One proposal per active Guardian is recorded. A Guardian the caller left + * out is proposed {@link DEFAULT_HANDOVER_ACCESS}; a proposal naming a grant + * that is not an active Guardian of this profile is refused rather than + * dropped, because it means the form and the roster disagree. + */ +export async function createHandover(input: { + profileId: string; + guardianId: string; + expiresInDays: number; + proposals: { grantId: string; proposal: HandoverAccess }[]; + now?: Date; +}): Promise { + const now = input.now ?? new Date(); + return prisma.$transaction((tx) => + withManagedProfileLock(tx, input.profileId, async (profile) => { + if (!profile?.managedProfileAt) throw new HandoverError("not_found"); + + const guardians = await tx.accountGrant.findMany({ + where: { grantorId: profile.id, ...activeGuardianWhere(now) }, + select: { id: true, granteeId: true }, + orderBy: { createdAt: "asc" }, + }); + if (!guardians.some((g) => g.granteeId === input.guardianId)) { + throw new HandoverError("not_found"); + } + const known = new Set(guardians.map((g) => g.id)); + if (input.proposals.some((p) => !known.has(p.grantId))) { + throw new HandoverError("unknown_guardian"); + } + + const proposals: StoredProposal[] = guardians.map((g) => ({ + grantId: g.id, + guardianId: g.granteeId, + proposal: + input.proposals.find((p) => p.grantId === g.id)?.proposal ?? + DEFAULT_HANDOVER_ACCESS, + })); + + // At most one open link per profile: the partial unique index enforces + // it, and this is what keeps a second mint from colliding with it. + const replaced = await tx.managedProfileHandover.updateMany({ + where: { profileId: profile.id, usedAt: null, revokedAt: null }, + data: { revokedAt: now }, + }); + + const token = generateHandoverToken(); + const expiresAt = new Date(now.getTime() + input.expiresInDays * DAY_MS); + const row = await tx.managedProfileHandover.create({ + data: { + profileId: profile.id, + createdById: input.guardianId, + tokenHash: hashHandoverToken(token), + proposalsJson: toJson({ version: 1, proposals }), + expiresAt, + }, + select: { id: true }, + }); + + // Filed under the RECORD with the Guardian as the actor, like every + // other act on a managed profile. No token, no names. + await auditLog("managed_profile.handover.created", { + userId: profile.id, + actorUserId: input.guardianId, + details: { + handoverId: row.id, + expiresAt: expiresAt.toISOString(), + replaced: replaced.count, + proposals: proposals.map((p) => ({ + grantId: p.grantId, + proposal: p.proposal, + })), + }, + client: tx, + }); + + return { id: row.id, token, expiresAt }; + }), + ); +} + +export interface HandoverStatus { + open: { + id: string; + createdAt: Date; + expiresAt: Date; + createdByCaller: boolean; + } | null; +} + +/** + * The profile's open link, for a Guardian's panel. Never the link itself: + * only its hash exists. `null` when the caller may not see the profile. + */ +export async function readHandoverStatus(input: { + profileId: string; + guardianId: string; + now?: Date; +}): Promise { + const now = input.now ?? new Date(); + const profile = await prisma.user.findUnique({ + where: { id: input.profileId }, + select: { managedProfileAt: true }, + }); + if (!profile?.managedProfileAt) return null; + if ( + !(await isActiveGuardian(prisma, input.profileId, input.guardianId, now)) + ) { + return null; + } + + const open = await prisma.managedProfileHandover.findFirst({ + where: { + profileId: input.profileId, + usedAt: null, + revokedAt: null, + expiresAt: { gt: now }, + }, + select: { id: true, createdAt: true, expiresAt: true, createdById: true }, + }); + // A link whose creator has since lost access cannot be claimed; showing it + // as open would promise something the claim refuses. + if ( + !open || + !(await isActiveGuardian(prisma, input.profileId, open.createdById, now)) + ) { + return { open: null }; + } + return { + open: { + id: open.id, + createdAt: open.createdAt, + expiresAt: open.expiresAt, + createdByCaller: open.createdById === input.guardianId, + }, + }; +} + +/** + * Withdraw the open link. Any active Guardian may, with no step-up: taking a + * link back must never be harder than handing it out. + */ +export async function revokeHandover(input: { + profileId: string; + guardianId: string; + now?: Date; +}): Promise<{ revoked: boolean }> { + const now = input.now ?? new Date(); + return prisma.$transaction((tx) => + withManagedProfileLock(tx, input.profileId, async (profile) => { + if (!profile?.managedProfileAt) throw new HandoverError("not_found"); + if (!(await isActiveGuardian(tx, profile.id, input.guardianId, now))) { + throw new HandoverError("not_found"); + } + const { count } = await tx.managedProfileHandover.updateMany({ + where: { profileId: profile.id, usedAt: null, revokedAt: null }, + data: { revokedAt: now }, + }); + if (count > 0) { + await auditLog("managed_profile.handover.revoked", { + userId: profile.id, + actorUserId: input.guardianId, + details: { count }, + client: tx, + }); + } + return { revoked: count > 0 }; + }), + ); +} + +// ── Anonymous side ────────────────────────────────────────────────────────── + +/** + * The stored row behind a raw token, by keyed-hash lookup, or null. A token + * that fails the shape gate costs no HMAC and no query. + */ +async function findHandoverByToken( + db: Pick, + rawToken: string, +) { + if (!looksLikeHandoverToken(rawToken)) return null; + const tokenHash = hashHandoverToken(rawToken); + const row = await db.managedProfileHandover.findUnique({ + where: { tokenHash }, + }); + if (!row || !handoverHashMatches(row.tokenHash, tokenHash)) return null; + return row; +} + +function isOpen( + row: { usedAt: Date | null; revokedAt: Date | null; expiresAt: Date }, + now: Date, +): boolean { + return row.usedAt === null && row.revokedAt === null && row.expiresAt > now; +} + +export interface HandoverPreview { + displayName: string | null; + expiresAt: Date; + guardians: { + grantId: string; + displayName: string; + proposal: HandoverAccess; + }[]; +} + +/** + * What a link would hand over, or null for every kind of unusable link. + * + * Deliberately minimal: the profile's name, the expiry, and who keeps which + * access. No health data, no date of birth, no usernames — the person holding + * the link has not proved anything yet beyond holding it. + */ +export async function previewHandover( + rawToken: string, + now: Date = new Date(), +): Promise { + const row = await findHandoverByToken(prisma, rawToken); + if (!row || !isOpen(row, now)) return null; + const state = parseHandoverState(row.proposalsJson); + if (!state) return null; + + const profile = await prisma.user.findUnique({ + where: { id: row.profileId }, + select: { displayName: true, managedProfileAt: true }, + }); + if (!profile?.managedProfileAt) return null; + if (!(await isActiveGuardian(prisma, row.profileId, row.createdById, now))) { + return null; + } + + const guardians = await prisma.accountGrant.findMany({ + where: { grantorId: row.profileId, ...activeGuardianWhere(now) }, + select: { + id: true, + grantee: { select: { displayName: true, username: true } }, + }, + orderBy: { createdAt: "asc" }, + }); + return { + displayName: profile.displayName, + expiresAt: row.expiresAt, + guardians: guardians.map((g) => ({ + grantId: g.id, + displayName: nameOf(g.grantee), + proposal: proposalFor(state, g.id), + })), + }; +} + +export interface ClaimedProfile { + profileId: string; + username: string; + displayName: string | null; + guardians: { guardianId: string; access: HandoverAccess }[]; +} + +/** + * Claim the profile behind a link. One transaction; see the module header for + * everything it does. Throws `HandoverError("invalid")` for every unusable + * link and `HandoverError("taken")` for a username or email in use. + * + * The caller has already hashed the password (Argon2id) and checked its + * strength: none of that work belongs inside the lock. + */ +export async function claimManagedProfile(input: { + rawToken: string; + username: string; + email: string; + passwordHash: string; + ipAddress?: string | null; + now?: Date; +}): Promise { + const now = input.now ?? new Date(); + const found = await findHandoverByToken(prisma, input.rawToken); + if (!found) throw new HandoverError("invalid"); + + try { + return await prisma.$transaction((tx) => + withManagedProfileLock(tx, found.profileId, async (profile) => { + if (!profile?.managedProfileAt) throw new HandoverError("invalid"); + + // Re-read under the lock: a new link, a withdrawal or a second claim + // that committed while this one waited is seen here. + const row = await tx.managedProfileHandover.findUnique({ + where: { id: found.id }, + }); + if (!row || !isOpen(row, now)) throw new HandoverError("invalid"); + const state = parseHandoverState(row.proposalsJson); + if (!state) throw new HandoverError("invalid"); + if (!(await isActiveGuardian(tx, profile.id, row.createdById, now))) { + throw new HandoverError("invalid"); + } + + // Single use: the conditional update is the claim on the token. + const consumed = await tx.managedProfileHandover.updateMany({ + where: { + id: row.id, + usedAt: null, + revokedAt: null, + expiresAt: { gt: now }, + }, + data: { usedAt: now }, + }); + if (consumed.count !== 1) throw new HandoverError("invalid"); + + // The new owner's credentials, field by field. The disclaimer and the + // onboarding were given by a Guardian on the person's behalf; an adult + // gives both themselves. + const updated = await tx.user.update({ + where: { id: profile.id }, + data: { + username: input.username, + email: input.email, + passwordHash: input.passwordHash, + disclaimerAcknowledgedAt: null, + disclaimerAcknowledgedVersion: null, + onboardingCompletedAt: null, + }, + select: { username: true, displayName: true }, + }); + await clearManagedProfileMarker({ profileId: profile.id }, tx); + + const withdrawnInvitations = await withdrawPendingGrantsOfRecord( + tx, + profile.id, + now, + ); + + const guardians = await tx.accountGrant.findMany({ + where: { grantorId: profile.id, ...activeGuardianWhere(now) }, + select: { id: true, granteeId: true, access: true, scopeJson: true }, + orderBy: { createdAt: "asc" }, + }); + const entries: StoredClaimEntry[] = []; + for (const grant of guardians) { + const proposal = proposalFor(state, grant.id); + const settled = await settleHandoverGuardianGrant(tx, { + recordId: profile.id, + guardianId: grant.granteeId, + liveGrant: grant, + target: proposal, + now, + }); + entries.push({ + grantId: grant.id, + guardianId: grant.granteeId, + proposal, + applied: settled.access, + currentGrantId: settled.grantId, + }); + // Under the Guardian's OWN account, so their history shows that a + // record they looked after has gone. No actor: it is not somebody + // else acting on the Guardian's record. + await auditLog("managed_profile.handed_over", { + userId: grant.granteeId, + actorUserId: null, + details: { + profileId: profile.id, + access: settled.access, + grantId: settled.grantId, + }, + client: tx, + }); + } + + const consent = await withdrawConsentInTransaction( + tx, + profile.id, + consentKindEnum.options, + now, + ); + + await tx.managedProfileHandover.update({ + where: { id: row.id }, + data: { + proposalsJson: toJson({ + ...state, + claim: { + claimedAt: now.toISOString(), + guardians: entries, + decidedAt: null, + }, + }), + }, + }); + + await auditLog("managed_profile.claimed", { + userId: profile.id, + actorUserId: null, + ipAddress: input.ipAddress ?? null, + details: { + handoverId: row.id, + guardians: entries.map((e) => ({ + grantId: e.grantId, + access: e.applied, + newGrantId: e.currentGrantId, + })), + withdrawnInvitations, + consentsWithdrawn: consent.revoked.length, + }, + client: tx, + }); + + return { + profileId: profile.id, + username: updated.username, + displayName: updated.displayName, + guardians: entries.map((e) => ({ + guardianId: e.guardianId, + access: e.applied, + })), + }; + }), + ); + } catch (err) { + if (isP2002(err)) throw new HandoverError("taken"); + throw err; + } +} + +// ── The new owner's decision ──────────────────────────────────────────────── + +export interface PendingHandoverGuardian { + /** The Guardian's grant at the claim; what a decision names. */ + grantId: string; + displayName: string; + proposal: HandoverAccess; + /** The access the Guardian holds now. */ + current: HandoverAccess; + /** + * False when the access moved since the claim — the Guardian stepped away, + * or the owner already changed it under Settings → Shared access — so the + * decision screen shows it and leaves it alone. + */ + decidable: boolean; +} + +export interface PendingHandoverDecision { + handoverId: string; + claimedAt: Date; + guardians: PendingHandoverGuardian[]; +} + +async function latestClaimedHandover( + db: Pick, + userId: string, +) { + return db.managedProfileHandover.findFirst({ + where: { profileId: userId, usedAt: { not: null } }, + orderBy: { usedAt: "desc" }, + select: { id: true, proposalsJson: true }, + }); +} + +/** Whether the pair's live row is still the one the handover left there. */ +function isUntouched( + entry: StoredClaimEntry, + live: { id: string } | null, +): boolean { + return entry.currentGrantId === null + ? live === null + : live?.id === entry.currentGrantId; +} + +/** + * Whether a decision is waiting, for the setup flow's front door. The cheap + * question; {@link readPendingHandoverDecision} is the full answer. + */ +export async function hasPendingHandoverDecision( + userId: string, +): Promise { + const row = await latestClaimedHandover(prisma, userId); + const state = row ? parseHandoverState(row.proposalsJson) : null; + return Boolean(state?.claim && state.claim.decidedAt === null); +} + +/** + * The decision waiting for the new owner, or null when there is none — the + * account was never a managed profile, or the decision was made. + */ +export async function readPendingHandoverDecision( + userId: string, + now: Date = new Date(), +): Promise { + const row = await latestClaimedHandover(prisma, userId); + if (!row) return null; + const state = parseHandoverState(row.proposalsJson); + if (!state?.claim || state.claim.decidedAt !== null) return null; + + const ids = state.claim.guardians.map((e) => e.guardianId); + const [people, liveGrants] = await Promise.all([ + prisma.user.findMany({ + where: { id: { in: ids } }, + select: { id: true, displayName: true, username: true }, + }), + prisma.accountGrant.findMany({ + where: { grantorId: userId, granteeId: { in: ids }, revokedAt: null }, + select: { + id: true, + granteeId: true, + access: true, + acceptedAt: true, + revokedAt: true, + expiresAt: true, + }, + }), + ]); + + const guardians: PendingHandoverGuardian[] = []; + for (const entry of state.claim.guardians) { + const person = people.find((p) => p.id === entry.guardianId); + // A Guardian whose account is gone has nothing left to decide. + if (!person) continue; + const live = + liveGrants.find((g) => g.granteeId === entry.guardianId) ?? null; + const decidable = isUntouched(entry, live); + guardians.push({ + grantId: entry.grantId, + displayName: nameOf(person), + proposal: entry.proposal, + current: decidable + ? entry.applied + : handoverAccessOf(live && isGrantActive(live, now) ? live : null), + decidable, + }); + } + return { + handoverId: row.id, + claimedAt: new Date(state.claim.claimedAt), + guardians, + }; +} + +export interface HandoverDecisionResult { + /** Guardians whose access this decision changed, for their notification. */ + changed: { guardianId: string; access: HandoverAccess }[]; +} + +/** + * Record the new owner's final word on each former Guardian. + * + * A Guardian the body leaves out keeps the access the claim gave them. One the + * owner already changed elsewhere, or who stepped away, is left alone. Every + * change ends a row and writes a new one, as any change of level does. + */ +export async function decideHandover(input: { + userId: string; + decisions: { grantId: string; access: HandoverAccess }[]; + now?: Date; +}): Promise { + const now = input.now ?? new Date(); + return prisma.$transaction((tx) => + // The same key every lifecycle act on this record takes. The marker is + // gone, so nothing here depends on it; the lock orders this against a + // second decision from another tab. + withManagedProfileLock(tx, input.userId, async () => { + const row = await latestClaimedHandover(tx, input.userId); + const state = row ? parseHandoverState(row.proposalsJson) : null; + if (!row || !state?.claim || state.claim.decidedAt !== null) { + throw new HandoverError("no_pending"); + } + const claim = state.claim; + const known = new Set(claim.guardians.map((e) => e.grantId)); + if (input.decisions.some((d) => !known.has(d.grantId))) { + throw new HandoverError("unknown_guardian"); + } + + const changed: HandoverDecisionResult["changed"] = []; + const outcome: { + grantId: string; + access: HandoverAccess; + changed: boolean; + skipped: boolean; + }[] = []; + const guardians: StoredClaimEntry[] = []; + for (const entry of claim.guardians) { + const target = + input.decisions.find((d) => d.grantId === entry.grantId)?.access ?? + entry.applied; + const live = await tx.accountGrant.findFirst({ + where: { + grantorId: input.userId, + granteeId: entry.guardianId, + revokedAt: null, + }, + select: { id: true, access: true, scopeJson: true }, + }); + if (!isUntouched(entry, live)) { + guardians.push(entry); + outcome.push({ + grantId: entry.grantId, + access: target, + changed: false, + skipped: true, + }); + continue; + } + const settled = await settleHandoverGuardianGrant(tx, { + recordId: input.userId, + guardianId: entry.guardianId, + liveGrant: live, + target, + now, + }); + guardians.push({ + ...entry, + decided: target, + currentGrantId: settled.grantId, + }); + outcome.push({ + grantId: entry.grantId, + access: target, + changed: settled.changed, + skipped: false, + }); + if (settled.changed) { + changed.push({ guardianId: entry.guardianId, access: target }); + await auditLog("managed_profile.handover.access_changed", { + userId: entry.guardianId, + actorUserId: null, + details: { profileId: input.userId, access: target }, + client: tx, + }); + } + } + + await tx.managedProfileHandover.update({ + where: { id: row.id }, + data: { + proposalsJson: toJson({ + ...state, + claim: { ...claim, guardians, decidedAt: now.toISOString() }, + }), + }, + }); + await auditLog("managed_profile.handover.decided", { + userId: input.userId, + details: { handoverId: row.id, decisions: outcome }, + client: tx, + }); + return { changed }; + }), + ); +} diff --git a/src/lib/managed-profiles/lifecycle.ts b/src/lib/managed-profiles/lifecycle.ts index 5ab65a432..8ea7ad142 100644 --- a/src/lib/managed-profiles/lifecycle.ts +++ b/src/lib/managed-profiles/lifecycle.ts @@ -407,21 +407,36 @@ export async function updateManagedProfile(input: { } /** - * Internal-only future compatibility hook. There is intentionally no route for - * this state change in the current product; holding the same lock ensures any - * later handover cannot race a Guardian reduction. + * Clear the managed-profile marker: the record becomes an ordinary account. + * + * v1.42 (#959) — the handover's claim (`handover.ts`) is the one product path + * that calls this, inside its own transaction and in the same commit that sets + * the new owner's credentials and settles every Guardian's access. Passing + * `tx` runs it there; the advisory lock is re-entrant inside one transaction, + * so the claim, which already holds it, takes it again here at no cost. + * Without `tx` it opens a transaction of its own, which is what the lifecycle + * race tests drive it through. + * + * Holding the same lock is the point either way: an acceptance, a Guardian + * removal or a deletion that races the clearing is ordered entirely before or + * after it. */ -export async function clearManagedProfileMarker(input: { - profileId: string; -}): Promise { - await prisma.$transaction(async (tx) => { - await withManagedProfileLock(tx, input.profileId, async (profile) => { +export async function clearManagedProfileMarker( + input: { profileId: string }, + tx?: Transaction, +): Promise { + const clear = (client: Transaction) => + withManagedProfileLock(client, input.profileId, async (profile) => { if (!profile) throw new ManagedProfileLifecycleError("not_found"); if (!profile.managedProfileAt) return; - await tx.user.update({ + await client.user.update({ where: { id: profile.id }, data: { managedProfileAt: null }, }); }); - }); + if (tx) { + await clear(tx); + return; + } + await prisma.$transaction(clear); } diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 30c23e921..b11f0caf3 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -234,6 +234,8 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "managed_profile.guardian.duplicate", "managed_profile.guardian.managed_invitee", "managed_profile.guardian.required", + "managed_profile.handover.no_pending", + "managed_profile.handover.unknown_guardian", "managed_profile.not_found", ], measurement: [ @@ -315,6 +317,11 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "profile.update.invalidBody", "profile.update.nothingSaved", ], + profile_claim: [ + "profile_claim.invalid", + "profile_claim.oidc_only_unsupported", + "profile_claim.taken", + ], record_write: ["record_write.rate_limited"], "report-selection": [ "report-selection.body.invalid_json", diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index fe8f6c784..657d062e7 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -36,14 +36,14 @@ import { inviteManagedProfileGuardianSchema, updateManagedProfileSchema, } from "@/lib/validations/managed-profiles"; +import { + createHandoverSchema, + handoverAccessSchema, + handoverDecisionSchema, +} from "@/lib/validations/managed-profile-handover"; import { onboardingStateResource } from "./onboarding"; import { aiAccountBlock, moduleAccessMap } from "./profile"; -import { - dataEnvelope, - errorEnvelope, - notImplementedResponse, - stdResponses, -} from "./shared"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; /** * The four request bodies this table publishes, and the one thing to know about @@ -551,32 +551,17 @@ const managedProfileEnvelope = dataEnvelope( ); // v1.42 (#959) — handing a managed profile over to the person it describes. -const handoverProposal = z - .enum(["end", "read", "manage"]) +const handoverAccess = handoverAccessSchema .describe( - "What a guardian's access becomes after the handover: ended, view only, or still managing. The new owner confirms or changes it when claiming.", + "What a guardian's access becomes after the handover: ended (`end`), view only (`read`), or still managing (`manage`).", ) - .meta({ id: "ManagedProfileHandoverProposal" }); + .meta({ id: "ManagedProfileHandoverAccess" }); -const createHandoverRequest = z - .object({ - expiresInDays: z - .union([z.literal(1), z.literal(7), z.literal(14)]) - .default(7) - .describe("How long the link stays valid."), - proposals: z - .array( - z.object({ - grantId: z.string().describe("The guardian's MANAGE grant."), - proposal: handoverProposal, - }), - ) - .describe( - "One proposal per active guardian. A guardian left out is proposed `read`.", - ), - }) - .strict() - .meta({ id: "CreateManagedProfileHandoverRequest" }); +const createHandoverRequest = createHandoverSchema.meta({ + id: "CreateManagedProfileHandoverRequest", + description: + "`expiresInDays` is 1, 7 (default) or 14. `proposals` names at most one entry per active guardian grant; a guardian left out is proposed `read`, and a grant that is not an active guardian of this profile is refused with 422.", +}); const managedProfileHandover = z .object({ @@ -591,13 +576,93 @@ const managedProfileHandover = z }) .meta({ id: "ManagedProfileHandover" }); +const managedProfileHandoverStatus = z + .object({ + available: z + .boolean() + .describe( + "False on an instance that allows single sign-on only, where a handover cannot be created.", + ), + open: z + .object({ + id: z.string(), + createdAt: z.iso.datetime({ offset: true }), + expiresAt: z.iso.datetime({ offset: true }), + createdByYou: z.boolean(), + }) + .nullable() + .describe( + "The profile's open link, or null. Never the link itself: only its hash is stored.", + ), + }) + .meta({ id: "ManagedProfileHandoverStatus" }); + +const handoverDecisionRequest = handoverDecisionSchema.meta({ + id: "HandoverDecisionRequest", + description: + "The new owner's decision per former guardian, named by the guardian's grant id at the claim. A guardian left out keeps the access the claim gave them.", +}); + +const pendingHandoverDecision = z + .object({ + pending: z + .object({ + claimedAt: z.iso.datetime({ offset: true }), + guardians: z.array( + z.object({ + grantId: z + .string() + .describe( + "The guardian's grant at the claim; what a decision names.", + ), + displayName: z.string(), + proposal: handoverAccess, + current: handoverAccess, + decidable: z + .boolean() + .describe( + "False when the access moved since the claim (the guardian stepped away, or it was changed under shared access); such a row is shown and left alone.", + ), + }), + ), + }) + .nullable(), + }) + .meta({ id: "PendingHandoverDecision" }); + export const accountSharingPaths: NonNullable = { "/api/managed-profiles/{id}/handover": { + get: { + tags: ["Account sharing"], + summary: "Whether a handover link is open", + description: + "The profile's open handover link, if any, and whether this instance allows creating one. Never returns the link: only its hash is stored. Cookie-only, no step-up.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + ...stdResponses, + "200": { + description: "The status.", + content: { + "application/json": { + schema: dataEnvelope( + managedProfileHandoverStatus, + "ManagedProfileHandoverStatusEnvelope", + ), + }, + }, + }, + "404": { + description: + "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, post: { tags: ["Account sharing"], summary: "Create a handover link for a managed profile", description: - "Mints the one-time link that lets the person a managed profile describes take it over as their own account. No data moves: claiming sets credentials on the profile's own record. Each guardian's access afterwards follows the proposal recorded here unless the new owner changes it. Creating a new link withdraws an open one. Pending MANAGE invitations on the profile are withdrawn when it is claimed. Cookie-only and step-up gated, like every act on a managed profile. Refused on an instance that allows single sign-on only.", + "Mints the one-time link that lets the person a managed profile describes take it over as their own account. No data moves: claiming sets credentials on the profile's own record. Claiming applies each guardian's proposal at once; the new owner then confirms or changes it on first sign-in, and until they do the proposal holds. Creating a new link withdraws an open one. Pending invitations on the profile are withdrawn when it is claimed. Cookie-only and step-up gated, like every act on a managed profile; ten an hour per caller. Refused on an instance that allows single sign-on only.", requestParams: { path: z.object({ id: z.string() }) }, requestBody: { required: true, @@ -626,7 +691,11 @@ export const accountSharingPaths: NonNullable = { "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`).", content: { "application/json": { schema: errorEnvelope } }, }, - ...notImplementedResponse, + "422": { + description: + "The body is invalid, or a proposal names a grant that is not an active guardian of this profile (`meta.errorCode: managed_profile.handover.unknown_guardian`).", + content: { "application/json": { schema: errorEnvelope } }, + }, }, }, delete: { @@ -653,7 +722,68 @@ export const accountSharingPaths: NonNullable = { "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`).", content: { "application/json": { schema: errorEnvelope } }, }, - ...notImplementedResponse, + }, + }, + }, + "/api/account/handover-decision": { + get: { + tags: ["Account sharing"], + summary: "The handover decision waiting for a new owner", + description: + "After claiming a managed profile, the new owner decides each former guardian's access. This answers that decision, or `pending: null` when none is waiting. Refused while acting on another account.", + responses: { + ...stdResponses, + "200": { + description: "The waiting decision, or null.", + content: { + "application/json": { + schema: dataEnvelope( + pendingHandoverDecision, + "PendingHandoverDecisionEnvelope", + ), + }, + }, + }, + }, + }, + post: { + tags: ["Account sharing"], + summary: "Decide each former guardian's access", + description: + "Records the new owner's final decision per former guardian. A changed level ends the guardian's grant and writes a new one; `manage` can restore access a guardian held before the handover. After this, access changes through the ordinary grant endpoints. Refused while acting on another account.", + requestBody: { + required: true, + content: { "application/json": { schema: handoverDecisionRequest } }, + }, + responses: { + ...stdResponses, + "200": { + description: "Decided.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + decided: z.literal(true), + changed: z + .number() + .int() + .describe("How many guardians' access this changed."), + }), + "HandoverDecisionEnvelope", + ), + }, + }, + }, + "409": { + description: + "No decision is waiting (`meta.errorCode: managed_profile.handover.no_pending`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "422": { + description: + "The body is invalid, or a decision names somebody who was not a guardian at the claim (`meta.errorCode: managed_profile.handover.unknown_guardian`).", + content: { "application/json": { schema: errorEnvelope } }, + }, }, }, }, diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index d38f507b0..121243170 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -46,9 +46,13 @@ import { stdResponses, errorEnvelope, loginPasswordSchema, - notImplementedResponse, } from "./shared"; import { passkeyLoginOptionsSchema } from "@/lib/validations/auth"; +import { + claimPreviewSchema, + claimSchema, +} from "@/lib/validations/managed-profile-handover"; +import { HANDOVER_ACCESS_LEVELS } from "@/lib/managed-profiles/handover-access"; // ── Sub-schemas owned here (route-specific shapes) ─────────────────── @@ -699,48 +703,42 @@ const codexDevicePollResponse = z .meta({ id: "CodexDevicePollResponse" }); // v1.42 (#959) — claiming a managed profile through its handover link. -const claimTokenField = z - .string() - .describe( - "The `hlp_` token from the handover link. Sent in the body, never in a URL.", - ); +const claimPreviewRequest = claimPreviewSchema.meta({ + id: "ProfileClaimPreviewRequest", + description: + "`token` is the `hlp_` token from the handover link. Sent in the body, never in a URL.", +}); -const claimPreviewRequest = z - .object({ token: claimTokenField }) - .strict() - .meta({ id: "ProfileClaimPreviewRequest" }); +const claimAccess = z + .enum(HANDOVER_ACCESS_LEVELS) + .meta({ id: "ProfileClaimGuardianAccess" }); const claimPreview = z .object({ - displayName: z.string(), + displayName: z + .string() + .nullable() + .describe("The profile's name, as its guardians set it."), expiresAt: z.iso.datetime({ offset: true }), - guardians: z.array( - z.object({ - grantId: z.string(), - displayName: z.string(), - proposal: z.enum(["end", "read", "manage"]), - }), - ), - }) - .meta({ id: "ProfileClaimPreview" }); - -const claimRequest = z - .object({ - token: claimTokenField, - username: z.string(), - email: z.string().describe("Required: the account needs a way back in."), - password: z.string(), guardians: z .array( z.object({ grantId: z.string(), - decision: z.enum(["end", "read", "manage"]), + displayName: z.string(), + proposal: claimAccess, }), ) - .describe("The new owner's decision per guardian."), + .describe( + "Each guardian and the access they keep after the claim, as proposed. The new owner confirms or changes it after signing in.", + ), }) - .strict() - .meta({ id: "ProfileClaimRequest" }); + .meta({ id: "ProfileClaimPreview" }); + +const claimRequest = claimSchema.meta({ + id: "ProfileClaimRequest", + description: + "`token` is the `hlp_` token from the handover link, in the body. `username` follows the registration rules and may not start with `managed-`; `email` is required, because the account needs a way back in; `password` follows the registration password policy.", +}); export const authPaths: NonNullable = { "/api/auth/claim/preview": { @@ -781,7 +779,6 @@ export const authPaths: NonNullable = { content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, - ...notImplementedResponse, }, }, }, @@ -792,7 +789,7 @@ export const authPaths: NonNullable = { tags: ["Auth"], summary: "Claim a managed profile", description: - "Turns the managed profile behind a handover link into the claimant's own account: sets the username, email and password on the profile's record, clears the managed marker, settles each guardian's access by the claimant's decision, withdraws pending MANAGE invitations, and resets the disclaimer, onboarding and AI consent so the new owner gives them personally. No data moves. Signs the new owner in. Anonymous; the token travels in the body, and every token failure answers the same 404.", + "Turns the managed profile behind a handover link into the claimant's own account, in one transaction: sets the username, email and password on the profile's record, clears the managed marker, withdraws pending invitations, applies each guardian's proposed access, and resets the disclaimer, onboarding and AI consent so the new owner gives them personally. The new owner then decides each guardian's access on first sign-in (`/api/account/handover-decision`). No data moves. Signs the new owner in. Anonymous; the token travels in the body, every token failure answers the same 404, and five attempts per fifteen minutes per source address are allowed.", requestBody: { required: true, content: { "application/json": { schema: claimRequest } }, @@ -821,11 +818,10 @@ export const authPaths: NonNullable = { }, "409": { description: - "The request carries a signed-in session (`meta.errorCode: auth.already_authenticated`), or the username or email is taken.", + "The request carries a signed-in session (`meta.errorCode: auth.already_authenticated`), or the username or email is taken (`meta.errorCode: profile_claim.taken`).", content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, - ...notImplementedResponse, }, }, }, diff --git a/src/lib/queries/use-handover-decision.ts b/src/lib/queries/use-handover-decision.ts new file mode 100644 index 000000000..4141596b3 --- /dev/null +++ b/src/lib/queries/use-handover-decision.ts @@ -0,0 +1,68 @@ +"use client"; + +/** + * v1.42 (#959) — the decision a new owner owes about their former Guardians, + * after claiming a managed profile. + * + * Read by the decision screen the claim lands on (`/onboarding/handover`) and + * by the card at the top of Settings → Shared access, which keeps offering it + * until it is made. Deciding refreshes the grant reads as well, because every + * change it makes is an ordinary grant row the sharing panel lists. + */ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { apiGet, apiPost } from "@/lib/api/api-fetch"; +import type { HandoverAccess } from "@/lib/managed-profiles/handover-access"; +import { invalidateGrantReads } from "@/lib/queries/use-account-grants"; +import { queryKeys } from "@/lib/query-keys"; + +export interface PendingHandoverGuardian { + grantId: string; + displayName: string; + proposal: HandoverAccess; + current: HandoverAccess; + decidable: boolean; +} + +export interface HandoverDecisionState { + pending: { + claimedAt: string; + guardians: PendingHandoverGuardian[]; + } | null; +} + +export function useHandoverDecision(enabled = true) { + return useQuery({ + queryKey: queryKeys.handoverDecision(), + queryFn: () => + apiGet("/api/account/handover-decision"), + enabled, + }); +} + +/** The wire body, composed once and exported for the integration suite. */ +export function handoverDecisionBody( + decisions: { grantId: string; access: HandoverAccess }[], +): { decisions: { grantId: string; access: HandoverAccess }[] } { + return { + decisions: decisions.map((d) => ({ grantId: d.grantId, access: d.access })), + }; +} + +export function useDecideHandover() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: queryKeys.handoverDecide(), + mutationFn: (decisions: { grantId: string; access: HandoverAccess }[]) => + apiPost<{ decided: true; changed: number }>( + "/api/account/handover-decision", + handoverDecisionBody(decisions), + ), + onSuccess: () => { + invalidateGrantReads(queryClient); + void queryClient.invalidateQueries({ + queryKey: queryKeys.handoverDecision(), + }); + }, + }); +} diff --git a/src/lib/queries/use-managed-profiles.ts b/src/lib/queries/use-managed-profiles.ts index eaaed7e26..429517734 100644 --- a/src/lib/queries/use-managed-profiles.ts +++ b/src/lib/queries/use-managed-profiles.ts @@ -32,6 +32,7 @@ import { queryKeys } from "@/lib/query-keys"; import { invalidateGrantReads } from "@/lib/queries/use-account-grants"; import type { GrantParty } from "@/lib/queries/use-account-grants"; import type { Locale } from "@/lib/i18n/config"; +import type { HandoverAccess } from "@/lib/managed-profiles/handover-access"; /** * Exactly the body `POST /api/managed-profiles` accepts. @@ -340,3 +341,112 @@ export function useRemoveManagedProfileGuardian() { onSuccess: invalidate, }); } + +// ── Handover (v1.42, #959) ────────────────────────────────────────────────── + +/** What `GET /api/managed-profiles/{id}/handover` answers. */ +export interface ManagedProfileHandoverStatus { + /** False on a single-sign-on-only instance, where no link can be made. */ + available: boolean; + open: { + id: string; + createdAt: string; + expiresAt: string; + createdByYou: boolean; + } | null; +} + +/** The minted link. `token` and `url` exist in this response only. */ +export interface ManagedProfileHandoverLink { + id: string; + token: string; + url: string; + expiresAt: string; +} + +/** + * Exactly the body `POST /api/managed-profiles/{id}/handover` accepts, plus + * the profile it is addressed to. The route is `.strict()`, so the wire body + * is composed by {@link handoverCreateBody} rather than by spreading this. + */ +export interface CreateManagedProfileHandoverInput { + profileId: string; + expiresInDays: 1 | 7 | 14; + proposals: { grantId: string; proposal: HandoverAccess }[]; +} + +/** The wire body, composed once and exported for the integration suite. */ +export function handoverCreateBody(input: CreateManagedProfileHandoverInput): { + expiresInDays: 1 | 7 | 14; + proposals: { grantId: string; proposal: HandoverAccess }[]; +} { + return { + expiresInDays: input.expiresInDays, + proposals: input.proposals.map((p) => ({ + grantId: p.grantId, + proposal: p.proposal, + })), + }; +} + +/** Whether this profile has an open handover link. Never the link itself. */ +export function useManagedProfileHandover(profileId: string, enabled = true) { + return useQuery({ + queryKey: queryKeys.managedProfileHandover(profileId), + queryFn: () => + apiGet( + `/api/managed-profiles/${profileId}/handover`, + ), + enabled, + }); +} + +/** + * Mint the handover link. Step-up gated like every act in this family, so a + * 401 carrying `auth.stepup.required` is the expected first answer. + * + * The link lives in the mutation's `data` and nowhere else: it is shown once + * and never cached under a query key. + */ +export function useCreateManagedProfileHandover() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: queryKeys.managedProfileHandoverCreate(), + mutationFn: async (input: CreateManagedProfileHandoverInput) => { + const link = await apiPost( + `/api/managed-profiles/${input.profileId}/handover`, + handoverCreateBody(input), + ); + // Lazy chunk, as the admin invite and the share link do: the QR encoder + // is needed here, at mint time, and nowhere else. + const QRCode = (await import("qrcode")).default; + const qrDataUrl = await QRCode.toDataURL(link.url, { + width: 240, + margin: 1, + }); + return { ...link, qrDataUrl }; + }, + onSuccess: (_link, input) => { + void queryClient.invalidateQueries({ + queryKey: queryKeys.managedProfileHandover(input.profileId), + }); + }, + }); +} + +/** Withdraw the open link. No step-up: taking it back is never harder. */ +export function useRevokeManagedProfileHandover() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: queryKeys.managedProfileHandoverRevoke(), + mutationFn: (profileId: string) => + apiDelete<{ revoked: boolean }>( + `/api/managed-profiles/${profileId}/handover`, + ), + onSuccess: (_result, profileId) => { + void queryClient.invalidateQueries({ + queryKey: queryKeys.managedProfileHandover(profileId), + }); + }, + }); +} diff --git a/src/lib/queries/use-profile-claim.ts b/src/lib/queries/use-profile-claim.ts new file mode 100644 index 000000000..4cd3b4a73 --- /dev/null +++ b/src/lib/queries/use-profile-claim.ts @@ -0,0 +1,67 @@ +"use client"; + +/** + * v1.42 (#959) — the anonymous side of a managed-profile handover: the + * preview of a link and the claim itself. + * + * The token travels in request BODIES only, never in a query key: a cache key + * is somewhere a credential must not be written, and the preview key is one + * fixed value because one claim page reads one link. + */ +import { useMutation, useQuery } from "@tanstack/react-query"; + +import { apiPost } from "@/lib/api/api-fetch"; +import type { HandoverAccess } from "@/lib/managed-profiles/handover-access"; +import { queryKeys } from "@/lib/query-keys"; + +export interface ProfileClaimPreview { + displayName: string | null; + expiresAt: string; + guardians: { + grantId: string; + displayName: string; + proposal: HandoverAccess; + }[]; +} + +export function useProfileClaimPreview(token: string | null, enabled: boolean) { + return useQuery({ + queryKey: queryKeys.profileClaimPreview(), + queryFn: () => + apiPost("/api/auth/claim/preview", { token }), + enabled: enabled && token !== null, + // A 404 is an answer, not a blip, and a retry would only spend the + // per-address budget the claim itself needs. + retry: false, + staleTime: Infinity, + gcTime: 0, + }); +} + +/** Exactly the body `POST /api/auth/claim` accepts. */ +export interface ProfileClaimInput { + token: string; + username: string; + email: string; + password: string; +} + +export function claimBody(input: ProfileClaimInput): ProfileClaimInput { + return { + token: input.token, + username: input.username, + email: input.email, + password: input.password, + }; +} + +export function useClaimProfile() { + return useMutation({ + mutationKey: queryKeys.profileClaim(), + mutationFn: (input: ProfileClaimInput) => + apiPost<{ userId: string; username: string }>( + "/api/auth/claim", + claimBody(input), + ), + }); +} diff --git a/src/lib/query-keys/sharing.ts b/src/lib/query-keys/sharing.ts index 09a4f14b5..48755f2d4 100644 --- a/src/lib/query-keys/sharing.ts +++ b/src/lib/query-keys/sharing.ts @@ -58,6 +58,28 @@ export const sharingKeys = { managedProfile: (profileId: string) => ["managed-profiles", profileId, "profile"] as const, + /** + * v1.42 (#959) — whether a managed profile has an open handover link (`GET + * /api/managed-profiles/{id}/handover`). Under the family prefix so a grant + * transition refreshes it with the rest. + */ + managedProfileHandover: (profileId: string) => + ["managed-profiles", profileId, "handover"] as const, + + /** + * v1.42 (#959) — the decision a new owner owes about their former + * Guardians (`GET /api/account/handover-decision`). Under `account` like + * the grant list it changes. + */ + handoverDecision: () => ["account", "handover-decision"] as const, + + /** + * v1.42 (#959) — the anonymous preview of a handover link. The token is + * deliberately NOT part of the key: a cache key is a place a credential + * should never be written to, and one claim page reads one link. + */ + profileClaimPreview: () => ["profile-claim", "preview"] as const, + /** Mutation keys — kept in the factory so no bare array reaches a call site. */ accountGrantInvite: () => ["account", "grants", "invite"] as const, accountGrantAccept: () => ["account", "grants", "accept"] as const, @@ -71,4 +93,10 @@ export const sharingKeys = { ["managed-profiles", "guardian", "invite"] as const, managedProfileGuardianRemove: () => ["managed-profiles", "guardian", "remove"] as const, + managedProfileHandoverCreate: () => + ["managed-profiles", "handover", "create"] as const, + managedProfileHandoverRevoke: () => + ["managed-profiles", "handover", "revoke"] as const, + handoverDecide: () => ["account", "handover-decision", "decide"] as const, + profileClaim: () => ["profile-claim", "submit"] as const, }; diff --git a/src/lib/secret-shape.ts b/src/lib/secret-shape.ts index 482378132..845d1aa94 100644 --- a/src/lib/secret-shape.ts +++ b/src/lib/secret-shape.ts @@ -4,7 +4,8 @@ * such text. * * `hlk_` access tokens, `hlr_` refresh tokens, `hls_` clinician share-link - * tokens and session secrets, `hlv_` registration invite tokens, `hle_` + * tokens and session secrets, `hlv_` registration invite tokens, `hlp_` + * managed-profile handover tokens (v1.42), `hle_` * elevation tokens, `hlh_` native sign-in handoff codes, and the MCP OAuth * artifacts `hlac_` (authorization code), `hlrt_` (refresh token) and `hlc_` * (client id); @@ -17,7 +18,7 @@ * that knows the secrets in play must also refuse text containing them. */ const SECRET_SHAPED = - /(?:\b(?:hlk_|hlr_|hls_|hlv_|hle_|hlh_|hlac_|hlrt_|hlc_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/; + /(?:\b(?:hlk_|hlr_|hls_|hlv_|hlp_|hle_|hlh_|hlac_|hlrt_|hlc_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/; export function looksSecretShaped(text: string): boolean { return SECRET_SHAPED.test(text); diff --git a/src/lib/sharing/grants.ts b/src/lib/sharing/grants.ts index 30a51a9c2..38d9f71e5 100644 --- a/src/lib/sharing/grants.ts +++ b/src/lib/sharing/grants.ts @@ -62,6 +62,10 @@ import { reduceManagedProfileGuardian, withManagedProfileLock, } from "@/lib/managed-profiles/lifecycle"; +import { + grantAccessFor, + type HandoverAccess, +} from "@/lib/managed-profiles/handover-access"; import { ENTIRE_RECORD, isShareDomain } from "@/lib/sharing/scope"; import type { ShareDomain, ShareScope } from "@/lib/sharing/scope"; import { Prisma } from "@/generated/prisma/client"; @@ -613,6 +617,19 @@ export async function revokeManagedProfileGuardian(input: { { grantorId: profile.id, granteeId: target.granteeId }, tx, ); + // v1.42 (#959) — a handover link the removed Guardian minted dies with + // their access. The claim refuses it anyway (its creator must still be + // an active Guardian); withdrawing it here keeps the other Guardians' + // panel from showing a link that can no longer be used. + await tx.managedProfileHandover.updateMany({ + where: { + profileId: profile.id, + createdById: target.granteeId, + usedAt: null, + revokedAt: null, + }, + data: { revokedAt: now }, + }); await auditLog("managed_profile.guardian.revoked", { userId: profile.id, actorUserId: input.guardianId, @@ -624,6 +641,139 @@ export async function revokeManagedProfileGuardian(input: { }); } +// ── Handover (v1.42, #959) ────────────────────────────────────────────────── + +/** + * Withdraw every invitation the record has offered and nobody has accepted. + * + * Called when a managed profile is claimed. A pending MANAGE invitation on a + * managed profile is a Guardian invitation; once the record belongs to an + * adult, `acceptGrant` would still accept it, and the invitee would become a + * MANAGE delegate of a person who never agreed to them. Withdrawing every + * pending row the record has offered closes that, and nothing else is lost: + * the new owner invites whoever they want themselves. + * + * `revokedBy = GRANTOR` because the record's owner is the party ending them. + * Must run inside the caller's transaction and under the managed-profile lock, + * which is what orders it against a concurrent acceptance. + */ +export async function withdrawPendingGrantsOfRecord( + tx: Prisma.TransactionClient, + recordId: string, + now: Date, +): Promise { + const { count } = await tx.accountGrant.updateMany({ + where: { grantorId: recordId, acceptedAt: null, revokedAt: null }, + data: { revokedAt: now, revokedBy: "GRANTOR" }, + }); + return count; +} + +/** What settling one Guardian's access did. */ +export interface HandoverGrantSettlement { + /** The level the Guardian now holds. */ + access: HandoverAccess; + /** The live grant afterwards, or null when access ended. */ + grantId: string | null; + /** Whether a row was ended or written. */ + changed: boolean; + /** How many of the Guardian's browser sessions were inside the record. */ + sessionsCleared: number; +} + +/** + * Set one former Guardian's access to a managed profile that is being, or has + * just been, handed over. The one place a handover moves a grant. + * + * The rules of WHICH level to set live in `handover-access.ts`; this is the + * mechanics, and it follows the state machine above: + * + * - the level a row carries never moves. Changing it ends the row + * (`revokedBy = GRANTOR`, the record's owner deciding) and writes a new + * one, so the history keeps "who had what, from when to when"; + * - ending a row and clearing the sessions inside it are one act, in the + * caller's transaction, as {@link endAndClear} has it for the panel; + * - a row that already says what the target says is left alone. + * + * The new row is written ACCEPTED, which is the one place this file creates a + * grant without an acceptance, and it is argued rather than assumed. The + * Guardian accepted MANAGE over this whole record when they became a Guardian; + * a handover only ever sets a level at or below that, and only for somebody + * who held it a moment earlier. A READ row cut from their MANAGE is a + * narrowing they need no new consent for, and a MANAGE row the new owner + * restores is the consent they already gave. What it cannot do is reach + * anybody who was not a Guardian of this record, or reach above MANAGE. + * + * Callers hold the managed-profile lock (`withManagedProfileLock`) for the + * transaction, so the live row read beside this call is the row it settles. + */ +export async function settleHandoverGuardianGrant( + tx: Prisma.TransactionClient, + input: { + recordId: string; + guardianId: string; + /** The pair's live row (`revokedAt IS NULL`), or null. */ + liveGrant: Pick | null; + target: HandoverAccess; + now: Date; + }, +): Promise { + const pair = { grantorId: input.recordId, granteeId: input.guardianId }; + const targetAccess = grantAccessFor(input.target); + const live = input.liveGrant; + + const alreadyThere = + live !== null && + targetAccess !== null && + live.access === targetAccess && + (live.access === "MANAGE" || live.scopeJson === null); + if (alreadyThere) { + return { + access: input.target, + grantId: live.id, + changed: false, + sessionsCleared: 0, + }; + } + if (live === null && targetAccess === null) { + return { + access: "end", + grantId: null, + changed: false, + sessionsCleared: 0, + }; + } + + if (live !== null) { + await tx.accountGrant.updateMany({ + where: { id: live.id, revokedAt: null }, + data: { revokedAt: input.now, revokedBy: "GRANTOR" }, + }); + } + + let grantId: string | null = null; + if (targetAccess !== null) { + const created = await tx.accountGrant.create({ + data: { + grantorId: input.recordId, + granteeId: input.guardianId, + access: targetAccess, + // `DbNull`, never `null`: see `inviteGrant` for why the two nulls + // mean opposite things on this column. + scopeJson: Prisma.DbNull, + invitedAt: input.now, + acceptedAt: input.now, + expiresAt: null, + }, + select: { id: true }, + }); + grantId = created.id; + } + + const sessionsCleared = await clearActingSessions(pair, tx); + return { access: input.target, grantId, changed: true, sessionsCleared }; +} + export interface RevokeGrantInput { grantId: string; /** The owner ending their own grant. Taken from the session. */ diff --git a/src/lib/validations/auth.ts b/src/lib/validations/auth.ts index 758bed0fc..61322e455 100644 --- a/src/lib/validations/auth.ts +++ b/src/lib/validations/auth.ts @@ -6,19 +6,39 @@ import { clientTransportSchema } from "@/lib/validations/client-transport"; /** Minimum password length — must match checkPasswordStrength() in @/lib/auth/password.ts */ const PASSWORD_MIN_LENGTH = 12; +/** + * v1.42 (#959) — the prefix a managed profile's generated username carries + * (`managed-`, `src/lib/managed-profiles/create.ts`). An account a person + * names themselves may not start with it: a self-chosen `managed-…` would read + * as a profile nobody signs into, in every list that shows usernames, and a + * profile being claimed has to leave the generated name behind rather than + * keep it. + */ +export const MANAGED_USERNAME_PREFIX = "managed-"; + +/** A username a person chooses: registration and claiming a profile. */ +export const accountUsernameSchema = z + .string() + .min(3, "At least 3 characters") + .max(30, "Maximum 30 characters") + .regex(/^[a-zA-Z0-9_-]+$/, "Only letters, numbers, _ and -") + .refine( + (value) => !value.toLowerCase().startsWith(MANAGED_USERNAME_PREFIX), + `A username cannot start with "${MANAGED_USERNAME_PREFIX}"`, + ); + +/** A first password: registration and claiming a profile. */ +export const newAccountPasswordSchema = z + .string() + .min( + PASSWORD_MIN_LENGTH, + `Password must be at least ${PASSWORD_MIN_LENGTH} characters`, + ); + export const registerSchema = z.object({ email: z.email("Invalid email address"), - username: z - .string() - .min(3, "At least 3 characters") - .max(30, "Maximum 30 characters") - .regex(/^[a-zA-Z0-9_-]+$/, "Only letters, numbers, _ and -"), - password: z - .string() - .min( - PASSWORD_MIN_LENGTH, - `Password must be at least ${PASSWORD_MIN_LENGTH} characters`, - ), + username: accountUsernameSchema, + password: newAccountPasswordSchema, /** * v1.4.25 W7 — browser-detected timezone. The signup form pulls * `Intl.DateTimeFormat().resolvedOptions().timeZone` and sends it diff --git a/src/lib/validations/managed-profile-handover.ts b/src/lib/validations/managed-profile-handover.ts new file mode 100644 index 000000000..f60b0d28c --- /dev/null +++ b/src/lib/validations/managed-profile-handover.ts @@ -0,0 +1,96 @@ +/** + * v1.42 (#959) — the bodies the managed-profile handover accepts. + * + * Four requests, all `.strict()`: a Guardian minting the link, the anonymous + * preview and claim on the far side of it, and the new owner's decision about + * each former Guardian afterwards. The OpenAPI table publishes these exact + * schemas, so they live here once rather than beside each route. + */ +import { z } from "zod/v4"; + +import { HANDOVER_ACCESS_LEVELS } from "@/lib/managed-profiles/handover-access"; +import { + accountUsernameSchema, + newAccountPasswordSchema, +} from "@/lib/validations/auth"; + +/** What a Guardian's access becomes after the handover. */ +export const handoverAccessSchema = z.enum(HANDOVER_ACCESS_LEVELS); + +/** + * A grant id as the server minted it (cuid). Bounded so a body cannot carry a + * megabyte of identifiers into a lookup. + */ +const grantIdSchema = z.string().min(1).max(64); + +/** + * At most one entry per Guardian, and a profile has a handful. The cap is a + * body-size floor rather than a product rule. + */ +const MAX_GUARDIAN_ENTRIES = 50; + +/** `POST /api/managed-profiles/{id}/handover`. */ +export const createHandoverSchema = z + .object({ + // How long the link stays valid, in days: seven unless the Guardian + // chooses one or fourteen. + expiresInDays: z + .union([z.literal(1), z.literal(7), z.literal(14)]) + .default(7), + proposals: z + .array( + z + .object({ grantId: grantIdSchema, proposal: handoverAccessSchema }) + .strict(), + ) + .max(MAX_GUARDIAN_ENTRIES) + .default([]) + .refine( + (entries) => + new Set(entries.map((entry) => entry.grantId)).size === + entries.length, + { message: "Name each guardian once" }, + ), + }) + .strict(); + +/** + * The token field of the two anonymous requests. Shape-bounded here only to + * keep the body small; the `hlp_` shape itself is checked by the service so + * that a malformed token and an unknown one answer identically. + */ +const claimTokenSchema = z.string().min(1).max(80); + +/** `POST /api/auth/claim/preview`. */ +export const claimPreviewSchema = z + .object({ token: claimTokenSchema }) + .strict(); + +/** `POST /api/auth/claim`. */ +export const claimSchema = z + .object({ + token: claimTokenSchema, + username: accountUsernameSchema, + email: z.email("Invalid email address").max(254), + password: newAccountPasswordSchema.max(1024), + }) + .strict(); + +/** `POST /api/account/handover-decision`. */ +export const handoverDecisionSchema = z + .object({ + decisions: z + .array( + z + .object({ grantId: grantIdSchema, access: handoverAccessSchema }) + .strict(), + ) + .max(MAX_GUARDIAN_ENTRIES) + .refine( + (entries) => + new Set(entries.map((entry) => entry.grantId)).size === + entries.length, + { message: "Name each guardian once" }, + ), + }) + .strict(); diff --git a/tests/integration/managed-profile-handover.test.ts b/tests/integration/managed-profile-handover.test.ts new file mode 100644 index 000000000..9a4d49091 --- /dev/null +++ b/tests/integration/managed-profile-handover.test.ts @@ -0,0 +1,998 @@ +/** + * v1.42 (#959) — handing a managed profile over to the person it describes, + * through the real routes and a real Postgres. + * + * Every request body is composed by the CLIENT's own builder + * (`handoverCreateBody`, `claimBody`, `handoverDecisionBody`), so a renamed + * field on either end fails here rather than in production. + * + * What is proved, in the order a handover happens: + * + * - the link: shown once, stored only as a hash, at most one open per + * profile, withdrawable, expiring, single-use, dead with its creator; + * - the gate on minting it: cookie and a fresh second factor, never Bearer; + * - the claim: one transaction that sets credentials, clears the marker, + * withdraws pending invitations, applies each proposal, resets disclaimer, + * onboarding and AI consent, audits every step and consumes the token — + * and a failure anywhere in it leaves the profile exactly as it was; + * - the races that matter: two claims, a claim against a new link, a claim + * against the profile's deletion; + * - the anonymous surface: uniform 404, live-session 409, rate limit, and + * the refusal on a single-sign-on-only instance; + * - the new owner's decision afterwards, including a restored MANAGE and a + * row that moved since the claim and is left alone. + */ +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; +import { hashToken } from "@/lib/auth/hmac"; +import { verifyPassword } from "@/lib/auth/password"; +import { createManagedProfile } from "@/lib/managed-profiles/create"; +import { + acceptGrant, + GrantError, + inviteGrant, + renounceGrantAndClearSwitch, + revokeManagedProfileGuardian, +} from "@/lib/sharing/grants"; +import { deleteManagedProfile } from "@/lib/managed-profiles/lifecycle"; +import { + claimManagedProfile, + createHandover, +} from "@/lib/managed-profiles/handover"; +import { resolveManagedGuardianRecipientIds } from "@/lib/notifications/delivery-identity"; +import { handoverCreateBody } from "@/lib/queries/use-managed-profiles"; +import { claimBody } from "@/lib/queries/use-profile-claim"; +import { handoverDecisionBody } from "@/lib/queries/use-handover-decision"; +import type { HandoverAccess } from "@/lib/managed-profiles/handover-access"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar, queuedSessionIds } = + await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => { + const snapshot = new Map(cookieJar); + const queuedSessionId = queuedSessionIds.shift(); + if (queuedSessionId) { + snapshot.set("healthlog_session", queuedSessionId); + } + return { + get: (name: string) => { + const value = snapshot.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), +})); + +// The breach corpus is a network call; the claim's own logic is the subject. +vi.mock("@/lib/password-breach-check", () => ({ + checkPasswordBreachIfEnabled: vi.fn().mockResolvedValue(null), +})); + +// The outbox. Guardians are told on their own channels; what is sent, to +// whom, is read back from here instead of from a mail server. +const sent: { userId: string; titleKey: string; messageKey: string }[] = []; +vi.mock("@/lib/notifications/dispatch-localised", () => ({ + dispatchLocalisedNotification: vi.fn( + async (opts: { userId: string; titleKey: string; messageKey: string }) => { + sent.push({ + userId: opts.userId, + titleKey: opts.titleKey, + messageKey: opts.messageKey, + }); + }, + ), +})); + +const PASSWORD = "correct horse battery staple 1959"; + +let sequence = 0; + +interface Person { + id: string; + username: string; + sessionId: string; +} + +/** An account with a second factor and a session that has just proved it. */ +async function person(label: string, freshMfa = true): Promise { + const suffix = sequence++; + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: `${label}-${suffix}`, + email: `${label}-${suffix}@example.test`, + totpConfirmedAt: new Date(), + }, + }); + const session = await prisma.session.create({ + data: { + userId: user.id, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + mfaVerifiedAt: freshMfa ? new Date() : null, + }, + }); + return { id: user.id, username: user.username, sessionId: session.id }; +} + +function signIn(who: Person): void { + headerJar.delete("authorization"); + cookieJar.set("healthlog_session", who.sessionId); +} + +function signOut(): void { + cookieJar.clear(); + headerJar.delete("authorization"); +} + +/** A profile with two active Guardians: its creator and one more. */ +async function household() { + const creator = await person("creator"); + const second = await person("second"); + const { profile, creatorGrant } = await createManagedProfile({ + creatorId: creator.id, + displayName: "Robin", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const invitation = await inviteGrant({ + grantorId: profile.id, + granteeId: second.id, + access: "MANAGE", + scope: null, + }); + const secondGrant = await acceptGrant({ + grantId: invitation.id, + granteeId: second.id, + }); + return { creator, second, profile, creatorGrant, secondGrant }; +} + +const json = (body: unknown) => ({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), +}); + +async function mint( + profileId: string, + proposals: { grantId: string; proposal: HandoverAccess }[] = [], + expiresInDays: 1 | 7 | 14 = 7, +) { + const { POST } = + await import("@/app/api/managed-profiles/[id]/handover/route"); + return POST( + new NextRequest( + `http://localhost/api/managed-profiles/${profileId}/handover`, + json(handoverCreateBody({ profileId, expiresInDays, proposals })), + ), + { params: Promise.resolve({ id: profileId }) }, + ); +} + +async function mintToken( + profileId: string, + proposals: { grantId: string; proposal: HandoverAccess }[] = [], +): Promise { + const response = await mint(profileId, proposals); + expect(response.status).toBe(201); + const { data } = await response.json(); + return data.token as string; +} + +async function status(profileId: string) { + const { GET } = + await import("@/app/api/managed-profiles/[id]/handover/route"); + return GET( + new NextRequest( + `http://localhost/api/managed-profiles/${profileId}/handover`, + ), + { params: Promise.resolve({ id: profileId }) }, + ); +} + +async function withdraw(profileId: string) { + const { DELETE } = + await import("@/app/api/managed-profiles/[id]/handover/route"); + return DELETE( + new NextRequest( + `http://localhost/api/managed-profiles/${profileId}/handover`, + { method: "DELETE" }, + ), + { params: Promise.resolve({ id: profileId }) }, + ); +} + +async function preview(token: string) { + const { POST } = await import("@/app/api/auth/claim/preview/route"); + return POST( + new NextRequest("http://localhost/api/auth/claim/preview", json({ token })), + ); +} + +async function claim(token: string, overrides: { username?: string } = {}) { + const suffix = sequence++; + const { POST } = await import("@/app/api/auth/claim/route"); + return POST( + new NextRequest( + "http://localhost/api/auth/claim", + json( + claimBody({ + token, + username: overrides.username ?? `robin${suffix}`, + email: `robin-${suffix}@example.test`, + password: PASSWORD, + }), + ), + ), + ); +} + +async function readDecision() { + const { GET } = await import("@/app/api/account/handover-decision/route"); + return GET(new NextRequest("http://localhost/api/account/handover-decision")); +} + +async function decide( + decisions: { grantId: string; access: HandoverAccess }[], +) { + const { POST } = await import("@/app/api/account/handover-decision/route"); + return POST( + new NextRequest( + "http://localhost/api/account/handover-decision", + json(handoverDecisionBody(decisions)), + ), + ); +} + +async function auditActions(userId: string): Promise { + const rows = await getPrismaClient().auditLog.findMany({ + where: { userId }, + orderBy: { createdAt: "asc" }, + select: { action: true }, + }); + return rows.map((r) => r.action); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + sent.length = 0; +}); + +afterEach(async () => { + const prisma = getPrismaClient(); + await prisma.$executeRawUnsafe( + 'DROP TRIGGER IF EXISTS handover_claim_failure ON "managed_profile_handovers"', + ); + await prisma.$executeRawUnsafe( + "DROP FUNCTION IF EXISTS fail_handover_claim()", + ); + delete process.env.OIDC_ONLY; + delete process.env.OIDC_ISSUER_URL; + delete process.env.OIDC_CLIENT_ID; + delete process.env.OIDC_CLIENT_SECRET; +}); + +describe("the handover link", () => { + it("is shown once and stored only as its hash", async () => { + const { creator, profile } = await household(); + signIn(creator); + + const response = await mint(profile.id); + expect(response.status).toBe(201); + const { data } = await response.json(); + expect(data.token).toMatch(/^hlp_[0-9a-f]{64}$/); + expect(data.url).toMatch(new RegExp(`/claim/${data.token}$`)); + + const row = await getPrismaClient().managedProfileHandover.findFirstOrThrow( + { where: { profileId: profile.id } }, + ); + expect(row.tokenHash).toBe(hashToken(data.token)); + expect(JSON.stringify(row)).not.toContain(data.token); + + // The status read never returns the link again. + const read = await (await status(profile.id)).json(); + expect(read.data.open).toMatchObject({ createdByYou: true }); + expect(JSON.stringify(read)).not.toContain(data.token); + + // Nothing written to the audit trail carries it either. + const audit = await getPrismaClient().auditLog.findMany(); + expect(JSON.stringify(audit)).not.toContain(data.token); + }); + + it("keeps at most one link open per profile", async () => { + const { creator, second, profile } = await household(); + signIn(creator); + const first = await mintToken(profile.id); + signIn(second); + const replacement = await mintToken(profile.id); + + const open = await getPrismaClient().managedProfileHandover.count({ + where: { profileId: profile.id, usedAt: null, revokedAt: null }, + }); + expect(open).toBe(1); + + signOut(); + expect((await preview(first)).status).toBe(404); + expect((await preview(replacement)).status).toBe(200); + }); + + it("dies when a Guardian withdraws it", async () => { + const { creator, profile } = await household(); + signIn(creator); + const token = await mintToken(profile.id); + expect((await withdraw(profile.id)).status).toBe(200); + + signOut(); + expect((await preview(token)).status).toBe(404); + expect((await claim(token)).status).toBe(404); + expect(await auditActions(profile.id)).toContain( + "managed_profile.handover.revoked", + ); + }); + + it("dies when it expires", async () => { + const { creator, profile } = await household(); + signIn(creator); + const token = await mintToken(profile.id); + await getPrismaClient().managedProfileHandover.updateMany({ + where: { profileId: profile.id }, + data: { expiresAt: new Date(Date.now() - 1000) }, + }); + + signOut(); + expect((await preview(token)).status).toBe(404); + expect((await claim(token)).status).toBe(404); + }); + + it("dies with its creator's access", async () => { + const { creator, second, profile, creatorGrant } = await household(); + signIn(creator); + const token = await mintToken(profile.id); + + await revokeManagedProfileGuardian({ + profileId: profile.id, + guardianId: second.id, + grantId: creatorGrant.id, + }); + + signIn(second); + const read = await (await status(profile.id)).json(); + expect(read.data.open).toBeNull(); + signOut(); + expect((await preview(token)).status).toBe(404); + }); + + it("is single use", async () => { + const { creator, profile } = await household(); + signIn(creator); + const token = await mintToken(profile.id); + + signOut(); + expect((await claim(token)).status).toBe(201); + signOut(); + expect((await claim(token)).status).toBe(404); + expect((await preview(token)).status).toBe(404); + }); + + it("answers one 404 for every unusable token", async () => { + for (const token of [ + "hlp_" + "0".repeat(64), + "not-a-token", + "hlv_" + "a".repeat(64), + ]) { + const response = await preview(token); + expect(response.status).toBe(404); + expect((await response.json()).meta.errorCode).toBe( + "profile_claim.invalid", + ); + } + }); +}); + +describe("minting the link", () => { + it("needs a fresh second factor", async () => { + const creator = await person("stale", false); + const { profile } = await createManagedProfile({ + creatorId: creator.id, + displayName: "Robin", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + signIn(creator); + const response = await mint(profile.id); + expect(response.status).toBe(401); + expect((await response.json()).meta.errorCode).toBe("auth.stepup.required"); + }); + + it("is unreachable with a Bearer token, even a wildcard one", async () => { + const { creator, profile } = await household(); + const raw = `hlk_${"c".repeat(64)}`; + await getPrismaClient().apiToken.create({ + data: { + userId: creator.id, + name: "handover-test", + tokenHash: hashToken(raw), + permissions: ["*"], + }, + }); + signOut(); + headerJar.set("authorization", `Bearer ${raw}`); + expect((await mint(profile.id)).status).toBe(401); + expect((await status(profile.id)).status).toBe(401); + expect((await withdraw(profile.id)).status).toBe(401); + }); + + it("answers 404 to somebody who is not a Guardian", async () => { + const { profile } = await household(); + const stranger = await person("stranger"); + signIn(stranger); + expect((await mint(profile.id)).status).toBe(404); + expect((await status(profile.id)).status).toBe(404); + expect((await withdraw(profile.id)).status).toBe(404); + }); + + it("refuses a proposal for somebody who is not a Guardian", async () => { + const { creator, profile } = await household(); + signIn(creator); + const response = await mint(profile.id, [ + { grantId: "not-a-guardian", proposal: "manage" }, + ]); + expect(response.status).toBe(422); + expect((await response.json()).meta.errorCode).toBe( + "managed_profile.handover.unknown_guardian", + ); + }); +}); + +describe("claiming the profile", () => { + it("turns the profile into the person's own account, in one go", async () => { + const { creator, second, profile, creatorGrant, secondGrant } = + await household(); + const prisma = getPrismaClient(); + // The record's data, which must not move. + await prisma.measurement.create({ + data: { + userId: profile.id, + type: "WEIGHT", + value: 41, + unit: "kg", + measuredAt: new Date(), + source: "MANUAL", + }, + }); + // A disclaimer and an AI consent a Guardian gave on the person's behalf. + await prisma.user.update({ + where: { id: profile.id }, + data: { + disclaimerAcknowledgedAt: new Date(), + disclaimerAcknowledgedVersion: "1", + onboardingCompletedAt: new Date(), + }, + }); + await prisma.consentReceipt.create({ + data: { + userId: profile.id, + kind: "ai_full", + artefact: "{}", + signedAt: new Date(), + }, + }); + // The second Guardian is inside the record right now. + await prisma.session.update({ + where: { id: second.sessionId }, + data: { actingAsUserId: profile.id }, + }); + // A Guardian invitation nobody accepted yet. + const third = await person("third"); + const pending = await inviteGrant({ + grantorId: profile.id, + granteeId: third.id, + access: "MANAGE", + scope: null, + }); + + signIn(creator); + const token = await mintToken(profile.id, [ + { grantId: creatorGrant.id, proposal: "manage" }, + { grantId: secondGrant.id, proposal: "end" }, + ]); + signOut(); + + const response = await claim(token, { username: "robin" }); + expect(response.status).toBe(201); + expect((await response.json()).data).toEqual({ + userId: profile.id, + username: "robin", + }); + // A session for the new owner, with setup owed. + expect(cookieJar.get("healthlog_session")).toBeTruthy(); + expect(cookieJar.get("hl_onboarding")).toBe("pending"); + + const account = await prisma.user.findUniqueOrThrow({ + where: { id: profile.id }, + }); + expect(account.managedProfileAt).toBeNull(); + expect(account.username).toBe("robin"); + expect(account.email).toMatch(/@example\.test$/); + expect(await verifyPassword(account.passwordHash ?? "", PASSWORD)).toBe( + true, + ); + expect(account.disclaimerAcknowledgedAt).toBeNull(); + expect(account.disclaimerAcknowledgedVersion).toBeNull(); + expect(account.onboardingCompletedAt).toBeNull(); + + // No data moved. + expect( + await prisma.measurement.count({ where: { userId: profile.id } }), + ).toBe(1); + + // The creator keeps MANAGE on the very same row; the second Guardian's + // row ended, ended by the record's owner, and their browser left it. + const kept = await prisma.accountGrant.findUniqueOrThrow({ + where: { id: creatorGrant.id }, + }); + expect(kept.revokedAt).toBeNull(); + const ended = await prisma.accountGrant.findUniqueOrThrow({ + where: { id: secondGrant.id }, + }); + expect(ended.revokedBy).toBe("GRANTOR"); + expect( + await prisma.accountGrant.count({ + where: { grantorId: profile.id, granteeId: second.id, revokedAt: null }, + }), + ).toBe(0); + const secondSession = await prisma.session.findUniqueOrThrow({ + where: { id: second.sessionId }, + }); + expect(secondSession.actingAsUserId).toBeNull(); + + // The pending invitation is withdrawn and can no longer be accepted. + const withdrawn = await prisma.accountGrant.findUniqueOrThrow({ + where: { id: pending.id }, + }); + expect(withdrawn.revokedAt).not.toBeNull(); + await expect( + acceptGrant({ grantId: pending.id, granteeId: third.id }), + ).rejects.toBeInstanceOf(GrantError); + + // The consent a Guardian gave is withdrawn; the person gives their own. + expect( + await prisma.consentReceipt.count({ + where: { userId: profile.id, revokedAt: null }, + }), + ).toBe(0); + + // Reminders no longer fan out to the Guardians. + expect( + await resolveManagedGuardianRecipientIds({ + userId: profile.id, + eventType: "MEDICATION_REMINDER", + title: "t", + message: "m", + } as Parameters[0]), + ).toBeNull(); + + // Every step is on the record's trail and on each Guardian's own. + const recordTrail = await auditActions(profile.id); + expect(recordTrail).toEqual( + expect.arrayContaining([ + "managed_profile.handover.created", + "managed_profile.claimed", + "auth.claim", + ]), + ); + expect(await auditActions(creator.id)).toContain( + "managed_profile.handed_over", + ); + expect(await auditActions(second.id)).toContain( + "managed_profile.handed_over", + ); + + // Each Guardian is told, on their own channels, what their access became. + await vi.waitFor(() => expect(sent).toHaveLength(2)); + expect(sent).toEqual( + expect.arrayContaining([ + { + userId: creator.id, + titleKey: "notifications.handover.claimedTitle", + messageKey: "notifications.handover.claimedManage", + }, + { + userId: second.id, + titleKey: "notifications.handover.claimedTitle", + messageKey: "notifications.handover.claimedEnd", + }, + ]), + ); + }); + + it("cuts a Guardian proposed view-only to a new READ row", async () => { + const { creator, second, profile, secondGrant } = await household(); + signIn(creator); + const token = await mintToken(profile.id, [ + { grantId: secondGrant.id, proposal: "read" }, + ]); + signOut(); + expect((await claim(token)).status).toBe(201); + + const live = await getPrismaClient().accountGrant.findFirstOrThrow({ + where: { grantorId: profile.id, granteeId: second.id, revokedAt: null }, + }); + expect(live.id).not.toBe(secondGrant.id); + expect(live.access).toBe("READ"); + expect(live.acceptedAt).not.toBeNull(); + expect(live.scopeJson).toBeNull(); + // The creator, left out of the proposals, is proposed READ too. + const creatorLive = await getPrismaClient().accountGrant.findFirstOrThrow({ + where: { grantorId: profile.id, granteeId: creator.id, revokedAt: null }, + }); + expect(creatorLive.access).toBe("READ"); + }); + + it("leaves the profile untouched when the transaction fails part-way", async () => { + const { creator, profile, secondGrant } = await household(); + const prisma = getPrismaClient(); + const third = await person("third"); + const pending = await inviteGrant({ + grantorId: profile.id, + granteeId: third.id, + access: "MANAGE", + scope: null, + }); + signIn(creator); + const token = await mintToken(profile.id, [ + { grantId: secondGrant.id, proposal: "end" }, + ]); + signOut(); + + // Fail the claim's LAST write: everything before it has run by then. + await prisma.$executeRawUnsafe(` + CREATE FUNCTION fail_handover_claim() RETURNS trigger AS $$ + BEGIN + RAISE EXCEPTION 'injected handover failure'; + END; + $$ LANGUAGE plpgsql; + `); + await prisma.$executeRawUnsafe(` + CREATE TRIGGER handover_claim_failure + BEFORE UPDATE ON "managed_profile_handovers" + FOR EACH ROW + WHEN (NEW.proposals_json IS DISTINCT FROM OLD.proposals_json) + EXECUTE FUNCTION fail_handover_claim(); + `); + + // The route's error boundary turns the thrown transaction into a 500. + expect((await claim(token)).status).toBe(500); + + const account = await prisma.user.findUniqueOrThrow({ + where: { id: profile.id }, + }); + expect(account.managedProfileAt).not.toBeNull(); + expect(account.username.startsWith("managed-")).toBe(true); + expect(account.passwordHash).toBeNull(); + expect(account.email).toBeNull(); + const row = await prisma.managedProfileHandover.findFirstOrThrow({ + where: { profileId: profile.id }, + }); + expect(row.usedAt).toBeNull(); + expect( + ( + await prisma.accountGrant.findUniqueOrThrow({ + where: { id: secondGrant.id }, + }) + ).revokedAt, + ).toBeNull(); + expect( + ( + await prisma.accountGrant.findUniqueOrThrow({ + where: { id: pending.id }, + }) + ).revokedAt, + ).toBeNull(); + expect(await auditActions(profile.id)).not.toContain( + "managed_profile.claimed", + ); + expect(cookieJar.get("healthlog_session")).toBeUndefined(); + + // With the fault gone, the same link still works. + await prisma.$executeRawUnsafe( + 'DROP TRIGGER handover_claim_failure ON "managed_profile_handovers"', + ); + expect((await claim(token)).status).toBe(201); + }); + + it("lets exactly one of two concurrent claims win", async () => { + const { creator, profile } = await household(); + signIn(creator); + const token = await mintToken(profile.id); + signOut(); + + const statuses = (await Promise.all([claim(token), claim(token)])).map( + (r) => r.status, + ); + expect(statuses.sort()).toEqual([201, 404]); + }); + + it("orders a claim against a new link: the replaced link is dead", async () => { + const { creator, profile } = await household(); + signIn(creator); + const first = await mintToken(profile.id); + signOut(); + + // Through the service, not the routes: the two would otherwise share one + // cookie jar, and the race is between the two transactions, not cookies. + const [claimed, minted] = await Promise.allSettled([ + claimManagedProfile({ + rawToken: first, + username: "racer", + email: "racer@example.test", + passwordHash: "hash", + }), + createHandover({ + profileId: profile.id, + guardianId: creator.id, + expiresInDays: 7, + proposals: [], + }), + ]); + // Either the claim went first (and the mint finds no managed profile), or + // the mint went first (and the claim finds a withdrawn link). + expect( + (claimed.status === "fulfilled" && minted.status === "rejected") || + (claimed.status === "rejected" && minted.status === "fulfilled"), + ).toBe(true); + const account = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: profile.id }, + }); + expect(account.managedProfileAt === null).toBe( + claimed.status === "fulfilled", + ); + }); + + it("orders a claim against the profile's deletion", async () => { + const { creator, profile } = await household(); + signIn(creator); + const token = await mintToken(profile.id); + signOut(); + + const [claimed, deleted] = await Promise.allSettled([ + claim(token), + deleteManagedProfile({ profileId: profile.id, guardianId: creator.id }), + ]); + const account = await getPrismaClient().user.findUnique({ + where: { id: profile.id }, + }); + if (account) { + // The claim won: the account stands, unmanaged, and the deletion was + // refused because there was no managed profile left to delete. + expect(claimed.status === "fulfilled" && claimed.value.status).toBe(201); + expect(account.managedProfileAt).toBeNull(); + expect(deleted.status).toBe("rejected"); + } else { + // The deletion won: the link went with the profile. + expect(claimed.status === "fulfilled" && claimed.value.status).toBe(404); + } + }); + + it("refuses over a live session and on a single-sign-on-only instance", async () => { + const { creator, profile } = await household(); + signIn(creator); + const token = await mintToken(profile.id); + + const overSession = await claim(token); + expect(overSession.status).toBe(409); + expect((await overSession.json()).meta.errorCode).toBe( + "auth.already_authenticated", + ); + + process.env.OIDC_ISSUER_URL = "https://idp.example.test"; + process.env.OIDC_CLIENT_ID = "client"; + process.env.OIDC_CLIENT_SECRET = "secret"; + process.env.OIDC_ONLY = "true"; + expect((await mint(profile.id)).status).toBe(403); + const status = await ( + await ( + await import("@/app/api/managed-profiles/[id]/handover/route") + ).GET( + new NextRequest( + `http://localhost/api/managed-profiles/${profile.id}/handover`, + ), + { params: Promise.resolve({ id: profile.id }) }, + ) + ).json(); + expect(status.data.available).toBe(false); + signOut(); + for (const response of [await preview(token), await claim(token)]) { + expect(response.status).toBe(403); + expect((await response.json()).meta.errorCode).toBe( + "profile_claim.oidc_only_unsupported", + ); + } + // The profile is still managed: nothing was taken over. + const account = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: profile.id }, + }); + expect(account.managedProfileAt).not.toBeNull(); + }); + + it("rate-limits claim attempts per address", async () => { + const codes: number[] = []; + for (let i = 0; i < 6; i++) { + codes.push((await claim(`hlp_${String(i).repeat(64)}`)).status); + } + expect(codes.slice(0, 5)).toEqual([404, 404, 404, 404, 404]); + expect(codes[5]).toBe(429); + }); + + it("refuses a username that is already taken, before using the link", async () => { + const { creator, profile } = await household(); + signIn(creator); + const token = await mintToken(profile.id); + signOut(); + + const response = await claim(token, { username: creator.username }); + expect(response.status).toBe(409); + expect((await response.json()).meta.errorCode).toBe("profile_claim.taken"); + // The link survives a refused attempt. + expect((await preview(token)).status).toBe(200); + }); +}); + +describe("the new owner's decision", () => { + async function claimedHousehold( + proposals: (h: Awaited>) => { + grantId: string; + proposal: HandoverAccess; + }[], + ) { + const h = await household(); + signIn(h.creator); + const token = await mintToken(h.profile.id, proposals(h)); + signOut(); + const response = await claim(token); + expect(response.status).toBe(201); + sent.length = 0; + return h; + } + + it("shows each Guardian's access and applies the owner's final word", async () => { + const h = await claimedHousehold((x) => [ + { grantId: x.creatorGrant.id, proposal: "manage" }, + { grantId: x.secondGrant.id, proposal: "end" }, + ]); + + const before = await (await readDecision()).json(); + expect(before.data.pending.guardians).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + grantId: h.creatorGrant.id, + proposal: "manage", + current: "manage", + decidable: true, + }), + expect.objectContaining({ + grantId: h.secondGrant.id, + proposal: "end", + current: "end", + decidable: true, + }), + ]), + ); + + const response = await decide([ + { grantId: h.creatorGrant.id, access: "end" }, + { grantId: h.secondGrant.id, access: "manage" }, + ]); + expect(response.status).toBe(200); + expect((await response.json()).data).toEqual({ decided: true, changed: 2 }); + + const prisma = getPrismaClient(); + expect( + await prisma.accountGrant.count({ + where: { + grantorId: h.profile.id, + granteeId: h.creator.id, + revokedAt: null, + }, + }), + ).toBe(0); + const restored = await prisma.accountGrant.findFirstOrThrow({ + where: { + grantorId: h.profile.id, + granteeId: h.second.id, + revokedAt: null, + }, + }); + expect(restored.access).toBe("MANAGE"); + expect(restored.acceptedAt).not.toBeNull(); + + expect((await (await readDecision()).json()).data.pending).toBeNull(); + const again = await decide([]); + expect(again.status).toBe(409); + expect((await again.json()).meta.errorCode).toBe( + "managed_profile.handover.no_pending", + ); + + expect(await auditActions(h.profile.id)).toContain( + "managed_profile.handover.decided", + ); + await vi.waitFor(() => expect(sent).toHaveLength(2)); + expect(sent.map((s) => s.messageKey).sort()).toEqual([ + "notifications.handover.changedEnd", + "notifications.handover.changedManage", + ]); + }); + + it("leaves alone a Guardian whose access moved since the claim", async () => { + const h = await claimedHousehold((x) => [ + { grantId: x.secondGrant.id, proposal: "read" }, + ]); + const prisma = getPrismaClient(); + const readRow = await prisma.accountGrant.findFirstOrThrow({ + where: { + grantorId: h.profile.id, + granteeId: h.second.id, + revokedAt: null, + }, + }); + // The Guardian steps away on their own. + await renounceGrantAndClearSwitch({ + grantId: readRow.id, + granteeId: h.second.id, + }); + + const before = await (await readDecision()).json(); + const row = before.data.pending.guardians.find( + (g: { grantId: string }) => g.grantId === h.secondGrant.id, + ); + expect(row).toMatchObject({ decidable: false, current: "end" }); + + expect( + (await decide([{ grantId: h.secondGrant.id, access: "manage" }])).status, + ).toBe(200); + // Their choice to leave stands. + expect( + await prisma.accountGrant.count({ + where: { + grantorId: h.profile.id, + granteeId: h.second.id, + revokedAt: null, + }, + }), + ).toBe(0); + }); + + it("refuses a decision about somebody who was not a Guardian", async () => { + await claimedHousehold(() => []); + const response = await decide([{ grantId: "stranger", access: "manage" }]); + expect(response.status).toBe(422); + }); + + it("answers no pending decision to an account that never was a profile", async () => { + const someone = await person("someone"); + signIn(someone); + expect((await (await readDecision()).json()).data.pending).toBeNull(); + }); +});