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:
Marc-André Bombeck committed 2026-10-21 19:49:07 +02:00
1 parent 65e0cba0bf
commit 74ce17b7de
35 files changed
+1392 -158

No files matched your search

+227 -71
View File
@@ -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: