mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
This commit is contained in:
1 parent
65e0cba0bf
commit
74ce17b7de
35 files changed
+1392
-158
No files matched your search
+227
-71
@@ -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:
|
||||
|
||||
+9
-2
@@ -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": {
|
||||
|
||||
+9
-2
@@ -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": {
|
||||
|
||||
+9
-2
@@ -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": {
|
||||
|
||||
+9
-2
@@ -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": {
|
||||
|
||||
+9
-2
@@ -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": {
|
||||
|
||||
+9
-2
@@ -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": {
|
||||
|
||||
+9
-2
@@ -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": {
|
||||
|
||||
+9
-2
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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("<Button");
|
||||
expect(statusView).not.toContain("<Link");
|
||||
// A guardian's controls go through the fenced managed-record routes, never
|
||||
// the owner's provider routes and never a navigation to the API.
|
||||
expect(statusView).toContain(
|
||||
"/api/record-settings/integrations/${provider}/connect",
|
||||
);
|
||||
expect(statusView).toContain(
|
||||
"/api/record-settings/integrations/${provider}/disconnect",
|
||||
);
|
||||
expect(statusView).not.toMatch(/["`]\/api\/\$\{provider\}\//);
|
||||
expect(statusView).not.toMatch(/loadDocument\(\s*["`]\/api/);
|
||||
expect(statusView).not.toMatch(/<Link[\s>]/);
|
||||
expect(settingsPage).toContain("RecordSettingsSectionGate");
|
||||
expect(sectionGate).toContain("if (!inSharedRecord) return children");
|
||||
expect(sectionGate).toContain("ManagedIntegrationStatus");
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -2044,6 +2044,10 @@ const GUARDIAN_ROUTES: Record<string, string> = {
|
||||
"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<string, string> = {
|
||||
* 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` —
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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: <T extends (...args: unknown[]) => 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<NextResponse> {
|
||||
@@ -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
|
||||
|
||||
@@ -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: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
@@ -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<NextResponse> {
|
||||
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
|
||||
|
||||
@@ -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: <T extends (...args: unknown[]) => 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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
|
||||
@@ -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: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
@@ -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<NextResponse> {
|
||||
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
|
||||
|
||||
@@ -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: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: <T extends (...args: unknown[]) => 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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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("<ManagedIntegrationStatus>", () => {
|
||||
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("<ManagedIntegrationStatus>", () => {
|
||||
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 record</g)).toHaveLength(4);
|
||||
// The legacy `state: "connected"` on every row does not make a card
|
||||
// connected; only `health` does.
|
||||
expect(html.match(/>Not connected\.</g)).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("says once why nothing here can be changed, and links the explanation", () => {
|
||||
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"',
|
||||
|
||||
@@ -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/
|
||||
* <provider>/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 (
|
||||
<QueryErrorCard
|
||||
@@ -88,6 +157,68 @@ export function ManagedIntegrationStatus() {
|
||||
(integration) => ({ 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 = (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="managed-integration-for"
|
||||
>
|
||||
{t("settings.sharedRecord.integrationConnectsFor", {
|
||||
name: recordName,
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
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: (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => connect.mutate(provider)}
|
||||
disabled={pending}
|
||||
aria-busy={pending || undefined}
|
||||
data-testid={`managed-integration-${provider}-connect`}
|
||||
>
|
||||
<Link2 className="size-3.5" aria-hidden="true" />
|
||||
{t("settings.sharedRecord.integrationConnectFor", {
|
||||
name: recordName,
|
||||
})}
|
||||
</Button>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="space-y-4"
|
||||
@@ -103,8 +234,10 @@ export function ManagedIntegrationStatus() {
|
||||
title={t("settings.sections.integrations.title")}
|
||||
subtitle={t("settings.sharedRecord.integrationStatusDescription")}
|
||||
/>
|
||||
<p className="text-sm" data-slot="managed-integration-read-only">
|
||||
{t("settings.sharedRecord.integrationStatusDetail")}{" "}
|
||||
<p className="text-sm" data-slot="managed-integration-target">
|
||||
{t("settings.sharedRecord.integrationStatusDetail", {
|
||||
name: recordName,
|
||||
})}{" "}
|
||||
<a
|
||||
href={SHARING_DOCS_HREF}
|
||||
target="_blank"
|
||||
@@ -129,6 +262,9 @@ export function ManagedIntegrationStatus() {
|
||||
connected={entry.health !== null}
|
||||
health={entry.health}
|
||||
testId={`managed-integration-${entry.integration}`}
|
||||
{...(isManagedOAuthProvider(entry.integration)
|
||||
? managedControls(entry.integration)
|
||||
: {})}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<CallbackActorVerdict> {
|
||||
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<OAuthKitProvider, readonly [string, string]>;
|
||||
|
||||
/**
|
||||
* 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<boolean>,
|
||||
): Promise<void> {
|
||||
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<string, string | null> | 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 },
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
"/api/record-settings": {
|
||||
get: {
|
||||
@@ -363,7 +403,7 @@ export const recordSettingsPaths: NonNullable<ZodOpenApiObject["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.",
|
||||
"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<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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"],
|
||||
|
||||
Reference in new issue
Block a user