From 74ce17b7de27e6e7614b971ccf612a2f1bbd22ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:49:07 +0200 Subject: [PATCH] feat(integrations): let a guardian connect a managed profile's providers A guardian acting on a managed profile can now connect and disconnect its Withings, WHOOP, Oura, Polar, Strava and Google Health from the profile's integration cards, and each card says whose record it connects for. The connect starts with a fenced fetch that mints a state naming the profile; the provider's callback lands tokens and data there only after checking that the browser belongs to a guardian with a live MANAGE grant on it. Without a provider app of its own, the profile borrows the guardian's, with an audit row. Withings and Google Health callbacks read the session directly, since a provider redirect cannot carry the record-session header. --- docs/api/openapi.yaml | 298 +++++++++++++----- messages/de.json | 11 +- messages/en.json | 11 +- messages/es.json | 11 +- messages/fr.json | 11 +- messages/it.json | 11 +- messages/ko.json | 11 +- messages/nl.json | 11 +- messages/pl.json | 11 +- .../destructive-control-guard.test.ts | 8 + .../record-settings-route-guard.test.ts | 6 +- src/__tests__/record-settings-ui.test.tsx | 15 +- src/__tests__/session-surface-guard.test.ts | 8 + src/__tests__/sharing-surface-guard.test.ts | 9 +- src/app/api/google-health/callback/route.ts | 37 ++- .../api/oura/callback/__tests__/route.test.ts | 14 +- src/app/api/oura/callback/route.ts | 20 +- .../polar/callback/__tests__/route.test.ts | 9 + src/app/api/polar/callback/route.ts | 20 +- .../[provider]/__tests__/route.test.ts | 145 +++++++++ .../integrations/[provider]/connect/route.ts | 97 ++++++ .../[provider]/disconnect/route.ts | 61 ++++ .../api/record-settings/integrations/route.ts | 8 +- .../strava/callback/__tests__/route.test.ts | 9 + src/app/api/strava/callback/route.ts | 20 +- .../whoop/callback/__tests__/route.test.ts | 9 + src/app/api/whoop/callback/route.ts | 17 +- .../withings/callback/__tests__/route.test.ts | 21 +- src/app/api/withings/callback/route.ts | 35 +- .../managed-integration-status.test.tsx | 36 ++- .../settings/managed-integration-status.tsx | 160 +++++++++- .../__tests__/connect-target.test.ts | 148 +++++++++ .../integrations/oauth-kit/connect-target.ts | 119 +++++++ .../oauth-kit/managed-provider.ts | 26 ++ src/lib/openapi/routes/record-settings.ts | 107 ++++++- 35 files changed, 1392 insertions(+), 158 deletions(-) create mode 100644 src/app/api/record-settings/integrations/[provider]/__tests__/route.test.ts create mode 100644 src/app/api/record-settings/integrations/[provider]/connect/route.ts create mode 100644 src/app/api/record-settings/integrations/[provider]/disconnect/route.ts create mode 100644 src/lib/integrations/oauth-kit/__tests__/connect-target.test.ts create mode 100644 src/lib/integrations/oauth-kit/connect-target.ts create mode 100644 src/lib/integrations/oauth-kit/managed-provider.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9b4f13c5b..6ee366015 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -22648,11 +22648,9 @@ paths: tags: - Account sharing summary: Read the managed record's integration connection states - description: "Disclosure only: whether each of the record's integrations is connected, when it last succeeded, and its - server-resolved health. There is deliberately no control arm and no credential projection anywhere under this - path — a Guardian cannot connect, disconnect, probe or resume a managed record's provider, because an OAuth flow - cannot safely carry a record selector. Cookie session or Bearer, while acting on a managed record the caller - guards." + description: Whether each of the record's integrations is connected, when it last succeeded, and its server-resolved + health. No credential projection. Connecting and disconnecting an OAuth provider are the two POST routes below + this path. Cookie session or Bearer, while acting on a managed record the caller guards. responses: "200": description: Connection state per provider. @@ -22664,6 +22662,84 @@ paths: "403": *a11 "422": *a4 "429": *a5 + /api/record-settings/integrations/{provider}/connect: + post: + tags: + - Account sharing + summary: Start connecting an OAuth provider for the managed record + description: "Mints the OAuth state for the record the session is acting on, sets it as an httpOnly cookie, and answers + with the provider's consent URL; the client then sends the browser there. A fetch rather than a navigation, so + the record-session fence applies. The state names the managed record: the provider's callback lands the tokens + on it after checking the browser still belongs to a Guardian holding a live MANAGE grant on that record. When + the record has no provider app of its own and the deployment has none, the Guardian's own app is copied onto the + record (audited). Fitbit is not offered: its classic API is retired. Cookie session, while acting on a managed + record the caller guards." + parameters: + - &a54 + name: provider + in: path + required: true + schema: + type: string + enum: + - withings + - whoop + - oura + - polar + - strava + - google-health + description: The OAuth provider. An unrecognised value is a 404, answered before authentication. + responses: + "200": + description: The consent URL to send the browser to. + content: + application/json: + schema: + $ref: "#/components/schemas/ManagedConnectStartEnvelope" + "401": *a1 + "403": *a11 + "404": + description: The path named a provider outside the closed set. Answered before the caller is authenticated. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: Neither the record, the Guardian, nor the deployment has an OAuth app for this provider. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + /api/record-settings/integrations/{provider}/disconnect: + post: + tags: + - Account sharing + summary: Disconnect an OAuth provider from the managed record + description: "Runs the same disconnect the owner of a record runs: the grant is dropped, the provider told where it can + (Withings webhooks, Strava deauthorisation), and the integration parked at `disconnected`. Imported data stays. + Audited against the record with the Guardian as the actor. Cookie session or Bearer, while acting on a managed + record the caller guards." + parameters: + - *a54 + responses: + "200": + description: The provider is disconnected. + content: + application/json: + schema: + $ref: "#/components/schemas/ManagedDisconnectEnvelope" + "401": *a1 + "403": *a11 + "404": + description: The path named a provider outside the closed set, or the record has no connection to it. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 /api/record-settings/{family}: get: tags: @@ -22672,7 +22748,7 @@ paths: description: Returns the named family's resolved settings for the record the session is acting on. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - &a54 + - &a55 name: family in: path required: true @@ -22716,7 +22792,7 @@ paths: ride the audit row. Body capped at 64 KiB. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - *a54 + - *a55 requestBody: required: true content: @@ -23342,13 +23418,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a55 + "403": &a56 description: Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": &a56 + "404": &a57 description: No stored copy with this id. content: application/json: @@ -23422,8 +23498,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a55 - "404": *a56 + "403": *a56 + "404": *a57 "409": description: A restore of the same account is already queued or running (`meta.errorCode` = `backup.restore.active`, `meta.jobId` names it), the stored copy was replaced by a newer one while it was being checked @@ -23467,7 +23543,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobListResponse" - "403": *a55 + "403": *a56 /api/admin/backups/restores/{jobId}: get: tags: @@ -23490,7 +23566,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobResponse" - "403": *a55 + "403": *a56 "404": description: No restore job with this id. content: @@ -23509,7 +23585,7 @@ paths: request leaves the server. Needs neither the Documents module nor the list: with the list unset, `available` is false and any connection still stored is listed (with `originAllowed: false`) so it can be removed. Cookie session only; not delegable." - security: &a57 + security: &a58 - cookieAuth: [] responses: "200": @@ -23519,7 +23595,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourcesStatusEnvelope" "401": *a1 - "403": &a58 + "403": &a59 description: "Refused before any request to the source: `documents.sources.browserOnly` for a Bearer caller, `sharing.not_permitted` while acting on another person's record (a delegate or guardian never uses the owner's archive), or `module.disabled` when the Documents module is off." @@ -23542,9 +23618,9 @@ paths: `documents.sources.tokenRequired`), so a stored token is never sent to an address it was not saved for. A Papra organization id is letters, digits, `_` and `-`. The token is stored encrypted (AES-256-GCM) and never returned. Ten saves and tests a minute per person. Cookie session only; not delegable." - security: *a57 + security: *a58 parameters: - - &a59 + - &a60 name: system in: path required: true @@ -23583,8 +23659,8 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceConnectionEnvelope" "401": *a1 - "403": *a58 - "404": &a60 + "403": *a59 + "404": &a61 description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. `documents.sources.notFound`: the source has no such document, or (Papra) no such organization, including @@ -23603,7 +23679,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": &a61 + "502": &a62 description: "The source could not answer usefully, with the reason in `meta.errorCode` and, when the source answered at all, its HTTP status in `meta.upstreamStatus` (its body is never passed on): `documents.sources.unreachable` (no connection, timeout, or a destination refused at dial time), `documents.sources.redirected` (the source @@ -23622,9 +23698,9 @@ paths: description: Deletes the caller's connection for this system, and its token with it. Works even after the operator removed the origin from the list, and with the Documents module switched off. `disconnected` is false when there was nothing to delete. Cookie session only; not delegable. - security: *a57 + security: *a58 parameters: - - *a59 + - *a60 responses: "200": description: Removed, or there was nothing to remove. @@ -23633,7 +23709,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceDisconnectEnvelope" "401": *a1 - "403": *a58 + "403": *a59 "422": *a4 "429": *a5 /api/documents/sources/{system}/test: @@ -23644,9 +23720,9 @@ paths: description: One request to the source with the stored token. Answers `ok` with the round-trip time and stamps `lastVerifiedAt`, or the reason in `meta.errorCode`. Shares the save's allowance of ten a minute. Cookie session only; not delegable. - security: *a57 + security: *a58 parameters: - - *a59 + - *a60 responses: "200": description: The source answered. @@ -23655,9 +23731,9 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTestEnvelope" "401": *a1 - "403": *a58 - "404": *a60 - "422": &a62 + "403": *a59 + "404": *a61 + "422": &a63 description: "`documents.sources.originNotAllowed`: the base address's origin is not on the operator's list (checked on every request, so a connection saved earlier is cut off once its origin is removed), or another validation failure." @@ -23666,7 +23742,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": *a61 + "502": *a62 /api/documents/sources/{system}/tags: get: tags: @@ -23674,9 +23750,9 @@ paths: summary: Tags of a connected archive description: The source's tags for the picker's filter, the first 250 by name, in the source's own ids. Shares the search allowance of sixty a minute. Cookie session only; not delegable. - security: *a57 + security: *a58 parameters: - - *a59 + - *a60 responses: "200": description: The tags. @@ -23685,11 +23761,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTagsEnvelope" "401": *a1 - "403": *a58 - "404": *a60 - "422": *a62 + "403": *a59 + "404": *a61 + "422": *a63 "429": *a5 - "502": *a61 + "502": *a62 /api/documents/sources/{system}/search: get: tags: @@ -23703,9 +23779,9 @@ paths: under that source key: `new`, `imported` with the vault document's `documentId`, or `deleted` (deleted in HealthLog; importing it again stores nothing). Nothing is stored. Sixty searches a minute, shared with the tag read. Cookie session only; not delegable." - security: *a57 + security: *a58 parameters: - - *a59 + - *a60 - name: q in: query required: false @@ -23746,11 +23822,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceSearchEnvelope" "401": *a1 - "403": *a58 - "404": *a60 - "422": *a62 + "403": *a59 + "404": *a61 + "422": *a63 "429": *a5 - "502": *a61 + "502": *a62 /api/documents/sources/{system}/import: post: tags: @@ -23769,9 +23845,9 @@ paths: fetched), including a duplicate. `kind` sets the document type (default OTHER). Automatic AI reading follows the person's own setting. Counts against the person's upload allowance (60 an hour) and storage quota. 201 for a new document, 200 otherwise. Cookie session only; not delegable." - security: *a57 + security: *a58 parameters: - - *a59 + - *a60 requestBody: required: true content: @@ -23829,7 +23905,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceImportEnvelope" "401": *a1 - "403": *a58 + "403": *a59 "404": description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. @@ -23860,7 +23936,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a62 + "422": *a63 "429": description: "`documents.inbound.rateLimited` (the person's hourly upload allowance, with the rate-limit headers) or `documents.inbound.uploadBusy` (too many uploads in progress; `Retry-After: 1`)." @@ -23868,7 +23944,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": *a61 + "502": *a62 /api/symptoms/definitions: get: tags: @@ -23892,7 +23968,7 @@ paths: schema: $ref: "#/components/schemas/ListSymptomDefinitionsEnvelope" "401": *a1 - "403": &a63 + "403": &a64 description: >- `illness.disabled`: the record has the illness module switched off (or the operator turned it off server-wide); symptoms ride that module. @@ -23935,7 +24011,7 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomDefinitionEnvelope" "401": *a1 - "403": *a63 + "403": *a64 "409": *a7 "422": *a4 "429": *a5 @@ -23965,8 +24041,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomDefinitionEnvelope" "401": *a1 - "403": *a63 - "404": &a64 + "403": *a64 + "404": &a65 description: "`symptoms.definition.notFound`: no definition with this id on the record." content: application/json: @@ -24000,8 +24076,8 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomDefinitionEnvelope" "401": *a1 - "403": *a63 - "404": *a64 + "403": *a64 + "404": *a65 "422": *a4 "429": *a5 /api/symptoms/events: @@ -24050,7 +24126,7 @@ paths: schema: $ref: "#/components/schemas/ListSymptomEventsEnvelope" "401": *a1 - "403": *a63 + "403": *a64 "422": *a4 "429": *a5 post: @@ -24076,8 +24152,8 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomEventEnvelope" "401": *a1 - "403": *a63 - "404": *a64 + "403": *a64 + "404": *a65 "409": *a7 "422": *a4 "429": *a5 @@ -24107,8 +24183,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomEventEnvelope" "401": *a1 - "403": *a63 - "404": &a65 + "403": *a64 + "404": &a66 description: "`symptoms.event.notFound`: no occurrence with this id on the record." content: application/json: @@ -24135,8 +24211,8 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomEventEnvelope" "401": *a1 - "403": *a63 - "404": *a65 + "403": *a64 + "404": *a66 "422": *a4 "429": *a5 /api/day/index: @@ -24287,7 +24363,7 @@ paths: schema: $ref: "#/components/schemas/TimelineEnvelope" "401": *a1 - "403": &a66 + "403": &a67 description: >- `module.disabled` with `meta.module = timeline`: the record has the timeline switched off (it is opt-in), or the operator turned it off server-wide. @@ -24324,7 +24400,7 @@ paths: schema: $ref: "#/components/schemas/TimelineReadinessEnvelope" "401": *a1 - "403": *a66 + "403": *a67 "422": *a4 "429": *a5 /api/life-events: @@ -24342,7 +24418,7 @@ paths: schema: $ref: "#/components/schemas/LifeEventListEnvelope" "401": *a1 - "403": &a67 + "403": &a68 description: >- Owner-only in v1.42: no share reaches life events, not a `profile` share, a whole-record share or MANAGE, and not a guardian acting for a managed profile. @@ -24382,7 +24458,7 @@ paths: schema: $ref: "#/components/schemas/CreateLifeEventEnvelope" "401": *a1 - "403": *a67 + "403": *a68 "409": *a7 "422": *a4 "429": *a12 @@ -24413,8 +24489,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLifeEventEnvelope" "401": *a1 - "403": *a67 - "404": &a68 + "403": *a68 + "404": &a69 description: No such life event on this account. content: application/json: @@ -24441,8 +24517,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLifeEventEnvelope" "401": *a1 - "403": *a67 - "404": *a68 + "403": *a68 + "404": *a69 "422": *a4 "429": *a5 /api/search: @@ -83528,10 +83604,90 @@ components: - integrations additionalProperties: false description: Whether the managed record's integrations are connected and how each is doing (`health`), and nothing else. - Deliberately no control and no credential projection — OAuth and provider state cannot safely carry a record - selector, so a Guardian can see that a pipe exists but cannot connect, disconnect, probe or resume it from here. - A provider with no real credential reads `disconnected` even when its ledger row says otherwise, because a - synthesised healthy ledger is not proof of a connection. + No credential projection. A Guardian connects or disconnects an OAuth provider for the record through `POST + /api/record-settings/integrations/{provider}/connect` and `/disconnect`. A provider with no real credential + reads `disconnected` even when its ledger row says otherwise, because a synthesised healthy ledger is not proof + of a connection. + ManagedConnectStartEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ManagedConnectStartResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ManagedConnectStartResponse: + type: object + properties: + recordId: + type: string + provider: + type: string + enum: + - withings + - whoop + - oura + - polar + - strava + - google-health + authorizationUrl: + type: string + description: The provider's consent URL, with the state the record owns. + required: + - recordId + - provider + - authorizationUrl + additionalProperties: false + description: Where to send the browser to connect a provider for the managed record. + ManagedDisconnectEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ManagedDisconnectResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ManagedDisconnectResponse: + type: object + properties: + recordId: + type: string + provider: + type: string + enum: + - withings + - whoop + - oura + - polar + - strava + - google-health + disconnected: + type: boolean + const: true + required: + - recordId + - provider + - disconnected + additionalProperties: false + description: The provider is no longer connected to the managed record. GetRecordSettingsFamilyEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index b8afe311c..5096b7c90 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5406,8 +5406,15 @@ "unavailableTitle": "In diesem geteilten Datensatz nicht verfügbar", "unavailableDescription": "Dieser Einstellungsbereich ist beim Anzeigen des Datensatzes einer anderen Person bewusst nicht verfügbar.", "integrationStatusDescription": "Verbindungsstatus für diesen verwalteten Datensatz.", - "integrationStatusDetail": "Ein betreutes Profil zeigt seine verbundenen Dienste nur als Status. Verbinden, neu verbinden, synchronisieren und trennen ist für Betreuende nicht möglich.", - "integrationStatusDocs": "So funktioniert das Teilen" + "integrationStatusDetail": "Dienste, die du hier verbindest, schicken ihre Daten in das Profil von {name}, nicht in deins. Ohne eigene Anbieter-App nutzt das Profil deine.", + "integrationStatusDocs": "So funktioniert das Teilen", + "integrationConnectsFor": "Verbindet für {name}", + "integrationConnectFor": "Für {name} verbinden", + "integrationDisconnect": "Trennen", + "integrationDisconnectTitle": "{provider} von {name} trennen?", + "integrationDisconnectBody": "Neue Daten kommen dann nicht mehr an. Was schon importiert ist, bleibt im Profil von {name}.", + "integrationNoClientApp": "Weder {name} noch dein eigenes Konto hat bisher eine {provider}-App. Richte zuerst bei deinen eigenen Integrationen eine ein.", + "integrationConnectFailed": "{provider} konnte nicht verbunden werden." }, "sections": { "account": { diff --git a/messages/en.json b/messages/en.json index 8ec8c0e61..37ff91d18 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5406,8 +5406,15 @@ "unavailableTitle": "Unavailable in this shared record", "unavailableDescription": "This Settings area is intentionally unavailable while viewing another person's record.", "integrationStatusDescription": "Connection status for this managed record.", - "integrationStatusDetail": "A managed profile shows its connected services as a status only. Connecting, reconnecting, syncing and disconnecting are not available to guardians.", - "integrationStatusDocs": "How sharing works" + "integrationStatusDetail": "Services you connect here send their data to {name}'s record, not to yours. Without a provider app of its own, the profile uses yours.", + "integrationStatusDocs": "How sharing works", + "integrationConnectsFor": "Connects for {name}", + "integrationConnectFor": "Connect for {name}", + "integrationDisconnect": "Disconnect", + "integrationDisconnectTitle": "Disconnect {provider} from {name}?", + "integrationDisconnectBody": "New data stops arriving. Everything already imported stays in {name}'s record.", + "integrationNoClientApp": "Neither {name} nor your own account has a {provider} app yet. Set one up under your own integrations first.", + "integrationConnectFailed": "Could not start connecting {provider}." }, "sections": { "account": { diff --git a/messages/es.json b/messages/es.json index 80928dc2d..4b1a9e65b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5406,8 +5406,15 @@ "unavailableTitle": "No disponible en este registro compartido", "unavailableDescription": "Esta sección de Ajustes no está disponible intencionadamente mientras consultas el registro de otra persona.", "integrationStatusDescription": "Estado de conexión de este registro gestionado.", - "integrationStatusDetail": "Un perfil gestionado muestra sus servicios conectados solo como estado. Conectar, volver a conectar, sincronizar y desconectar no está disponible para quienes lo gestionan.", - "integrationStatusDocs": "Cómo funciona compartir" + "integrationStatusDetail": "Los servicios que conectes aquí envían sus datos al registro de {name}, no al tuyo. Si el perfil no tiene una app propia del proveedor, usa la tuya.", + "integrationStatusDocs": "Cómo funciona compartir", + "integrationConnectsFor": "Conecta para {name}", + "integrationConnectFor": "Conectar para {name}", + "integrationDisconnect": "Desconectar", + "integrationDisconnectTitle": "¿Desconectar {provider} de {name}?", + "integrationDisconnectBody": "Dejarán de llegar datos nuevos. Todo lo ya importado se queda en el registro de {name}.", + "integrationNoClientApp": "Ni {name} ni tu propia cuenta tienen todavía una app de {provider}. Configura una primero en tus propias integraciones.", + "integrationConnectFailed": "No se pudo empezar a conectar {provider}." }, "sections": { "account": { diff --git a/messages/fr.json b/messages/fr.json index 4f7058a57..ea046e3a4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5406,8 +5406,15 @@ "unavailableTitle": "Indisponible dans ce dossier partagé", "unavailableDescription": "Cette section des réglages est volontairement indisponible lorsque vous consultez le dossier d’une autre personne.", "integrationStatusDescription": "État de connexion pour ce dossier géré.", - "integrationStatusDetail": "Un profil géré affiche ses services connectés uniquement sous forme d’état. Les personnes qui le gèrent ne peuvent ni connecter, ni reconnecter, ni synchroniser, ni déconnecter.", - "integrationStatusDocs": "Comment fonctionne le partage" + "integrationStatusDetail": "Les services que vous connectez ici envoient leurs données dans le dossier de {name}, pas dans le vôtre. Sans application propre chez le fournisseur, le profil utilise la vôtre.", + "integrationStatusDocs": "Comment fonctionne le partage", + "integrationConnectsFor": "Connecte pour {name}", + "integrationConnectFor": "Connecter pour {name}", + "integrationDisconnect": "Déconnecter", + "integrationDisconnectTitle": "Déconnecter {provider} de {name} ?", + "integrationDisconnectBody": "Les nouvelles données n’arriveront plus. Tout ce qui a déjà été importé reste dans le dossier de {name}.", + "integrationNoClientApp": "Ni {name} ni votre propre compte n’ont encore d’application {provider}. Configurez-en d’abord une dans vos propres intégrations.", + "integrationConnectFailed": "Impossible de lancer la connexion à {provider}." }, "sections": { "account": { diff --git a/messages/it.json b/messages/it.json index a1dc08274..47b448231 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5406,8 +5406,15 @@ "unavailableTitle": "Non disponibile in questo record condiviso", "unavailableDescription": "Questa area delle Impostazioni non è disponibile intenzionalmente mentre visualizzi il record di un’altra persona.", "integrationStatusDescription": "Stato di connessione per questo record gestito.", - "integrationStatusDetail": "Un profilo gestito mostra i servizi collegati solo come stato. Chi lo gestisce non può collegare, ricollegare, sincronizzare o scollegare.", - "integrationStatusDocs": "Come funziona la condivisione" + "integrationStatusDetail": "I servizi che colleghi qui inviano i dati al profilo di {name}, non al tuo. Se il profilo non ha un'app propria del fornitore, usa la tua.", + "integrationStatusDocs": "Come funziona la condivisione", + "integrationConnectsFor": "Collega per {name}", + "integrationConnectFor": "Collega per {name}", + "integrationDisconnect": "Scollega", + "integrationDisconnectTitle": "Scollegare {provider} da {name}?", + "integrationDisconnectBody": "I nuovi dati smettono di arrivare. Quanto già importato resta nel profilo di {name}.", + "integrationNoClientApp": "Né {name} né il tuo account hanno ancora un'app {provider}. Configurane prima una nelle tue integrazioni.", + "integrationConnectFailed": "Impossibile avviare il collegamento a {provider}." }, "sections": { "account": { diff --git a/messages/ko.json b/messages/ko.json index 8712f3f1a..a0515cf73 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5406,8 +5406,15 @@ "unavailableTitle": "이 공유 기록에서는 사용할 수 없어요", "unavailableDescription": "다른 사람의 기록을 보는 동안에는 이 설정 영역을 사용할 수 없어요.", "integrationStatusDescription": "이 관리 기록의 연결 상태예요.", - "integrationStatusDetail": "관리 프로필은 연결된 서비스를 상태로만 보여 줘요. 보호자는 연결, 다시 연결, 동기화, 연결 해제를 할 수 없어요.", - "integrationStatusDocs": "공유 방식 알아보기" + "integrationStatusDetail": "여기서 연결한 서비스의 데이터는 내 기록이 아니라 {name}의 기록으로 들어가요. 프로필에 자체 앱이 없으면 내 앱을 사용해요.", + "integrationStatusDocs": "공유 방식 알아보기", + "integrationConnectsFor": "{name}을(를) 위해 연결", + "integrationConnectFor": "{name}을(를) 위해 연결하기", + "integrationDisconnect": "연결 해제", + "integrationDisconnectTitle": "{name}에서 {provider} 연결을 해제할까요?", + "integrationDisconnectBody": "새 데이터가 더 이상 들어오지 않아요. 이미 가져온 데이터는 {name}의 기록에 남아요.", + "integrationNoClientApp": "{name}과(와) 내 계정 모두 아직 {provider} 앱이 없어요. 먼저 내 통합 설정에서 앱을 설정해 주세요.", + "integrationConnectFailed": "{provider} 연결을 시작하지 못했어요." }, "sections": { "account": { diff --git a/messages/nl.json b/messages/nl.json index dc0c5e5f0..a5e20c7d3 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -5406,8 +5406,15 @@ "unavailableTitle": "Niet beschikbaar in dit gedeelde dossier", "unavailableDescription": "Dit deel van Instellingen is bewust niet beschikbaar terwijl je het dossier van iemand anders bekijkt.", "integrationStatusDescription": "Koppelingsstatus voor dit beheerde dossier.", - "integrationStatusDetail": "Een beheerd profiel toont de gekoppelde diensten alleen als status. Koppelen, opnieuw koppelen, synchroniseren en ontkoppelen zijn niet beschikbaar voor verzorgers.", - "integrationStatusDocs": "Hoe delen werkt" + "integrationStatusDetail": "Diensten die je hier koppelt, sturen hun gegevens naar het dossier van {name}, niet naar het jouwe. Zonder eigen app bij de aanbieder gebruikt het profiel de jouwe.", + "integrationStatusDocs": "Hoe delen werkt", + "integrationConnectsFor": "Koppelt voor {name}", + "integrationConnectFor": "Koppelen voor {name}", + "integrationDisconnect": "Ontkoppelen", + "integrationDisconnectTitle": "{provider} ontkoppelen van {name}?", + "integrationDisconnectBody": "Er komen geen nieuwe gegevens meer binnen. Wat al geïmporteerd is, blijft in het dossier van {name}.", + "integrationNoClientApp": "Noch {name} noch je eigen account heeft al een {provider}-app. Stel er eerst een in bij je eigen integraties.", + "integrationConnectFailed": "Koppelen met {provider} kon niet starten." }, "sections": { "account": { diff --git a/messages/pl.json b/messages/pl.json index 625abe2a4..394f3ccbf 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5406,8 +5406,15 @@ "unavailableTitle": "Niedostępne w tym udostępnionym rekordzie", "unavailableDescription": "Ten obszar Ustawień jest celowo niedostępny podczas przeglądania rekordu innej osoby.", "integrationStatusDescription": "Stan połączenia dla tego zarządzanego rekordu.", - "integrationStatusDetail": "Profil zarządzany pokazuje połączone usługi tylko jako stan. Opiekunowie nie mogą łączyć, ponownie łączyć, synchronizować ani rozłączać.", - "integrationStatusDocs": "Jak działa udostępnianie" + "integrationStatusDetail": "Usługi połączone tutaj wysyłają dane do profilu {name}, a nie do Twojego. Jeśli profil nie ma własnej aplikacji u dostawcy, używa Twojej.", + "integrationStatusDocs": "Jak działa udostępnianie", + "integrationConnectsFor": "Łączy dla: {name}", + "integrationConnectFor": "Połącz dla: {name}", + "integrationDisconnect": "Rozłącz", + "integrationDisconnectTitle": "Rozłączyć {provider} od profilu {name}?", + "integrationDisconnectBody": "Nowe dane przestaną napływać. Wszystko, co już zaimportowano, zostaje w profilu {name}.", + "integrationNoClientApp": "Ani {name}, ani Twoje konto nie ma jeszcze aplikacji {provider}. Najpierw skonfiguruj ją we własnych integracjach.", + "integrationConnectFailed": "Nie udało się rozpocząć łączenia z {provider}." }, "sections": { "account": { diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 6fe80d772..e183eb18c 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -672,6 +672,14 @@ const REGISTRY: DestructiveEntry[] = [ recovery: "permanent", confirm: ["ConfirmDialog"], }, + { + file: "components/settings/managed-integration-status.tsx", + destroys: + "a managed profile's stored Withings, WHOOP, Google Health, Polar, Oura or Strava grant, disconnected by its guardian — reconnecting means authorising again", + triggers: ["components/settings/integrations/integration-card.tsx"], + recovery: "permanent", + confirm: ["ConfirmDialog"], + }, { file: "components/settings/integrations/nightscout-card.tsx", destroys: diff --git a/src/__tests__/record-settings-route-guard.test.ts b/src/__tests__/record-settings-route-guard.test.ts index 6435fadb1..7c93b193b 100644 --- a/src/__tests__/record-settings-route-guard.test.ts +++ b/src/__tests__/record-settings-route-guard.test.ts @@ -20,9 +20,11 @@ describe("record settings route guard", () => { .map((entry) => `app/api/record-settings/${entry}`) .sort(); - expect(discovered).toHaveLength(3); + expect(discovered).toHaveLength(5); expect(discovered).toEqual([ "app/api/record-settings/[family]/route.ts", + "app/api/record-settings/integrations/[provider]/connect/route.ts", + "app/api/record-settings/integrations/[provider]/disconnect/route.ts", "app/api/record-settings/integrations/route.ts", "app/api/record-settings/route.ts", ]); @@ -32,6 +34,8 @@ describe("record settings route guard", () => { const routes = [ "route.ts", "integrations/route.ts", + "integrations/[provider]/connect/route.ts", + "integrations/[provider]/disconnect/route.ts", "[family]/route.ts", ] as const; const sources = await Promise.all( diff --git a/src/__tests__/record-settings-ui.test.tsx b/src/__tests__/record-settings-ui.test.tsx index caa025978..780d1f2f6 100644 --- a/src/__tests__/record-settings-ui.test.tsx +++ b/src/__tests__/record-settings-ui.test.tsx @@ -27,7 +27,7 @@ function expectMatchingMessageKeys(expected: unknown, actual: unknown): void { } describe("managed record settings UI", () => { - it("gates shared deep links and renders integration status without controls", async () => { + it("gates shared deep links and controls integrations only through the managed-record routes", async () => { const [ statusView, managedSettingsView, @@ -58,8 +58,17 @@ describe("managed record settings UI", () => { expect(statusView).toContain("recordSettingsIntegrations"); expect(statusView).toContain("/api/record-settings/integrations"); expect(statusView).toContain("assertRecordSettingsResponseForRecord"); - expect(statusView).not.toContain("]/); expect(settingsPage).toContain("RecordSettingsSectionGate"); expect(sectionGate).toContain("if (!inSharedRecord) return children"); expect(sectionGate).toContain("ManagedIntegrationStatus"); diff --git a/src/__tests__/session-surface-guard.test.ts b/src/__tests__/session-surface-guard.test.ts index 6fdcd8534..4916b1cee 100644 --- a/src/__tests__/session-surface-guard.test.ts +++ b/src/__tests__/session-surface-guard.test.ts @@ -115,6 +115,14 @@ describe("S1 — the direct session-resolution set is frozen", () => { "consent decision on a route outside apiHandler", // Provider OAuth callbacks. The browser arrives as a top-level // navigation, so the code is bound to the session user and redirected. + // v1.44 — Withings and Google Health join: a navigation cannot carry the + // record-session header a record resolver needs, and the session user may + // be a guardian finishing a managed profile's connect, which + // `verifyCallbackActor` checks against a live MANAGE grant. + "app/api/google-health/callback/route.ts": + "binds the OAuth code to the session user", + "app/api/withings/callback/route.ts": + "binds the OAuth code to the session user", "app/api/oura/callback/route.ts": "binds the OAuth code to the session user", "app/api/polar/callback/route.ts": diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 5f851c312..d6ec5452e 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -2044,6 +2044,10 @@ const GUARDIAN_ROUTES: Record = { "Verified integration connection status for the managed record, without any provider control surface.", "app/api/record-settings/[family]/route.ts": "The closed, field-specific managed-record configuration DTOs; identity and credentials remain excluded.", + "app/api/record-settings/integrations/[provider]/connect/route.ts": + "Starts an OAuth connect for the managed record a guardian administers. It returns the consent URL and the state names the managed record; the callback re-checks the browser's live MANAGE grant before any token lands. Refused on an adult record by the marker, at every grant level.", + "app/api/record-settings/integrations/[provider]/disconnect/route.ts": + "Disconnects a provider from the managed record a guardian administers, through the same disconnect the owner runs. Imported data stays. Refused on an adult record by the marker, at every grant level.", }; /** @@ -2164,8 +2168,11 @@ const ACTOR_ROUTES: Record = { * v1.43 -- AUDIT-C's create and soft delete join the record list and the * manage literal under `profile`, the level allergies are written at. * 262 -> 266. + * + * v1.44 -- a guardian connects and disconnects a managed profile's OAuth + * providers: two guardian-only entries. 266 -> 268. */ -const FROZEN_ENTRY_COUNT = 266; +const FROZEN_ENTRY_COUNT = 268; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/google-health/callback/route.ts b/src/app/api/google-health/callback/route.ts index 9f87a2fcf..71d6cf67b 100644 --- a/src/app/api/google-health/callback/route.ts +++ b/src/app/api/google-health/callback/route.ts @@ -1,5 +1,7 @@ import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, AUTH_ERROR_CODES, HttpError } from "@/lib/api-handler"; +import { getSession } from "@/lib/auth/session"; +import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; @@ -57,7 +59,15 @@ const ERR = (reason: string) => ); export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // The person in the browser, read off the session itself rather than + // through a record resolver: a provider redirect is a navigation, it cannot + // carry the record-session header, and the record is the one the state row + // names, checked against this person below. A session is still required. + const sessionData = await getSession(); + if (!sessionData) { + throw new HttpError(401, "Not authenticated", AUTH_ERROR_CODES.missing); + } + const actor = sessionData.user; annotate({ action: { name: "google_health.callback" } }); const { searchParams } = new URL(request.url); @@ -125,8 +135,12 @@ export const GET = apiHandler(async (request: NextRequest) => { annotate({ meta: { reason: "expired" } }); return ERR("expired"); } - if (consumed.userId !== user.id) { - annotate({ meta: { reason: "cross_user" } }); + // The record the connect started for; a guardian may finish it for a + // managed profile it holds a live MANAGE grant on. + const recordUserId = consumed.userId; + const actorCheck = await verifyCallbackActor(recordUserId, actor.id); + if (!actorCheck.ok) { + annotate({ meta: { reason: actorCheck.reason } }); return ERR("cross_user"); } if (!consumed.codeVerifier) { @@ -139,7 +153,7 @@ export const GET = apiHandler(async (request: NextRequest) => { } try { - const creds = await getUserGoogleHealthCredentials(user.id); + const creds = await getUserGoogleHealthCredentials(recordUserId); if (!creds) { return ERR("nocreds"); } @@ -154,7 +168,7 @@ export const GET = apiHandler(async (request: NextRequest) => { // so the user re-consents rather than landing a dead connection. if (!tokens.refresh_token) { getEvent()?.addWarning( - `google-health callback: token response carried no refresh_token for user ${user.id}`, + `google-health callback: token response carried no refresh_token for user ${recordUserId}`, ); annotate({ meta: { reason: "no_refresh_token" } }); return ERR("no_refresh_token"); @@ -166,7 +180,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const scope = tokens.scope ?? getGoogleHealthScopeString(); await prisma.googleHealthConnection.upsert({ - where: { userId: user.id }, + where: { userId: recordUserId }, update: { googleUserId, accessToken: encrypt(tokens.access_token), @@ -182,7 +196,7 @@ export const GET = apiHandler(async (request: NextRequest) => { backfillProgress: Prisma.DbNull, }, create: { - userId: user.id, + userId: recordUserId, googleUserId, accessToken: encrypt(tokens.access_token), refreshToken: encrypt(tokens.refresh_token), @@ -193,14 +207,15 @@ export const GET = apiHandler(async (request: NextRequest) => { }); await auditLog("google_health.connect", { - userId: user.id, + userId: recordUserId, + actorUserId: actorCheck.guardianUserId, details: { googleUserId }, }); // Re-completing OAuth clears any prior reauth-required state on the status // ledger too (the `needsReauth` column above is the connection-local flag; // this resets the integration-status streak). - await markReconnected(user.id, "google-health"); + await markReconnected(recordUserId, "google-health"); // Enqueue the self-converging history backfill. Best-effort: the boot-time // discovery query (`backfillCompletedAt IS NULL`) is the safety net, so a @@ -209,7 +224,7 @@ export const GET = apiHandler(async (request: NextRequest) => { if (boss) { await boss .send(GOOGLE_HEALTH_BACKFILL_QUEUE, { - userId: user.id, + userId: recordUserId, enqueuedAt: new Date().toISOString(), }) .catch((err) => diff --git a/src/app/api/oura/callback/__tests__/route.test.ts b/src/app/api/oura/callback/__tests__/route.test.ts index 2806299e4..23e5b7b5c 100644 --- a/src/app/api/oura/callback/__tests__/route.test.ts +++ b/src/app/api/oura/callback/__tests__/route.test.ts @@ -24,6 +24,15 @@ vi.mock("@/lib/oauth/callback-claim", () => ({ duplicateOAuthCallback: async () => null, })); +vi.mock("@/lib/integrations/oauth-kit/connect-target", () => ({ + // The guardian arm is covered in its own test; here the record's own + // browser (or none) may finish, any other session may not. + verifyCallbackActor: async (record: string, session: string | null) => + session === null || session === record + ? { ok: true, guardianUserId: null } + : { ok: false, reason: "cross_user" }, +})); + vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, })); @@ -166,7 +175,10 @@ describe("GET /api/oura/callback", () => { const data = userUpdate.mock.calls[0]![0].data; expect(data.ouraAccessTokenEncrypted).toBe("enc:at"); expect(data.ouraRefreshTokenEncrypted).toBe("enc:rt"); - expect(auditLog).toHaveBeenCalledWith("oura.connect", { userId: "u1" }); + expect(auditLog).toHaveBeenCalledWith("oura.connect", { + userId: "u1", + actorUserId: null, + }); expect(res.headers.get("location")).toContain("oura=connected"); }); }); diff --git a/src/app/api/oura/callback/route.ts b/src/app/api/oura/callback/route.ts index 6e7b7ed20..21d3c60d6 100644 --- a/src/app/api/oura/callback/route.ts +++ b/src/app/api/oura/callback/route.ts @@ -2,6 +2,7 @@ import { prisma } from "@/lib/db"; import { Prisma } from "@/generated/prisma/client"; import { apiHandler } from "@/lib/api-handler"; import { getSession } from "@/lib/auth/session"; +import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; @@ -80,11 +81,18 @@ export const GET = apiHandler(async (request: NextRequest) => { const evt = getEvent(); if (evt) evt.setAuth({ user_id: userId, auth_method: "session" }); + // The browser finishing the flow must be the record's own, or, for a + // managed profile, a guardian's with a live MANAGE grant on it. const sessionData = await getSession(); - if (sessionData && sessionData.user.id !== userId) { - annotate({ meta: { reason: "cross_user" } }); + const actorCheck = await verifyCallbackActor( + userId, + sessionData?.user.id ?? null, + ); + if (!actorCheck.ok) { + annotate({ meta: { reason: actorCheck.reason } }); return ERR("cross_user"); } + const guardianUserId = actorCheck.guardianUserId; if (!code) return ERR("nocode"); @@ -104,12 +112,13 @@ export const GET = apiHandler(async (request: NextRequest) => { fallbackUrl: SETTINGS_URL(), stateCookie: oauthStateCookieName("oura"), }, - () => completeCallback(userId, code, iss), + () => completeCallback(userId, guardianUserId, code, iss), ); }); async function completeCallback( userId: string, + guardianUserId: string | null, code: string, iss: string | null, ): Promise { @@ -137,7 +146,10 @@ async function completeCallback( }, }); - await auditLog("oura.connect", { userId }); + await auditLog("oura.connect", { + userId, + actorUserId: guardianUserId, + }); await markReconnected(userId, "oura"); // Start the first sync now instead of leaving the user staring at an empty diff --git a/src/app/api/polar/callback/__tests__/route.test.ts b/src/app/api/polar/callback/__tests__/route.test.ts index 9addbc134..21d925ad3 100644 --- a/src/app/api/polar/callback/__tests__/route.test.ts +++ b/src/app/api/polar/callback/__tests__/route.test.ts @@ -46,6 +46,15 @@ vi.mock("@/lib/oauth/callback-claim", () => ({ duplicateOAuthCallback: async () => null, })); +vi.mock("@/lib/integrations/oauth-kit/connect-target", () => ({ + // The guardian arm is covered in its own test; here the record's own + // browser (or none) may finish, any other session may not. + verifyCallbackActor: async (record: string, session: string | null) => + session === null || session === record + ? { ok: true, guardianUserId: null } + : { ok: false, reason: "cross_user" }, +})); + vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, })); diff --git a/src/app/api/polar/callback/route.ts b/src/app/api/polar/callback/route.ts index cca2ff691..f67765b43 100644 --- a/src/app/api/polar/callback/route.ts +++ b/src/app/api/polar/callback/route.ts @@ -1,6 +1,7 @@ import { prisma } from "@/lib/db"; import { apiHandler } from "@/lib/api-handler"; import { getSession } from "@/lib/auth/session"; +import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; @@ -70,11 +71,18 @@ export const GET = apiHandler(async (request: NextRequest) => { if (evt) evt.setAuth({ user_id: userId, auth_method: "session" }); // Optional session cross-check when a web session is present. + // The browser finishing the flow must be the record's own, or, for a + // managed profile, a guardian's with a live MANAGE grant on it. const sessionData = await getSession(); - if (sessionData && sessionData.user.id !== userId) { - annotate({ meta: { reason: "cross_user" } }); + const actorCheck = await verifyCallbackActor( + userId, + sessionData?.user.id ?? null, + ); + if (!actorCheck.ok) { + annotate({ meta: { reason: actorCheck.reason } }); return ERR("cross_user"); } + const guardianUserId = actorCheck.guardianUserId; if (!code) return ERR("nocode"); @@ -90,12 +98,13 @@ export const GET = apiHandler(async (request: NextRequest) => { ).toString(), stateCookie: oauthStateCookieName("polar"), }, - () => completeCallback(userId, code), + () => completeCallback(userId, guardianUserId, code), ); }); async function completeCallback( userId: string, + guardianUserId: string | null, code: string, ): Promise { try { @@ -126,7 +135,10 @@ async function completeCallback( }, }); - await auditLog("polar.connect", { userId }); + await auditLog("polar.connect", { + userId, + actorUserId: guardianUserId, + }); await markReconnected(userId, "polar"); // Start the first sync now instead of leaving the user staring at an empty diff --git a/src/app/api/record-settings/integrations/[provider]/__tests__/route.test.ts b/src/app/api/record-settings/integrations/[provider]/__tests__/route.test.ts new file mode 100644 index 000000000..91a2176ae --- /dev/null +++ b/src/app/api/record-settings/integrations/[provider]/__tests__/route.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { NextRequest } from "next/server"; + +const m = vi.hoisted(() => ({ + requireGuardianAuth: vi.fn(), + access: vi.fn(), + share: vi.fn(async () => undefined), + hasApp: vi.fn(async () => true), + start: vi.fn(), + disconnect: vi.fn(), + rate: vi.fn(async () => ({ allowed: true })), + audit: vi.fn(async () => undefined), +})); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireGuardianAuth: m.requireGuardianAuth, + SharingAccessDeniedError: class SharingAccessDeniedError extends Error {}, +})); +vi.mock("@/lib/record-settings", () => ({ + resolveGuardianRecordSettingsAccess: m.access, +})); +vi.mock("@/lib/integrations/oauth-kit/connect-target", () => ({ + shareGuardianClientApp: m.share, +})); +vi.mock("@/lib/integrations/oauth-kit/connect-start", async (orig) => ({ + ...(await orig< + typeof import("@/lib/integrations/oauth-kit/connect-start") + >()), + recordHasClientApp: m.hasApp, + startOAuthConnect: m.start, +})); +vi.mock("@/lib/integrations/oauth-kit/disconnect", () => ({ + disconnectOAuth: m.disconnect, +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: m.rate, + rateLimitHeaders: () => ({}), +})); +vi.mock("@/lib/auth/audit", () => ({ auditLog: m.audit })); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/secure-cookie", () => ({ + shouldEmitSecureCookie: () => true, +})); + +import { POST as connect } from "../connect/route"; +import { POST as disconnect } from "../disconnect/route"; + +const params = (provider: string) => ({ + params: Promise.resolve({ provider }), +}); +const req = {} as NextRequest; +const GUARDED = { + actorId: "guardian", + recordId: "profile", + recordKind: "managed", + relationship: "guardian", +}; + +beforeEach(() => { + vi.clearAllMocks(); + m.requireGuardianAuth.mockResolvedValue({}); + m.access.mockReturnValue(GUARDED); + m.rate.mockResolvedValue({ allowed: true }); +}); + +describe("POST /api/record-settings/integrations/[provider]/connect", () => { + it("refuses a provider outside the set before authenticating", async () => { + const res = await connect(req, params("fitbit")); + expect(res.status).toBe(404); + expect(m.requireGuardianAuth).not.toHaveBeenCalled(); + }); + + it("refuses a caller who is not a guardian of a managed record", async () => { + m.access.mockReturnValue(null); + await expect(connect(req, params("oura"))).rejects.toThrow(); + expect(m.start).not.toHaveBeenCalled(); + }); + + it("mints the state for the managed record and answers with the consent URL", async () => { + m.start.mockResolvedValue({ + ok: true, + authorizationUrl: "https://cloud.ouraring.com/oauth/authorize?state=s", + cookie: { name: "oura_oauth_state", value: "s", maxAgeSeconds: 600 }, + }); + const res = await connect(req, params("oura")); + expect(m.share).toHaveBeenCalledWith( + "oura", + { recordUserId: "profile", actorUserId: "guardian" }, + expect.any(Function), + ); + expect(m.start).toHaveBeenCalledWith("oura", "profile"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual({ + recordId: "profile", + provider: "oura", + authorizationUrl: "https://cloud.ouraring.com/oauth/authorize?state=s", + }); + expect(res.headers.get("set-cookie")).toContain("oura_oauth_state=s"); + expect(res.headers.get("set-cookie")?.toLowerCase()).toContain("httponly"); + expect(m.audit).toHaveBeenCalledWith("integration.connect.started", { + userId: "profile", + details: { provider: "oura" }, + }); + }); + + it("answers 409 when no provider app can be found", async () => { + m.start.mockResolvedValue({ ok: false, reason: "nocreds" }); + const res = await connect(req, params("strava")); + expect(res.status).toBe(409); + }); + + it("is rate limited per record", async () => { + m.rate.mockResolvedValue({ allowed: false }); + const res = await connect(req, params("withings")); + expect(res.status).toBe(429); + expect(m.rate).toHaveBeenCalledWith( + "withings:connect:profile", + expect.any(Number), + expect.any(Number), + ); + }); +}); + +describe("POST /api/record-settings/integrations/[provider]/disconnect", () => { + it("disconnects the managed record", async () => { + m.disconnect.mockResolvedValue("disconnected"); + const res = await disconnect(req, params("google-health")); + expect(m.disconnect).toHaveBeenCalledWith("google-health", "profile"); + expect(res.status).toBe(200); + }); + + it("answers 404 when the record is not connected", async () => { + m.disconnect.mockResolvedValue("not_connected"); + const res = await disconnect(req, params("whoop")); + expect(res.status).toBe(404); + }); + + it("refuses a provider outside the set", async () => { + const res = await disconnect(req, params("nightscout")); + expect(res.status).toBe(404); + expect(m.disconnect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/record-settings/integrations/[provider]/connect/route.ts b/src/app/api/record-settings/integrations/[provider]/connect/route.ts new file mode 100644 index 000000000..e251643f7 --- /dev/null +++ b/src/app/api/record-settings/integrations/[provider]/connect/route.ts @@ -0,0 +1,97 @@ +import type { NextRequest } from "next/server"; + +import { + apiHandler, + requireGuardianAuth, + SharingAccessDeniedError, +} from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { + recordHasClientApp, + setConnectStateCookie, + startOAuthConnect, +} from "@/lib/integrations/oauth-kit/connect-start"; +import { shareGuardianClientApp } from "@/lib/integrations/oauth-kit/connect-target"; +import { isManagedOAuthProvider } from "@/lib/integrations/oauth-kit/managed-provider"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { resolveGuardianRecordSettingsAccess } from "@/lib/record-settings"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ provider: string }> }; + +const CONNECT_RATE_LIMIT = 10; +const CONNECT_WINDOW_MS = 60_000; + +/** + * A guardian starts connecting a provider for the managed profile they are + * acting on. + * + * A fetch, not a navigation: the record-session fence needs the headers the + * app transport sends, which a browser navigation cannot carry. So this + * answers with the consent URL and sets the state cookie, and the page then + * sends the browser to the provider. The state names the profile; the + * provider's callback lands the tokens there after checking the browser is + * still a guardian of it (`verifyCallbackActor`). + * + * Without a provider app of its own, the profile borrows the guardian's. + */ +export const POST = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { provider } = await params; + if (!isManagedOAuthProvider(provider)) { + return apiError("Integration not found", 404); + } + + const context = await requireGuardianAuth(); + const access = resolveGuardianRecordSettingsAccess(context); + if (!access) throw new SharingAccessDeniedError(); + annotate({ + action: { name: "record-settings.integration.connect.start" }, + meta: { provider }, + }); + + const rl = await checkRateLimit( + `${provider}:connect:${access.recordId}`, + CONNECT_RATE_LIMIT, + CONNECT_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many requests", 429, { + headers: rateLimitHeaders(rl), + }); + } + + await shareGuardianClientApp( + provider, + { recordUserId: access.recordId, actorUserId: access.actorId }, + () => recordHasClientApp(provider, access.recordId), + ); + + const started = await startOAuthConnect(provider, access.recordId); + if (!started.ok) { + return started.reason === "nocreds" + ? apiError( + "Neither this profile nor your own account has an app for this provider.", + 409, + ) + : apiError("The connect could not be started.", 500); + } + + await auditLog("integration.connect.started", { + userId: access.recordId, + details: { provider }, + }); + + return setConnectStateCookie( + apiSuccess({ + recordId: access.recordId, + provider, + authorizationUrl: started.authorizationUrl, + }), + started.cookie, + ); + }, +); diff --git a/src/app/api/record-settings/integrations/[provider]/disconnect/route.ts b/src/app/api/record-settings/integrations/[provider]/disconnect/route.ts new file mode 100644 index 000000000..eb12ed277 --- /dev/null +++ b/src/app/api/record-settings/integrations/[provider]/disconnect/route.ts @@ -0,0 +1,61 @@ +import type { NextRequest } from "next/server"; + +import { + apiHandler, + requireGuardianAuth, + SharingAccessDeniedError, +} from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { disconnectOAuth } from "@/lib/integrations/oauth-kit/disconnect"; +import { isManagedOAuthProvider } from "@/lib/integrations/oauth-kit/managed-provider"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { resolveGuardianRecordSettingsAccess } from "@/lib/record-settings"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ provider: string }> }; + +/** + * A guardian disconnects a provider from the managed profile they are acting + * on, through the same disconnect a person runs for their own account + * (`disconnectOAuth`). The profile's imported data stays. + */ +export const POST = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { provider } = await params; + if (!isManagedOAuthProvider(provider)) { + return apiError("Integration not found", 404); + } + + const context = await requireGuardianAuth(); + const access = resolveGuardianRecordSettingsAccess(context); + if (!access) throw new SharingAccessDeniedError(); + annotate({ + action: { name: "record-settings.integration.disconnect" }, + meta: { provider }, + }); + + const rl = await checkRateLimit( + `${provider}-disconnect:${access.recordId}`, + 20, + 60_000, + ); + if (!rl.allowed) { + return apiError("Too many requests", 429, { + headers: rateLimitHeaders(rl), + }); + } + + if ( + (await disconnectOAuth(provider, access.recordId)) === "not_connected" + ) { + return apiError("No connection", 404); + } + return apiSuccess({ + recordId: access.recordId, + provider, + disconnected: true, + }); + }, +); diff --git a/src/app/api/record-settings/integrations/route.ts b/src/app/api/record-settings/integrations/route.ts index 027e26b17..e87ebba78 100644 --- a/src/app/api/record-settings/integrations/route.ts +++ b/src/app/api/record-settings/integrations/route.ts @@ -30,9 +30,11 @@ const MANAGED_INTEGRATIONS: readonly IntegrationKey[] = [ ]; /** - * A managed profile may disclose whether its existing integrations are - * connected, but this endpoint deliberately has no control or credential - * projection. OAuth and provider state cannot safely carry a record selector. + * Whether a managed profile's integrations are connected and how each is + * doing. No credential projection. A guardian connects and disconnects the + * profile's OAuth providers through `./[provider]/connect` and + * `./[provider]/disconnect`, whose state names the profile itself rather than + * carrying a record selector. */ export const GET = apiHandler(async () => { const context = await requireGuardianAuth(); diff --git a/src/app/api/strava/callback/__tests__/route.test.ts b/src/app/api/strava/callback/__tests__/route.test.ts index c967bce51..2594d1adb 100644 --- a/src/app/api/strava/callback/__tests__/route.test.ts +++ b/src/app/api/strava/callback/__tests__/route.test.ts @@ -49,6 +49,15 @@ vi.mock("@/lib/oauth/callback-claim", () => ({ duplicateOAuthCallback: async () => null, })); +vi.mock("@/lib/integrations/oauth-kit/connect-target", () => ({ + // The guardian arm is covered in its own test; here the record's own + // browser (or none) may finish, any other session may not. + verifyCallbackActor: async (record: string, session: string | null) => + session === null || session === record + ? { ok: true, guardianUserId: null } + : { ok: false, reason: "cross_user" }, +})); + vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, })); diff --git a/src/app/api/strava/callback/route.ts b/src/app/api/strava/callback/route.ts index 1fc7ddc2b..b6d184849 100644 --- a/src/app/api/strava/callback/route.ts +++ b/src/app/api/strava/callback/route.ts @@ -1,6 +1,7 @@ import { prisma } from "@/lib/db"; import { apiHandler } from "@/lib/api-handler"; import { getSession } from "@/lib/auth/session"; +import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; @@ -69,11 +70,18 @@ export const GET = apiHandler(async (request: NextRequest) => { const evt = getEvent(); if (evt) evt.setAuth({ user_id: userId, auth_method: "session" }); + // The browser finishing the flow must be the record's own, or, for a + // managed profile, a guardian's with a live MANAGE grant on it. const sessionData = await getSession(); - if (sessionData && sessionData.user.id !== userId) { - annotate({ meta: { reason: "cross_user" } }); + const actorCheck = await verifyCallbackActor( + userId, + sessionData?.user.id ?? null, + ); + if (!actorCheck.ok) { + annotate({ meta: { reason: actorCheck.reason } }); return ERR("cross_user"); } + const guardianUserId = actorCheck.guardianUserId; if (!code) return ERR("nocode"); @@ -89,12 +97,13 @@ export const GET = apiHandler(async (request: NextRequest) => { ).toString(), stateCookie: oauthStateCookieName("strava"), }, - () => completeCallback(userId, code), + () => completeCallback(userId, guardianUserId, code), ); }); async function completeCallback( userId: string, + guardianUserId: string | null, code: string, ): Promise { try { @@ -131,7 +140,10 @@ async function completeCallback( }, }); - await auditLog("strava.connect", { userId }); + await auditLog("strava.connect", { + userId, + actorUserId: guardianUserId, + }); await markReconnected(userId, "strava"); // Enqueue the self-converging history backfill at connect time so deep diff --git a/src/app/api/whoop/callback/__tests__/route.test.ts b/src/app/api/whoop/callback/__tests__/route.test.ts index 2f8d632fb..c7750844f 100644 --- a/src/app/api/whoop/callback/__tests__/route.test.ts +++ b/src/app/api/whoop/callback/__tests__/route.test.ts @@ -11,6 +11,15 @@ vi.mock("@/lib/oauth/callback-claim", async (importOriginal) => ({ duplicateOAuthCallback: async () => null, })); +vi.mock("@/lib/integrations/oauth-kit/connect-target", () => ({ + // The guardian arm is covered in its own test; here the record's own + // browser (or none) may finish, any other session may not. + verifyCallbackActor: async (record: string, session: string | null) => + session === null || session === record + ? { ok: true, guardianUserId: null } + : { ok: false, reason: "cross_user" }, +})); + vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, })); diff --git a/src/app/api/whoop/callback/route.ts b/src/app/api/whoop/callback/route.ts index 63d190764..89bc61364 100644 --- a/src/app/api/whoop/callback/route.ts +++ b/src/app/api/whoop/callback/route.ts @@ -1,6 +1,7 @@ import { prisma } from "@/lib/db"; import { apiHandler } from "@/lib/api-handler"; import { getSession } from "@/lib/auth/session"; +import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; @@ -180,11 +181,18 @@ export const GET = apiHandler(async (request: NextRequest) => { // Optional session cross-check: only when a web session cookie is present. // The native ticket path carries no session — the nonce cookie + atomic // single-use delete already bind the row to its originator. + // A guardian finishing a managed profile's connect needs a live MANAGE + // grant on it; a managed profile has no session of its own. const sessionData = await getSession(); - if (sessionData && sessionData.user.id !== userId) { - annotate({ meta: { reason: "cross_user" } }); + const actorCheck = await verifyCallbackActor( + userId, + sessionData?.user.id ?? null, + ); + if (!actorCheck.ok) { + annotate({ meta: { reason: actorCheck.reason } }); return outcomeRedirect(returnScheme, "error", "cross_user"); } + const guardianUserId = actorCheck.guardianUserId; if (!code) { return outcomeRedirect(returnScheme, "error", "nocode"); @@ -237,7 +245,10 @@ export const GET = apiHandler(async (request: NextRequest) => { throw err; } - await auditLog("whoop.connect", { userId }); + await auditLog("whoop.connect", { + userId, + actorUserId: guardianUserId, + }); // Re-completing OAuth clears any prior reauth-required state. await markReconnected(userId, "whoop"); diff --git a/src/app/api/withings/callback/__tests__/route.test.ts b/src/app/api/withings/callback/__tests__/route.test.ts index 12a5da528..89ff7a13e 100644 --- a/src/app/api/withings/callback/__tests__/route.test.ts +++ b/src/app/api/withings/callback/__tests__/route.test.ts @@ -31,12 +31,18 @@ vi.mock("@/lib/oauth/callback-claim", async (importOriginal) => ({ duplicateOAuthCallback: async () => null, })); +vi.mock("@/lib/integrations/oauth-kit/connect-target", () => ({ + // The guardian arm is covered in its own test; here the record's own + // browser (or none) may finish, any other session may not. + verifyCallbackActor: async (record: string, session: string | null) => + session === null || session === record + ? { ok: true, guardianUserId: null } + : { ok: false, reason: "cross_user" }, +})); + vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, - requireAuth: vi.fn(async () => ({ - user: { id: "user-1" }, - session: { id: "sess-1" }, - })), + AUTH_ERROR_CODES: { missing: "auth.missing" }, HttpError: class HttpError extends Error { constructor( public status: number, @@ -47,6 +53,13 @@ vi.mock("@/lib/api-handler", () => ({ }, })); +vi.mock("@/lib/auth/session", () => ({ + getSession: vi.fn(async () => ({ + user: { id: "user-1" }, + session: { id: "sess-1" }, + })), +})); + vi.mock("@/lib/db", () => { const prisma = { withingsOAuthState: { diff --git a/src/app/api/withings/callback/route.ts b/src/app/api/withings/callback/route.ts index f1c02e33c..a151b2250 100644 --- a/src/app/api/withings/callback/route.ts +++ b/src/app/api/withings/callback/route.ts @@ -1,5 +1,7 @@ import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, AUTH_ERROR_CODES, HttpError } from "@/lib/api-handler"; +import { getSession } from "@/lib/auth/session"; +import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; @@ -52,7 +54,15 @@ import { timingSafeEqual } from "node:crypto"; * lands on `replay`. */ export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // The person in the browser, read off the session itself rather than + // through a record resolver: a provider redirect is a navigation, it cannot + // carry the record-session header, and the record is the one the state row + // names, checked against this person below. A session is still required. + const sessionData = await getSession(); + if (!sessionData) { + throw new HttpError(401, "Not authenticated", AUTH_ERROR_CODES.missing); + } + const actor = sessionData.user; annotate({ action: { name: "withings.callback" } }); const { searchParams } = new URL(request.url); @@ -161,8 +171,12 @@ export const GET = apiHandler(async (request: NextRequest) => { ), ); } - if (consumed.userId !== user.id) { - annotate({ meta: { reason: "cross_user" } }); + // The record the connect started for; a guardian may finish it for a + // managed profile it holds a live MANAGE grant on. + const recordUserId = consumed.userId; + const actorCheck = await verifyCallbackActor(recordUserId, actor.id); + if (!actorCheck.ok) { + annotate({ meta: { reason: actorCheck.reason } }); return NextResponse.redirect( new URL( "/settings/integrations?withings=error&reason=cross_user", @@ -181,7 +195,7 @@ export const GET = apiHandler(async (request: NextRequest) => { } try { - const creds = await getUserWithingsCredentials(user.id); + const creds = await getUserWithingsCredentials(recordUserId); if (!creds) { return NextResponse.redirect( new URL( @@ -204,7 +218,7 @@ export const GET = apiHandler(async (request: NextRequest) => { // entirely. Storing the requested set is therefore a safe // representation of what the connection holds. await prisma.withingsConnection.upsert({ - where: { userId: user.id }, + where: { userId: recordUserId }, update: { withingsUserId: tokens.userid, accessToken: encrypt(tokens.access_token), @@ -221,7 +235,7 @@ export const GET = apiHandler(async (request: NextRequest) => { sleepStageReads: Prisma.DbNull, }, create: { - userId: user.id, + userId: recordUserId, withingsUserId: tokens.userid, accessToken: encrypt(tokens.access_token), refreshToken: encrypt(tokens.refresh_token), @@ -233,18 +247,19 @@ export const GET = apiHandler(async (request: NextRequest) => { }); // Reconcile every category from a clean state after connect/reconnect. - setupWebhook(user.id).catch(() => + setupWebhook(recordUserId).catch(() => getEvent()?.addWarning("Webhook setup failed"), ); await auditLog("withings.connect", { - userId: user.id, + userId: recordUserId, + actorUserId: actorCheck.guardianUserId, details: { withingsUserId: tokens.userid }, }); // Re-completing OAuth clears any prior `error_reauth` state. We // don't write a fresh success-time — that's the next sync's job. - await markReconnected(user.id, "withings"); + await markReconnected(recordUserId, "withings"); const response = NextResponse.redirect( new URL( diff --git a/src/components/settings/__tests__/managed-integration-status.test.tsx b/src/components/settings/__tests__/managed-integration-status.test.tsx index 8cc091ea6..f782d0d55 100644 --- a/src/components/settings/__tests__/managed-integration-status.test.tsx +++ b/src/components/settings/__tests__/managed-integration-status.test.tsx @@ -1,5 +1,6 @@ /** - * A managed record's connections on the shared integration card, read-only. + * A managed record's connections on the shared integration card: a guardian + * connects and disconnects the record's OAuth providers, the rest is status. */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; @@ -32,6 +33,8 @@ vi.mock("@tanstack/react-query", () => ({ isError: false, refetch: vi.fn(), }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), })); import { I18nProvider } from "@/lib/i18n/context"; @@ -77,7 +80,7 @@ describe("", () => { loading = false; }); - it("renders one shared card per provider from health, with no control on any", () => { + it("renders one shared card per provider from health, with controls on the OAuth ones", () => { payload = withHealth({ oura: healthFixture({ state: "action_required", @@ -100,20 +103,37 @@ describe("", () => { expect(html).toMatch( /data-testid="managed-integration-oura"[\s\S]*?data-slot="integration-health-badge"[^>]*data-state="action_required"/, ); - // Read-only even where the state asks for an action. - expect(html).not.toContain('data-slot="integration-primary-action"'); - expect(html).not.toContain('data-slot="integration-menu-trigger"'); + // The reconnect a broken grant asks for, on the card that asks for it. + expect(html).toContain('data-testid="oura-reconnect-button"'); + // A connected OAuth card carries the disconnect menu, and every OAuth card + // says whose record it connects for. + expect(html).toContain('data-testid="withings-menu"'); + expect(html.match(/Connects for Managed record/g)).toHaveLength(6); + // Each unconnected OAuth provider offers one connect for the record; + // Fitbit (retired) and Nightscout (no OAuth) stay status only. + for (const key of ["whoop", "polar", "google-health", "strava"]) { + expect(html).toContain( + `data-testid="managed-integration-${key}-connect"`, + ); + } + expect(html).not.toContain( + 'data-testid="managed-integration-fitbit-connect"', + ); + expect(html).not.toContain( + 'data-testid="managed-integration-nightscout-connect"', + ); + expect(html.match(/>Connect for Managed recordNot connected\. { + it("says once where connected data goes, and links the explanation", () => { payload = withHealth({}); const html = render(); - expect(html).toContain('data-slot="managed-integration-read-only"'); + expect(html).toContain('data-slot="managed-integration-target"'); expect(html).toContain( - "A managed profile shows its connected services as a status only.", + "Services you connect here send their data to Managed record's record, not to yours.", ); expect(html).toContain( 'href="https://docs.healthlog.dev/self-hosting/account-sharing#records-with-no-login"', diff --git a/src/components/settings/managed-integration-status.tsx b/src/components/settings/managed-integration-status.tsx index 22f5de00e..4ce08a958 100644 --- a/src/components/settings/managed-integration-status.tsx +++ b/src/components/settings/managed-integration-status.tsx @@ -1,17 +1,32 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; -import { Droplet, ExternalLink, Plug } from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Droplet, ExternalLink, Link2, Plug, Unplug } from "lucide-react"; -import { IntegrationCard } from "@/components/settings/integrations/integration-card"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { + IntegrationCard, + type IntegrationMenuAction, +} from "@/components/settings/integrations/integration-card"; +import { Button } from "@/components/ui/button"; import { OAUTH_INTEGRATIONS } from "@/components/settings/integrations/oauth-integration-card"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { SectionHeading } from "@/components/ui/section-heading"; -import { apiGet } from "@/lib/api/api-fetch"; +import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import type { ConnectionHealth } from "@/lib/integrations/connection-health"; -import { queryKeys } from "@/lib/query-keys"; +import { + isManagedOAuthProvider, + type ManagedOAuthProvider, +} from "@/lib/integrations/oauth-kit/managed-provider"; +import { loadDocument } from "@/lib/navigation/load-document"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; import { assertRecordSettingsResponseForRecord } from "@/lib/record-settings/response"; type ManagedIntegrationKey = keyof typeof INTEGRATION_DISPLAY_NAMES; @@ -49,15 +64,18 @@ function iconFor(key: ManagedIntegrationKey) { * The managed record's connections, on the same card every integration in * Settings renders, from the same server-resolved `health`. * - * Read-only by construction: a guardian cannot connect, reconnect, resume, - * sync, test or disconnect a managed record's provider, because an OAuth flow - * cannot safely carry a record selector. So the cards carry no menu and no - * button, and one sentence above them says why, with the page that explains - * the boundary. + * A guardian connects and disconnects the record's OAuth providers here. The + * connect starts with a fetch (`POST /api/record-settings/integrations/ + * /connect`, which carries the record-session headers a navigation + * cannot) and then sends the browser to the provider; tokens and data land on + * the managed record. Every card says whose record it connects for. Syncing, + * testing and provider credentials stay out: the record syncs on its own + * schedule once connected. */ export function ManagedIntegrationStatus() { const { t } = useTranslations(); const { user } = useAuth(); + const queryClient = useQueryClient(); const activeRecord = user?.accountAccess?.active ?? null; const recordId = activeRecord?.accountId ?? null; const statusQuery = useQuery({ @@ -72,6 +90,57 @@ export function ManagedIntegrationStatus() { enabled: activeRecord?.recordKind === "managed" && recordId !== null, }); + const recordName = activeRecord?.displayName ?? ""; + + const invalidateStatus = () => + queryClient.invalidateQueries({ + queryKey: queryKeys.recordSettingsIntegrations(recordId ?? ""), + }); + + const connect = useMutation({ + mutationFn: async (provider: ManagedOAuthProvider) => + apiPost<{ authorizationUrl: string }>( + `/api/record-settings/integrations/${provider}/connect`, + ), + onSuccess: (started) => loadDocument(started.authorizationUrl), + onError: (failure, provider) => + showErrorToast( + failure instanceof ApiError && failure.status === 409 + ? t("settings.sharedRecord.integrationNoClientApp", { + name: recordName, + provider: INTEGRATION_DISPLAY_NAMES[provider], + }) + : t("settings.sharedRecord.integrationConnectFailed", { + provider: INTEGRATION_DISPLAY_NAMES[provider], + }), + { cause: failure }, + ), + }); + + const disconnect = useMutation({ + mutationFn: async (provider: ManagedOAuthProvider) => + apiPost(`/api/record-settings/integrations/${provider}/disconnect`), + onSuccess: (_result, provider) => { + void invalidateStatus(); + // A record-settings write; the capability answer reads the record's + // connections, so it is evicted with the rest of the AI inputs. + void invalidateKeys(queryClient, aiInputDependentKeys); + toastWrittenOutcome( + "success", + t("settings.integrationDisconnected", { + provider: INTEGRATION_DISPLAY_NAMES[provider], + }), + ); + }, + onError: (failure, provider) => + showErrorToast( + t("settings.integrationDisconnectFailed", { + provider: INTEGRATION_DISPLAY_NAMES[provider], + }), + { cause: failure }, + ), + }); + if (statusQuery.isError) { return ( ({ integration, health: null }), ); + /** What a guardian can do with one OAuth provider of the record. */ + function managedControls(provider: ManagedOAuthProvider) { + const providerName = INTEGRATION_DISPLAY_NAMES[provider]; + const pending = + (connect.isPending && connect.variables === provider) || + (disconnect.isPending && disconnect.variables === provider); + const forRecord = ( +

+ {t("settings.sharedRecord.integrationConnectsFor", { + name: recordName, + })} +

+ ); + const menu: IntegrationMenuAction[] = [ + { + id: "disconnect", + label: t("settings.sharedRecord.integrationDisconnect"), + icon: Unplug, + destructive: true, + disabled: pending, + onSelect: () => disconnect.mutate(provider), + confirm: { + title: t("settings.sharedRecord.integrationDisconnectTitle", { + provider: providerName, + name: recordName, + }), + body: t("settings.sharedRecord.integrationDisconnectBody", { + name: recordName, + }), + confirmLabel: t("settings.sharedRecord.integrationDisconnect"), + }, + }, + ]; + return { + children: forRecord, + menu, + primary: { + onReconnect: () => connect.mutate(provider), + pending, + }, + connectAction: ( + + ), + }; + } + return (
-

- {t("settings.sharedRecord.integrationStatusDetail")}{" "} +

+ {t("settings.sharedRecord.integrationStatusDetail", { + name: recordName, + })}{" "} ))} diff --git a/src/lib/integrations/oauth-kit/__tests__/connect-target.test.ts b/src/lib/integrations/oauth-kit/__tests__/connect-target.test.ts new file mode 100644 index 000000000..19ec6a345 --- /dev/null +++ b/src/lib/integrations/oauth-kit/__tests__/connect-target.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { findUnique, update, findActiveGrant, auditLog } = vi.hoisted(() => ({ + findUnique: vi.fn(), + update: vi.fn(async () => ({})), + findActiveGrant: vi.fn(), + auditLog: vi.fn(async () => undefined), +})); + +vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique, update } } })); +vi.mock("@/lib/auth/audit", () => ({ auditLog })); +vi.mock("@/lib/sharing/grants", () => ({ + findActiveGrant, + grantAllows: ( + grant: { access: string; revokedAt: Date | null }, + need: string, + ) => + grant.revokedAt === null && + (need !== "manage" || grant.access === "MANAGE"), +})); + +import { shareGuardianClientApp, verifyCallbackActor } from "../connect-target"; + +const MANAGED = { managedProfileAt: new Date("2026-01-01T00:00:00Z") }; +const ADULT = { managedProfileAt: null }; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("verifyCallbackActor", () => { + it("admits the record's own session without a lookup", async () => { + await expect(verifyCallbackActor("rec", "rec")).resolves.toEqual({ + ok: true, + guardianUserId: null, + }); + expect(findUnique).not.toHaveBeenCalled(); + }); + + it("keeps the session-less own-account path for an ordinary account", async () => { + findUnique.mockResolvedValue(ADULT); + await expect(verifyCallbackActor("rec", null)).resolves.toEqual({ + ok: true, + guardianUserId: null, + }); + }); + + it("refuses another person's session on an ordinary account, grant or not", async () => { + findUnique.mockResolvedValue(ADULT); + findActiveGrant.mockResolvedValue({ access: "MANAGE", revokedAt: null }); + await expect(verifyCallbackActor("rec", "other")).resolves.toEqual({ + ok: false, + reason: "cross_user", + }); + expect(findActiveGrant).not.toHaveBeenCalled(); + }); + + it("admits a guardian with a live MANAGE grant on a managed profile", async () => { + findUnique.mockResolvedValue(MANAGED); + findActiveGrant.mockResolvedValue({ access: "MANAGE", revokedAt: null }); + await expect(verifyCallbackActor("rec", "guardian")).resolves.toEqual({ + ok: true, + guardianUserId: "guardian", + }); + expect(findActiveGrant).toHaveBeenCalledWith({ + grantorId: "rec", + granteeId: "guardian", + }); + }); + + it("refuses a read or write grant, and a grant revoked on the consent screen", async () => { + findUnique.mockResolvedValue(MANAGED); + findActiveGrant.mockResolvedValueOnce({ access: "WRITE", revokedAt: null }); + await expect(verifyCallbackActor("rec", "g")).resolves.toMatchObject({ + ok: false, + }); + findActiveGrant.mockResolvedValueOnce(null); + await expect(verifyCallbackActor("rec", "g")).resolves.toMatchObject({ + ok: false, + }); + }); + + it("requires a guardian's session for a managed profile", async () => { + findUnique.mockResolvedValue(MANAGED); + await expect(verifyCallbackActor("rec", null)).resolves.toEqual({ + ok: false, + reason: "guardian_session_required", + }); + }); + + it("refuses a record that does not exist", async () => { + findUnique.mockResolvedValue(null); + await expect(verifyCallbackActor("gone", null)).resolves.toMatchObject({ + ok: false, + }); + }); +}); + +describe("shareGuardianClientApp", () => { + const target = { recordUserId: "rec", actorUserId: "guardian" }; + + it("does nothing on a person's own connect", async () => { + const has = vi.fn(async () => false); + await shareGuardianClientApp( + "oura", + { recordUserId: "a", actorUserId: "a" }, + has, + ); + expect(has).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + + it("does nothing when the profile already resolves an app", async () => { + await shareGuardianClientApp("oura", target, async () => true); + expect(findUnique).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + + it("copies the guardian's sealed app onto the profile and audits it", async () => { + findUnique.mockResolvedValue({ + ouraClientIdEncrypted: "sealed-id", + ouraClientSecretEncrypted: "sealed-secret", + }); + await shareGuardianClientApp("oura", target, async () => false); + expect(update).toHaveBeenCalledWith({ + where: { id: "rec" }, + data: { + ouraClientIdEncrypted: "sealed-id", + ouraClientSecretEncrypted: "sealed-secret", + }, + }); + expect(auditLog).toHaveBeenCalledWith("integration.client_app.shared", { + userId: "rec", + actorUserId: "guardian", + details: { provider: "oura" }, + }); + }); + + it("copies nothing when the guardian has no app either", async () => { + findUnique.mockResolvedValue({ + stravaClientIdEncrypted: null, + stravaClientSecretEncrypted: null, + }); + await shareGuardianClientApp("strava", target, async () => false); + expect(update).not.toHaveBeenCalled(); + expect(auditLog).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/integrations/oauth-kit/connect-target.ts b/src/lib/integrations/oauth-kit/connect-target.ts new file mode 100644 index 000000000..98dafb33b --- /dev/null +++ b/src/lib/integrations/oauth-kit/connect-target.ts @@ -0,0 +1,119 @@ +/** + * Whose record an OAuth connect lands on, and who is allowed to finish it. + * + * A guardian connects a provider for a managed profile they manage: the + * connect route resolves through `requireGuardianAuth`, so its `user` is the + * profile and its `actor` the guardian, and the state it mints names the + * profile. Tokens and data land on the profile. The state never carries a + * record selector the browser could change: it is signed, or a server-side + * ledger row, and it names exactly one record. + * + * The callback then has to answer one question the connect could not: is the + * browser completing this flow allowed to attach a provider account to that + * record? For a person's own record the answer is unchanged (their session, or + * no session at all on the flows that allow one). For a managed profile, which + * has no login of its own, the browser must hold a session of a guardian with + * a live MANAGE grant on the profile, checked again here because ten minutes + * can pass on a consent screen and a grant can be revoked in them. + */ +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { findActiveGrant, grantAllows } from "@/lib/sharing/grants"; +import type { OAuthKitProvider } from "./token"; + +export type CallbackActorVerdict = + | { + readonly ok: true; + /** The guardian who finished it, or null for the record's own browser. */ + readonly guardianUserId: string | null; + } + | { + readonly ok: false; + readonly reason: "cross_user" | "guardian_session_required"; + }; + +/** + * May the session `sessionUserId` (null: no session) finish a connect whose + * state names `recordUserId`? On success, which guardian acted, if one did. + */ +export async function verifyCallbackActor( + recordUserId: string, + sessionUserId: string | null, +): Promise { + if (sessionUserId === recordUserId) { + return { ok: true, guardianUserId: null }; + } + const record = await prisma.user.findUnique({ + where: { id: recordUserId }, + select: { managedProfileAt: true }, + }); + if (!record) return { ok: false, reason: "cross_user" }; + if (record.managedProfileAt === null) { + // An ordinary account: its own browser without a session may finish (the + // native flows), anybody else's session may not. + return sessionUserId === null + ? { ok: true, guardianUserId: null } + : { ok: false, reason: "cross_user" }; + } + if (sessionUserId === null) { + return { ok: false, reason: "guardian_session_required" }; + } + const grant = await findActiveGrant({ + grantorId: recordUserId, + granteeId: sessionUserId, + }); + if (!grant || !grantAllows(grant, "manage")) { + return { ok: false, reason: "cross_user" }; + } + return { ok: true, guardianUserId: sessionUserId }; +} + +/** The per-user columns holding a provider's own OAuth app. */ +const CLIENT_COLUMNS = { + withings: ["withingsClientIdEncrypted", "withingsClientSecretEncrypted"], + whoop: ["whoopClientIdEncrypted", "whoopClientSecretEncrypted"], + oura: ["ouraClientIdEncrypted", "ouraClientSecretEncrypted"], + strava: ["stravaClientIdEncrypted", "stravaClientSecretEncrypted"], + "google-health": [ + "googleHealthClientIdEncrypted", + "googleHealthClientSecretEncrypted", + ], + polar: ["polarClientIdEncrypted", "polarClientSecretEncrypted"], +} as const satisfies Record; + +/** + * Let a managed profile connect with its guardian's own provider app. + * + * Every provider here is bring-your-own-app: the client id and secret sit on + * the account that connects, and the background refresh reads them from that + * account. A managed profile never had a settings page to paste them into, so + * when it has no app of its own and the deployment has no shared one, the + * guardian's app is copied onto the profile, still sealed, with an audit row + * on the profile naming the guardian. Does nothing for a person's own connect, and nothing when + * the profile can already resolve an app. + */ +export async function shareGuardianClientApp( + provider: OAuthKitProvider, + target: { recordUserId: string; actorUserId: string }, + recordHasApp: () => Promise, +): Promise { + if (target.recordUserId === target.actorUserId) return; + if (await recordHasApp()) return; + const [idColumn, secretColumn] = CLIENT_COLUMNS[provider]; + const guardian = (await prisma.user.findUnique({ + where: { id: target.actorUserId }, + select: { [idColumn]: true, [secretColumn]: true }, + })) as Record | null; + const clientId = guardian?.[idColumn] ?? null; + const clientSecret = guardian?.[secretColumn] ?? null; + if (!clientId || !clientSecret) return; + await prisma.user.update({ + where: { id: target.recordUserId }, + data: { [idColumn]: clientId, [secretColumn]: clientSecret }, + }); + await auditLog("integration.client_app.shared", { + userId: target.recordUserId, + actorUserId: target.actorUserId, + details: { provider }, + }); +} diff --git a/src/lib/integrations/oauth-kit/managed-provider.ts b/src/lib/integrations/oauth-kit/managed-provider.ts new file mode 100644 index 000000000..8235cdcc9 --- /dev/null +++ b/src/lib/integrations/oauth-kit/managed-provider.ts @@ -0,0 +1,26 @@ +/** + * The OAuth providers a guardian may connect for a managed profile, and the + * route parameter that names one. + * + * Every kit provider qualifies. Fitbit does not: the classic Fitbit Web API is + * retired on 2026-10-30 and takes no new connections. Nightscout and Dawarich + * are not OAuth flows. + */ +import type { OAuthKitProvider } from "./token"; + +export const MANAGED_OAUTH_PROVIDERS = [ + "withings", + "whoop", + "oura", + "polar", + "strava", + "google-health", +] as const satisfies readonly OAuthKitProvider[]; + +export type ManagedOAuthProvider = (typeof MANAGED_OAUTH_PROVIDERS)[number]; + +export function isManagedOAuthProvider( + value: string, +): value is ManagedOAuthProvider { + return (MANAGED_OAUTH_PROVIDERS as readonly string[]).includes(value); +} diff --git a/src/lib/openapi/routes/record-settings.ts b/src/lib/openapi/routes/record-settings.ts index d332d79e3..35fcc9c2f 100644 --- a/src/lib/openapi/routes/record-settings.ts +++ b/src/lib/openapi/routes/record-settings.ts @@ -26,6 +26,7 @@ import { z } from "zod/v4"; import { METRIC_BOUNDS } from "@/lib/analytics/effective-range"; import { MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS } from "@/lib/record-settings"; import { connectionHealth } from "./integrations"; +import { MANAGED_OAUTH_PROVIDERS } from "@/lib/integrations/oauth-kit/managed-provider"; import { dataEnvelope, errorEnvelope, @@ -112,7 +113,7 @@ const recordSettingsIntegrationsResponse = z .meta({ id: "RecordSettingsIntegrationsResponse", description: - "Whether the managed record's integrations are connected and how each is doing (`health`), and nothing else. Deliberately no control and no credential projection — OAuth and provider state cannot safely carry a record selector, so a Guardian can see that a pipe exists but cannot connect, disconnect, probe or resume it from here. A provider with no real credential reads `disconnected` even when its ledger row says otherwise, because a synthesised healthy ledger is not proof of a connection.", + "Whether the managed record's integrations are connected and how each is doing (`health`), and nothing else. No credential projection. A Guardian connects or disconnects an OAuth provider for the record through `POST /api/record-settings/integrations/{provider}/connect` and `/disconnect`. A provider with no real credential reads `disconnected` even when its ledger row says otherwise, because a synthesised healthy ledger is not proof of a connection.", }); // ── The six family DTOs ────────────────────────────────────────────── @@ -334,6 +335,45 @@ const familyParameter = { "Which configuration family to read or write. An unrecognised value is a 404 — and note that the family check runs BEFORE authentication, so an unknown family answers 404 to an unauthenticated caller where a known one would answer 401.", }; +const managedProviderEnum = z.enum(MANAGED_OAUTH_PROVIDERS); + +const managedProviderParameter = { + name: "provider", + in: "path" as const, + required: true, + schema: { + type: "string" as const, + enum: [...MANAGED_OAUTH_PROVIDERS], + }, + description: + "The OAuth provider. An unrecognised value is a 404, answered before authentication.", +}; + +const managedConnectStartResponse = z + .object({ + recordId: z.string(), + provider: managedProviderEnum, + authorizationUrl: z + .string() + .describe("The provider's consent URL, with the state the record owns."), + }) + .meta({ + id: "ManagedConnectStartResponse", + description: + "Where to send the browser to connect a provider for the managed record.", + }); + +const managedDisconnectResponse = z + .object({ + recordId: z.string(), + provider: managedProviderEnum, + disconnected: z.literal(true), + }) + .meta({ + id: "ManagedDisconnectResponse", + description: "The provider is no longer connected to the managed record.", + }); + export const recordSettingsPaths: NonNullable = { "/api/record-settings": { get: { @@ -363,7 +403,7 @@ export const recordSettingsPaths: NonNullable = { tags: ["Account sharing"], summary: "Read the managed record's integration connection states", description: - "Disclosure only: whether each of the record's integrations is connected, when it last succeeded, and its server-resolved health. There is deliberately no control arm and no credential projection anywhere under this path — a Guardian cannot connect, disconnect, probe or resume a managed record's provider, because an OAuth flow cannot safely carry a record selector. Cookie session or Bearer, while acting on a managed record the caller guards.", + "Whether each of the record's integrations is connected, when it last succeeded, and its server-resolved health. No credential projection. Connecting and disconnecting an OAuth provider are the two POST routes below this path. Cookie session or Bearer, while acting on a managed record the caller guards.", responses: { ...recordRefusal(), "200": { @@ -381,6 +421,69 @@ export const recordSettingsPaths: NonNullable = { }, }, }, + "/api/record-settings/integrations/{provider}/connect": { + post: { + tags: ["Account sharing"], + summary: "Start connecting an OAuth provider for the managed record", + description: + "Mints the OAuth state for the record the session is acting on, sets it as an httpOnly cookie, and answers with the provider's consent URL; the client then sends the browser there. A fetch rather than a navigation, so the record-session fence applies. The state names the managed record: the provider's callback lands the tokens on it after checking the browser still belongs to a Guardian holding a live MANAGE grant on that record. When the record has no provider app of its own and the deployment has none, the Guardian's own app is copied onto the record (audited). Fitbit is not offered: its classic API is retired. Cookie session, while acting on a managed record the caller guards.", + parameters: [managedProviderParameter], + responses: { + ...recordRefusal(), + "200": { + description: "The consent URL to send the browser to.", + content: { + "application/json": { + schema: dataEnvelope( + managedConnectStartResponse, + "ManagedConnectStartEnvelope", + ), + }, + }, + }, + "404": { + description: + "The path named a provider outside the closed set. Answered before the caller is authenticated.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: + "Neither the record, the Guardian, nor the deployment has an OAuth app for this provider.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/record-settings/integrations/{provider}/disconnect": { + post: { + tags: ["Account sharing"], + summary: "Disconnect an OAuth provider from the managed record", + description: + "Runs the same disconnect the owner of a record runs: the grant is dropped, the provider told where it can (Withings webhooks, Strava deauthorisation), and the integration parked at `disconnected`. Imported data stays. Audited against the record with the Guardian as the actor. Cookie session or Bearer, while acting on a managed record the caller guards.", + parameters: [managedProviderParameter], + responses: { + ...recordRefusal(), + "200": { + description: "The provider is disconnected.", + content: { + "application/json": { + schema: dataEnvelope( + managedDisconnectResponse, + "ManagedDisconnectEnvelope", + ), + }, + }, + }, + "404": { + description: + "The path named a provider outside the closed set, or the record has no connection to it.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, "/api/record-settings/{family}": { get: { tags: ["Account sharing"],