Merge branch 'feat/v144-integrations' into release/v1.44.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-21 20:51:29 +02:00
commit 21cfd46988
113 files changed
+6630 -1259

No files matched your search

+229 -71
View File
@@ -22703,11 +22703,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.
@@ -22719,6 +22717,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:
@@ -22727,7 +22803,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
@@ -22771,7 +22847,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:
@@ -23398,13 +23474,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:
@@ -23478,8 +23554,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
@@ -23523,7 +23599,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/AdminBackupRestoreJobListResponse"
"403": *a55
"403": *a56
/api/admin/backups/restores/{jobId}:
get:
tags:
@@ -23546,7 +23622,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/AdminBackupRestoreJobResponse"
"403": *a55
"403": *a56
"404":
description: No restore job with this id.
content:
@@ -23565,7 +23641,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":
@@ -23575,7 +23651,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."
@@ -23598,9 +23674,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
@@ -23639,8 +23715,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
@@ -23659,7 +23735,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
@@ -23678,9 +23754,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.
@@ -23689,7 +23765,7 @@ paths:
schema:
$ref: "#/components/schemas/DocumentSourceDisconnectEnvelope"
"401": *a1
"403": *a58
"403": *a59
"422": *a4
"429": *a5
/api/documents/sources/{system}/test:
@@ -23700,9 +23776,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.
@@ -23711,9 +23787,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."
@@ -23722,7 +23798,7 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429": *a5
"502": *a61
"502": *a62
/api/documents/sources/{system}/tags:
get:
tags:
@@ -23730,9 +23806,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.
@@ -23741,11 +23817,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:
@@ -23759,9 +23835,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
@@ -23802,11 +23878,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:
@@ -23825,9 +23901,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:
@@ -23885,7 +23961,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.
@@ -23916,7 +23992,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`)."
@@ -23924,7 +24000,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"502": *a61
"502": *a62
/api/symptoms/definitions:
get:
tags:
@@ -23948,7 +24024,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.
@@ -23991,7 +24067,7 @@ paths:
schema:
$ref: "#/components/schemas/CreateSymptomDefinitionEnvelope"
"401": *a1
"403": *a63
"403": *a64
"409": *a7
"422": *a4
"429": *a5
@@ -24021,8 +24097,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:
@@ -24056,8 +24132,8 @@ paths:
schema:
$ref: "#/components/schemas/DeleteSymptomDefinitionEnvelope"
"401": *a1
"403": *a63
"404": *a64
"403": *a64
"404": *a65
"422": *a4
"429": *a5
/api/symptoms/events:
@@ -24106,7 +24182,7 @@ paths:
schema:
$ref: "#/components/schemas/ListSymptomEventsEnvelope"
"401": *a1
"403": *a63
"403": *a64
"422": *a4
"429": *a5
post:
@@ -24132,8 +24208,8 @@ paths:
schema:
$ref: "#/components/schemas/CreateSymptomEventEnvelope"
"401": *a1
"403": *a63
"404": *a64
"403": *a64
"404": *a65
"409": *a7
"422": *a4
"429": *a5
@@ -24163,8 +24239,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:
@@ -24191,8 +24267,8 @@ paths:
schema:
$ref: "#/components/schemas/DeleteSymptomEventEnvelope"
"401": *a1
"403": *a63
"404": *a65
"403": *a64
"404": *a66
"422": *a4
"429": *a5
/api/day/index:
@@ -24343,7 +24419,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.
@@ -24380,7 +24456,7 @@ paths:
schema:
$ref: "#/components/schemas/TimelineReadinessEnvelope"
"401": *a1
"403": *a66
"403": *a67
"422": *a4
"429": *a5
/api/life-events:
@@ -24398,7 +24474,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.
@@ -24438,7 +24514,7 @@ paths:
schema:
$ref: "#/components/schemas/CreateLifeEventEnvelope"
"401": *a1
"403": *a67
"403": *a68
"409": *a7
"422": *a4
"429": *a12
@@ -24469,8 +24545,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:
@@ -24497,8 +24573,8 @@ paths:
schema:
$ref: "#/components/schemas/DeleteLifeEventEnvelope"
"401": *a1
"403": *a67
"404": *a68
"403": *a68
"404": *a69
"422": *a4
"429": *a5
/api/search:
@@ -79651,6 +79727,8 @@ components:
- vo2max
- cardio_age
- resilience
- workout
- heartrate
hasToken:
description: Nightscout only.
type: boolean
@@ -83708,10 +83786,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:
+12 -3
View File
@@ -5416,8 +5416,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": {
@@ -6675,7 +6682,9 @@
"items": {
"vo2max": "VO2max",
"cardio_age": "kardiovaskuläres Alter",
"resilience": "Resilienz"
"resilience": "Resilienz",
"workout": "Trainings",
"heartrate": "Herzfrequenz"
}
}
}
+12 -3
View File
@@ -5416,8 +5416,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": {
@@ -6675,7 +6682,9 @@
"items": {
"vo2max": "VO2 max",
"cardio_age": "cardiovascular age",
"resilience": "resilience"
"resilience": "resilience",
"workout": "workouts",
"heartrate": "heart rate"
}
}
}
+12 -3
View File
@@ -5416,8 +5416,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": {
@@ -6675,7 +6682,9 @@
"items": {
"vo2max": "VO2 máx.",
"cardio_age": "edad cardiovascular",
"resilience": "resiliencia"
"resilience": "resiliencia",
"workout": "entrenamientos",
"heartrate": "frecuencia cardiaca"
}
}
}
+12 -3
View File
@@ -5416,8 +5416,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": {
@@ -6675,7 +6682,9 @@
"items": {
"vo2max": "la VO2 max",
"cardio_age": "l’âge cardiovasculaire",
"resilience": "la résilience"
"resilience": "la résilience",
"workout": "les entraînements",
"heartrate": "la fréquence cardiaque"
}
}
}
+12 -3
View File
@@ -5416,8 +5416,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": {
@@ -6675,7 +6682,9 @@
"items": {
"vo2max": "VO2 max",
"cardio_age": "età cardiovascolare",
"resilience": "resilienza"
"resilience": "resilienza",
"workout": "allenamenti",
"heartrate": "frequenza cardiaca"
}
}
}
+12 -3
View File
@@ -5416,8 +5416,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": {
@@ -6675,7 +6682,9 @@
"items": {
"vo2max": "VO2 max",
"cardio_age": "심혈관 나이",
"resilience": "회복탄력성"
"resilience": "회복탄력성",
"workout": "운동",
"heartrate": "심박수"
}
}
}
+12 -3
View File
@@ -5416,8 +5416,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": {
@@ -6675,7 +6682,9 @@
"items": {
"vo2max": "VO2 max",
"cardio_age": "cardiovasculaire leeftijd",
"resilience": "veerkracht"
"resilience": "veerkracht",
"workout": "trainingen",
"heartrate": "hartslag"
}
}
}
+12 -3
View File
@@ -5416,8 +5416,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": {
@@ -6675,7 +6682,9 @@
"items": {
"vo2max": "VO2 max",
"cardio_age": "wiek sercowo-naczyniowy",
"resilience": "odporność"
"resilience": "odporność",
"workout": "treningi",
"heartrate": "tętno"
}
}
}
@@ -0,0 +1,13 @@
-- Where the Oura history backfill has got, and when it finished.
--
-- The backfill reads every Oura collection's history in slices through the
-- shared integration backfill lane. After each window it writes, the progress
-- note records where the collection's next window ends, so a slice that stops
-- on its budget or a rate limit resumes there instead of from the newest day.
-- The completion stamp drops the account from boot discovery.
--
-- Both nullable and unset for every existing row: every current Oura
-- connection is offered the backfill at the next boot, with no reconnect.
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "oura_backfill_progress" JSONB;
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "oura_backfill_completed_at" TIMESTAMP(3);
+15 -5
View File
@@ -270,22 +270,32 @@ model User {
// v1.17.0 — Oura OAuth credentials (F4, per-user, encrypted at app level).
// Mirrors the WHOOP credential columns; OAuth access/refresh tokens stored
// encrypted.
ouraAccessTokenEncrypted String? @map("oura_access_token_encrypted")
ouraRefreshTokenEncrypted String? @map("oura_refresh_token_encrypted")
ouraAccessTokenEncrypted String? @map("oura_access_token_encrypted")
ouraRefreshTokenEncrypted String? @map("oura_refresh_token_encrypted")
// v1.17.1 — Oura BYO-app client credentials (W1c, per-user, encrypted at app
// level). Mirrors the WHOOP/Fitbit `clientId`/`clientSecret` columns 1:1 so a
// self-hoster pastes their own Oura Cloud app's client id/secret into Settings
// before the OAuth connect flow runs. Stored encrypted as a string via
// `src/lib/crypto.ts`, matching every other `*Encrypted` column.
ouraClientIdEncrypted String? @map("oura_client_id_encrypted")
ouraClientSecretEncrypted String? @map("oura_client_secret_encrypted")
ouraClientIdEncrypted String? @map("oura_client_id_encrypted")
ouraClientSecretEncrypted String? @map("oura_client_secret_encrypted")
/// v1.42.3 — Oura collections the current grant does not cover, as a JSON
/// string array of collection names (`spo2`, `vo2max`, `cardio_age`,
/// `resilience`). Written by the sync when a collection answers 401/403 while
/// its siblings succeed with the same token, which is a scope the grant never
/// carried, not a dead token. The Oura card reads it to ask for one
/// reconnect. Cleared on connect, disconnect and credential removal.
ouraScopeDenied Json? @map("oura_scope_denied")
ouraScopeDenied Json? @map("oura_scope_denied")
/// v1.44 — how far the Oura history backfill has got: per collection, the
/// day its next window ends on, and whether it is done or refused for a
/// scope; plus where the account's history starts once learned. Written
/// after every window so a slice resumes where the last one stopped (see
/// `src/lib/oura/backfill-progress.ts`); kept after completion so a
/// reconnect reads only what the old grant refused.
ouraBackfillProgress Json? @map("oura_backfill_progress")
/// v1.44 — the Oura history backfill finished (self-converging discovery
/// predicate `oura_backfill_completed_at IS NULL`). Cleared on connect.
ouraBackfillCompletedAt DateTime? @map("oura_backfill_completed_at")
// v1.28.x — Strava OAuth credentials (per-user BYO-app, encrypted at app
// level). Strava caps every newly-created API app at single-player mode
@@ -106,6 +106,8 @@ const BACKFILL_STAMPS: Record<string, string> = {
"marks the connection's one-shot as done; not a listed record",
"src/lib/jobs/strava-backfill.ts > user":
"stamps the account's backfill-completed marker",
"src/lib/jobs/oura-backfill.ts > user":
"writes the account's backfill progress note and completed marker",
"src/lib/jobs/document-content-index-backfill.ts > documentContentIndex":
"the index row is rebuilt content; its updatedAt is the rebuild time",
"src/lib/jobs/lab-biomarker-backfill.ts > biomarker":
@@ -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:
@@ -62,6 +62,7 @@ const WRITERS: Record<string, Disposition> = {
"lib/fitbit/sync-core.ts": { kind: "gate" },
"lib/google-health/sync-core.ts": { kind: "gate" },
"lib/nightscout/sync.ts": { kind: "gate" },
"lib/oura/sync-heart-rate.ts": { kind: "gate" },
// The shared identity seam. Polar, Oura, WHOOP, the Withings sleep leg,
// the Apple export importer and the batch endpoint all write through it,
// so the gate here is what makes a new provider fail closed.
@@ -54,6 +54,8 @@ const WRITE_SITES: Record<string, string> = {
"lib/whoop/webhook-handler.ts": TAIL,
// Retiring the rows of Oura sleep periods the person rejected or deleted.
"lib/oura/sync.ts": TAIL,
// Retiring the equivalent-distance rows earlier Oura syncs wrote.
"lib/oura/retire-distance.ts": TAIL,
// Integration syncs: each collects the (type, day) pairs it touched and
// runs both legs itself at the end of the sync.
"lib/fitbit/sync-core.ts":
@@ -62,6 +64,8 @@ const WRITE_SITES: Record<string, string> = {
"refolds the touched days and re-warms the touched types at the end of the upsert",
"lib/nightscout/sync.ts":
"refolds the touched days and re-warms the touched types at the end of the sync",
"lib/oura/sync-heart-rate.ts":
"collects the days each heart-rate page touched; refolds them and re-warms the type once per window",
"lib/withings/sync.ts":
"refolds the touched days and re-warms the touched types at the end of the sync",
"lib/withings/sync-activity.ts":
+114
View File
@@ -0,0 +1,114 @@
/**
* Every OAuth provider integration goes through the one token lifecycle.
*
* `src/lib/integrations/oauth-kit/token.ts` owns reading a grant, deciding it
* needs a refresh, refreshing under the advisory lock, and writing the result
* back conditionally. It replaced seven copies of that sequence, which had
* drifted into four different safety levels, one of them a status route that
* refreshed a one-time token with no guard at all. This file is what stops an
* eighth copy:
*
* A. Each kit provider has `src/lib/<provider>/oauth-descriptor.ts`, and the
* descriptor names that provider.
* B. A provider client's `refreshAccessToken` is imported by that
* provider's descriptor and by nothing else outside tests.
* C. The refresh lock is taken by the kit and by the AI provider store
* (`src/lib/ai/provider.ts`, which is not an integration) only.
* D. A token is sealed into a connection row only by a callback route
* (the connect-time write). The kit writes ciphertext the descriptor is
* handed and never encrypts on its own.
*
* Fitbit is the one listed exception: the classic Fitbit Web API is retired
* on 2026-10-30 and its integration rides out on its own refresh until then,
* rather than being migrated onto a lifecycle it will never use.
*
* Every leg asserts a non-empty match set before it compares, so a matcher
* that stops matching fails instead of passing.
*/
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/** The providers whose tokens the kit manages. */
const KIT_PROVIDERS = [
"withings",
"whoop",
"oura",
"strava",
"google-health",
"polar",
] as const;
/** Retired on 2026-10-30; keeps its own refresh until it is removed. */
const RETIRED = new Set(["src/lib/fitbit/sync-core.ts"]);
function productionFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((f) => !f.startsWith("generated/"))
.filter((f) => !f.includes("__tests__/") && !/\.test\.tsx?$/.test(f))
.map((f) => `src/${f}`);
}
function read(rel: string): string {
return readFileSync(join(process.cwd(), rel), "utf8");
}
describe("OAuth token lifecycle guard", () => {
const files = productionFiles();
it("A: every kit provider has a descriptor naming it", () => {
for (const provider of KIT_PROVIDERS) {
const rel = `src/lib/${provider}/oauth-descriptor.ts`;
expect(existsSync(join(process.cwd(), rel)), rel).toBe(true);
expect(read(rel)).toMatch(new RegExp(`provider:\\s*"${provider}"`));
}
});
it("B: a provider's refreshAccessToken is imported only by its descriptor", () => {
const importers = files.filter((f) => {
const src = read(f);
return /import\s*\{[^}]*\brefreshAccessToken\b[^}]*\}\s*from\s*["'](?:\.\/client|@\/lib\/[a-z-]+\/client)["']/.test(
src,
);
});
expect(importers.length).toBeGreaterThan(0);
const allowed = new Set([
...KIT_PROVIDERS.filter((p) => p !== "polar").map(
(p) => `src/lib/${p}/oauth-descriptor.ts`,
),
...RETIRED,
]);
expect(importers.filter((f) => !allowed.has(f))).toEqual([]);
});
it("C: only the kit and the AI provider store take the refresh lock", () => {
const callers = files.filter((f) =>
/\bacquireProviderTokenRefreshLock\s*\(/.test(read(f)),
);
expect(callers.length).toBeGreaterThan(0);
expect(callers.sort()).toEqual([
"src/lib/ai/provider.ts",
"src/lib/integrations/oauth-kit/token.ts",
"src/lib/integrations/oauth-refresh.ts",
]);
});
it("D: tokens are sealed into a connection only by a callback", () => {
const sealing = files.filter((f) =>
/\b(?:accessToken|refreshToken|[a-z]+AccessTokenEncrypted|[a-z]+RefreshTokenEncrypted):\s*encrypt\(/.test(
read(f),
),
);
expect(sealing.length).toBeGreaterThan(0);
const offenders = sealing.filter(
(f) =>
!RETIRED.has(f) &&
!/^src\/app\/api\/[a-z-]+\/callback\/route\.ts$/.test(f),
);
expect(offenders).toEqual([]);
});
});
@@ -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(
+12 -3
View File
@@ -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":
+8 -1
View File
@@ -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` —
@@ -116,6 +116,15 @@ const PROVIDER_INGESTS: ProviderIngest[] = [
writeFile: "lib/import/health-connect/import.ts",
metadataKey: "healthConnectExerciseType",
},
{
provider: "Oura",
mapper: "mapOuraSportType",
mapperFile: "lib/oura/sport-map.ts",
captureFile: "lib/oura/sync-workouts.ts",
captureField: "w.activity",
writeFile: "lib/oura/sync-workouts.ts",
metadataKey: "ouraActivity",
},
{
provider: "Fitbit",
mapper: "mapFitbitSportType",
+26 -11
View File
@@ -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) =>
+17 -46
View File
@@ -1,20 +1,12 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
setConnectStateCookie,
startOAuthConnect,
} from "@/lib/integrations/oauth-kit/connect-start";
import { apiError } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import {
generatePkcePair,
getAuthorizationUrl,
} from "@/lib/google-health/client";
import { getUserGoogleHealthCredentials } from "@/lib/google-health/credentials";
import {
GOOGLE_HEALTH_OAUTH_STATE_COOKIE,
GOOGLE_HEALTH_OAUTH_STATE_TTL_MS,
mintGoogleHealthOAuthStateNonce,
} from "@/lib/google-health/oauth-state";
import { NextRequest, NextResponse } from "next/server";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
/**
* Redirect the user to the Google OAuth consent page for the Google Health
@@ -55,27 +47,14 @@ export const GET = apiHandler(async (req: NextRequest) => {
);
}
const creds = await getUserGoogleHealthCredentials(user.id);
if (!creds) {
return apiError(
"Please configure your Google OAuth Client ID and Client Secret in Settings first.",
400,
);
}
const nonce = mintGoogleHealthOAuthStateNonce();
const { verifier, challenge } = generatePkcePair();
try {
await prisma.googleHealthOAuthState.create({
data: {
nonce,
userId: user.id,
codeVerifier: verifier,
expiresAt: new Date(Date.now() + GOOGLE_HEALTH_OAUTH_STATE_TTL_MS),
},
});
} catch (err) {
getEvent()?.setError(err);
const started = await startOAuthConnect("google-health", user.id);
if (!started.ok) {
if (started.reason === "nocreds") {
return apiError(
"Please configure your Google OAuth Client ID and Client Secret in Settings first.",
400,
);
}
annotate({ action: { name: "google_health.connect.create_failed" } });
return NextResponse.redirect(
new URL(
@@ -85,16 +64,8 @@ export const GET = apiHandler(async (req: NextRequest) => {
);
}
const url = getAuthorizationUrl(nonce, creds, challenge);
const response = NextResponse.redirect(url);
response.cookies.set(GOOGLE_HEALTH_OAUTH_STATE_COOKIE, nonce, {
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: Math.floor(GOOGLE_HEALTH_OAUTH_STATE_TTL_MS / 1000),
path: "/",
});
return response;
return setConnectStateCookie(
NextResponse.redirect(started.authorizationUrl),
started.cookie,
);
});
+2 -18
View File
@@ -1,9 +1,7 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { disconnectOAuth } from "@/lib/integrations/oauth-kit/disconnect";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { markDisconnected } from "@/lib/integrations/status";
/**
* Disconnect the Google Health integration for the current user (v1.26.0).
@@ -20,23 +18,9 @@ export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "google_health.disconnect" } });
const connection = await prisma.googleHealthConnection.findUnique({
where: { userId: user.id },
});
if (!connection) {
if ((await disconnectOAuth("google-health", user.id)) === "not_connected") {
return apiError("No Google Health connection", 404);
}
await prisma.googleHealthConnection.delete({
where: { userId: user.id },
});
await auditLog("google_health.disconnect", {
userId: user.id,
});
await markDisconnected(user.id, "google-health");
return apiSuccess({ disconnected: true });
});
@@ -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");
});
});
@@ -206,12 +218,17 @@ describe("GET /api/oura/callback — issuer", () => {
* card for up to an hour.
*/
describe("GET /api/oura/callback — connect-time first sync", () => {
it("enqueues exactly one targeted oura-sync job for the connecting user", async () => {
it("enqueues one targeted oura-sync job and the history backfill for the connecting user", async () => {
const res = await GET(makeReq({ code: "c", state: "S", cookie: "S" }));
expect(res.headers.get("location")).toContain("oura=connected");
expect(bossSend).toHaveBeenCalledTimes(1);
expect(bossSend).toHaveBeenCalledTimes(2);
expect(bossSend).toHaveBeenCalledWith("oura-sync", { userId: "u1" });
expect(bossSend).toHaveBeenCalledWith(
"oura-backfill",
expect.objectContaining({ userId: "u1" }),
expect.objectContaining({ singletonKey: "oura-backfill|u1" }),
);
});
it("still connects when no boss instance is available", async () => {
+40 -4
View File
@@ -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";
@@ -17,6 +18,11 @@ import { ouraCollectionsMissingFromGrant } from "@/lib/oura/scope";
import { markReconnected } from "@/lib/integrations/status";
import { OURA_SYNC_QUEUE } from "@/lib/jobs/integration-poll-queues";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { integrationBackfillSourceOptions } from "@/lib/jobs/integration-backfill-admission";
import {
OURA_BACKFILL_QUEUE,
type OuraBackfillPayload,
} from "@/lib/jobs/oura-backfill";
import { NextRequest, NextResponse } from "next/server";
/**
@@ -80,11 +86,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 +117,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> {
@@ -134,10 +148,17 @@ async function completeCallback(
ouraRefreshTokenEncrypted: encrypt(tokens.refresh_token),
ouraScopeDenied:
missing && missing.length > 0 ? missing : Prisma.DbNull,
// Open the history backfill again: a first connect has none yet,
// and a reconnect may grant what the old grant refused. What was
// already walked stays in the progress note and is not read again.
ouraBackfillCompletedAt: null,
},
});
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
@@ -153,6 +174,21 @@ async function completeCallback(
.catch((err) =>
getEvent()?.addWarning(`oura-sync connect enqueue failed: ${err}`),
);
const backfill: OuraBackfillPayload = {
userId,
enqueuedAt: new Date().toISOString(),
};
await boss
.send(
OURA_BACKFILL_QUEUE,
backfill,
integrationBackfillSourceOptions(`oura-backfill|${userId}`),
)
.catch((err) =>
getEvent()?.addWarning(
`oura-backfill connect enqueue failed: ${err}`,
),
);
}
const response = NextResponse.redirect(
+11 -23
View File
@@ -1,14 +1,10 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAuthorizationUrl } from "@/lib/oura/client";
import { getOuraClientCredentials } from "@/lib/oura/credentials";
import {
OAUTH_STATE_TTL_MS,
mintSignedState,
oauthStateCookieName,
} from "@/lib/oauth/signed-state";
setConnectStateCookie,
startOAuthConnect,
} from "@/lib/integrations/oauth-kit/connect-start";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { NextResponse } from "next/server";
/**
@@ -45,8 +41,8 @@ export const GET = apiHandler(async () => {
);
}
const creds = await getOuraClientCredentials(user.id);
if (!creds) {
const started = await startOAuthConnect("oura", user.id);
if (!started.ok) {
annotate({ action: { name: "oura.connect.no_credentials" } });
return NextResponse.redirect(
new URL(
@@ -56,16 +52,8 @@ export const GET = apiHandler(async () => {
);
}
const state = mintSignedState("oura", user.id);
const url = getAuthorizationUrl(state, creds);
const response = NextResponse.redirect(url);
response.cookies.set(oauthStateCookieName("oura"), state, {
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: Math.floor(OAUTH_STATE_TTL_MS / 1000),
path: "/",
});
return response;
return setConnectStateCookie(
NextResponse.redirect(started.authorizationUrl),
started.cookie,
);
});
+2 -21
View File
@@ -1,11 +1,8 @@
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { disconnectOAuth } from "@/lib/integrations/oauth-kit/disconnect";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { markDisconnected } from "@/lib/integrations/status";
/**
* v1.17.0 — disconnect Oura for the current user.
@@ -25,25 +22,9 @@ export const POST = apiHandler(async () => {
});
}
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { ouraAccessTokenEncrypted: true },
});
if (!dbUser?.ouraAccessTokenEncrypted) {
if ((await disconnectOAuth("oura", user.id)) === "not_connected") {
return apiError("No Oura connection", 404);
}
await prisma.user.update({
where: { id: user.id },
data: {
ouraAccessTokenEncrypted: null,
ouraRefreshTokenEncrypted: null,
ouraScopeDenied: Prisma.DbNull,
},
});
await auditLog("oura.disconnect", { userId: user.id });
await markDisconnected(user.id, "oura");
return apiSuccess({ disconnected: true });
});
@@ -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,
}));
+16 -4
View File
@@ -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
+11 -23
View File
@@ -1,14 +1,10 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAuthorizationUrl } from "@/lib/polar/client";
import { getPolarClientCredentials } from "@/lib/polar/credentials";
import {
OAUTH_STATE_TTL_MS,
mintSignedState,
oauthStateCookieName,
} from "@/lib/oauth/signed-state";
setConnectStateCookie,
startOAuthConnect,
} from "@/lib/integrations/oauth-kit/connect-start";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { NextResponse } from "next/server";
/**
@@ -48,8 +44,8 @@ export const GET = apiHandler(async () => {
);
}
const creds = await getPolarClientCredentials(user.id);
if (!creds) {
const started = await startOAuthConnect("polar", user.id);
if (!started.ok) {
annotate({ action: { name: "polar.connect.no_credentials" } });
return NextResponse.redirect(
new URL(
@@ -59,16 +55,8 @@ export const GET = apiHandler(async () => {
);
}
const state = mintSignedState("polar", user.id);
const url = getAuthorizationUrl(state, creds);
const response = NextResponse.redirect(url);
response.cookies.set(oauthStateCookieName("polar"), state, {
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: Math.floor(OAUTH_STATE_TTL_MS / 1000),
path: "/",
});
return response;
return setConnectStateCookie(
NextResponse.redirect(started.authorizationUrl),
started.cookie,
);
});
+2 -19
View File
@@ -1,10 +1,8 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { disconnectOAuth } from "@/lib/integrations/oauth-kit/disconnect";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { markDisconnected } from "@/lib/integrations/status";
/**
* v1.17.0 — disconnect Polar for the current user.
@@ -24,24 +22,9 @@ export const POST = apiHandler(async () => {
});
}
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { polarAccessTokenEncrypted: true },
});
if (!dbUser?.polarAccessTokenEncrypted) {
if ((await disconnectOAuth("polar", user.id)) === "not_connected") {
return apiError("No Polar connection", 404);
}
await prisma.user.update({
where: { id: user.id },
data: {
polarAccessTokenEncrypted: null,
polarUserIdEncrypted: null,
},
});
await auditLog("polar.disconnect", { userId: user.id });
await markDisconnected(user.id, "polar");
return apiSuccess({ disconnected: true });
});
@@ -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,
}));
+16 -4
View File
@@ -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 -23
View File
@@ -1,14 +1,10 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAuthorizationUrl } from "@/lib/strava/client";
import { getStravaClientCredentials } from "@/lib/strava/credentials";
import {
OAUTH_STATE_TTL_MS,
mintSignedState,
oauthStateCookieName,
} from "@/lib/oauth/signed-state";
setConnectStateCookie,
startOAuthConnect,
} from "@/lib/integrations/oauth-kit/connect-start";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { NextResponse } from "next/server";
/**
@@ -45,8 +41,8 @@ export const GET = apiHandler(async () => {
);
}
const creds = await getStravaClientCredentials(user.id);
if (!creds) {
const started = await startOAuthConnect("strava", user.id);
if (!started.ok) {
annotate({ action: { name: "strava.connect.no_credentials" } });
return NextResponse.redirect(
new URL(
@@ -56,16 +52,8 @@ export const GET = apiHandler(async () => {
);
}
const state = mintSignedState("strava", user.id);
const url = getAuthorizationUrl(state, creds);
const response = NextResponse.redirect(url);
response.cookies.set(oauthStateCookieName("strava"), state, {
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: Math.floor(OAUTH_STATE_TTL_MS / 1000),
path: "/",
});
return response;
return setConnectStateCookie(
NextResponse.redirect(started.authorizationUrl),
started.cookie,
);
});
+3 -27
View File
@@ -1,15 +1,8 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { disconnectOAuth } from "@/lib/integrations/oauth-kit/disconnect";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate, getEvent } from "@/lib/logging/context";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { markDisconnected } from "@/lib/integrations/status";
import { deauthorize } from "@/lib/strava/client";
import {
getStravaConnection,
STRAVA_DISCONNECTED,
} from "@/lib/strava/credentials";
/**
* v1.28.x — disconnect Strava for the current user.
@@ -30,26 +23,9 @@ export const POST = apiHandler(async () => {
});
}
const connection = await getStravaConnection(user.id);
if (!connection) {
if ((await disconnectOAuth("strava", user.id)) === "not_connected") {
return apiError("No Strava connection", 404);
}
// Best-effort revoke at Strava; a Strava-side outage must never block the
// local disconnect.
try {
await deauthorize(connection.accessToken);
} catch (err) {
getEvent()?.addWarning(`strava: deauthorize failed on disconnect: ${err}`);
}
await prisma.user.update({
where: { id: user.id },
data: STRAVA_DISCONNECTED,
});
await auditLog("strava.disconnect", { userId: user.id });
await markDisconnected(user.id, "strava");
return apiSuccess({ disconnected: true });
});
@@ -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,
}));
+14 -3
View File
@@ -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");
+19 -44
View File
@@ -1,20 +1,15 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAuthorizationUrl } from "@/lib/whoop/client";
import { consumeWhoopConnectTicket } from "@/lib/whoop/connect-ticket";
import { getUserWhoopCredentials } from "@/lib/whoop/credentials";
import {
WHOOP_OAUTH_STATE_COOKIE,
WHOOP_OAUTH_STATE_TTL_MS,
mintWhoopOAuthStateNonce,
} from "@/lib/whoop/oauth-state";
setConnectStateCookie,
startOAuthConnect,
} from "@/lib/integrations/oauth-kit/connect-start";
import { apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { consumeWhoopConnectTicket } from "@/lib/whoop/connect-ticket";
import { validateReturnScheme } from "@/lib/whoop/return-scheme";
import { NextRequest, NextResponse } from "next/server";
import { relativeRedirect } from "@/lib/http/relative-redirect";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
/**
* Redirect the user to the WHOOP OAuth authorization page (v1.11.0; native
@@ -91,47 +86,27 @@ export const GET = apiHandler(async (req: NextRequest) => {
);
}
const creds = await getUserWhoopCredentials(userId);
if (!creds) {
annotate({ action: { name: "whoop.connect.no_credentials" } });
return relativeRedirect(
"/settings/integrations?whoop=error&reason=nocreds",
);
}
// Validate the optional native return scheme against the strict allowlist.
// An invalid/absent value resolves to null → the callback uses the web
// redirect (never an arbitrary reflected scheme).
const returnScheme = validateReturnScheme(searchParams.get("return_scheme"));
const nonce = mintWhoopOAuthStateNonce();
try {
await prisma.whoopOAuthState.create({
data: {
nonce,
userId,
expiresAt: new Date(Date.now() + WHOOP_OAUTH_STATE_TTL_MS),
returnScheme,
},
});
} catch (err) {
getEvent()?.setError(err);
const started = await startOAuthConnect("whoop", userId, { returnScheme });
if (!started.ok) {
if (started.reason === "nocreds") {
annotate({ action: { name: "whoop.connect.no_credentials" } });
return relativeRedirect(
"/settings/integrations?whoop=error&reason=nocreds",
);
}
annotate({ action: { name: "whoop.connect.create_failed" } });
return relativeRedirect(
"/settings/integrations?whoop=error&reason=connect",
);
}
const url = getAuthorizationUrl(nonce, creds);
const response = NextResponse.redirect(url);
response.cookies.set(WHOOP_OAUTH_STATE_COOKIE, nonce, {
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: Math.floor(WHOOP_OAUTH_STATE_TTL_MS / 1000),
path: "/",
});
return response;
return setConnectStateCookie(
NextResponse.redirect(started.authorizationUrl),
started.cookie,
);
});
+2 -18
View File
@@ -1,9 +1,7 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { disconnectOAuth } from "@/lib/integrations/oauth-kit/disconnect";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { markDisconnected } from "@/lib/integrations/status";
/**
* Disconnect the WHOOP integration for the current user (v1.11.0).
@@ -20,23 +18,9 @@ export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "whoop.disconnect" } });
const connection = await prisma.whoopConnection.findUnique({
where: { userId: user.id },
});
if (!connection) {
if ((await disconnectOAuth("whoop", user.id)) === "not_connected") {
return apiError("No WHOOP connection", 404);
}
await prisma.whoopConnection.delete({
where: { userId: user.id },
});
await auditLog("whoop.disconnect", {
userId: user.id,
});
await markDisconnected(user.id, "whoop");
return apiSuccess({ disconnected: true });
});
@@ -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: {
+25 -10
View File
@@ -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(
+17 -41
View File
@@ -1,17 +1,12 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAuthorizationUrl } from "@/lib/withings/client";
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
import {
WITHINGS_OAUTH_STATE_COOKIE,
WITHINGS_OAUTH_STATE_TTL_MS,
mintWithingsOAuthStateNonce,
} from "@/lib/withings/oauth-state";
setConnectStateCookie,
startOAuthConnect,
} from "@/lib/integrations/oauth-kit/connect-start";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest, NextResponse } from "next/server";
import { relativeRedirect } from "@/lib/http/relative-redirect";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
/**
* Redirects the user to Withings OAuth authorization page.
@@ -62,41 +57,22 @@ export const GET = apiHandler(async (req: NextRequest) => {
);
}
const creds = await getUserWithingsCredentials(user.id);
if (!creds) {
annotate({ action: { name: "withings.connect.no_credentials" } });
return relativeRedirect(
"/settings/integrations?withings=error&reason=nocreds",
);
}
const nonce = mintWithingsOAuthStateNonce();
try {
await prisma.withingsOAuthState.create({
data: {
nonce,
userId: user.id,
expiresAt: new Date(Date.now() + WITHINGS_OAUTH_STATE_TTL_MS),
},
});
} catch (err) {
getEvent()?.setError(err);
const started = await startOAuthConnect("withings", user.id);
if (!started.ok) {
if (started.reason === "nocreds") {
annotate({ action: { name: "withings.connect.no_credentials" } });
return relativeRedirect(
"/settings/integrations?withings=error&reason=nocreds",
);
}
annotate({ action: { name: "withings.connect.create_failed" } });
return relativeRedirect(
"/settings/integrations?withings=error&reason=connect",
);
}
const url = getAuthorizationUrl(nonce, creds);
const response = NextResponse.redirect(url);
response.cookies.set(WITHINGS_OAUTH_STATE_COOKIE, nonce, {
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: Math.floor(WITHINGS_OAUTH_STATE_TTL_MS / 1000),
path: "/",
});
return response;
return setConnectStateCookie(
NextResponse.redirect(started.authorizationUrl),
started.cookie,
);
});
+2 -41
View File
@@ -1,15 +1,7 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { disconnectOAuth } from "@/lib/integrations/oauth-kit/disconnect";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { decrypt } from "@/lib/crypto";
import { unsubscribeWebhook } from "@/lib/withings/client";
import {
WITHINGS_NOTIFY_APPLIS,
getWithingsWebhookCallbackUrl,
} from "@/lib/withings/sync";
import { markDisconnected } from "@/lib/integrations/status";
/**
* Disconnect Withings integration for the current user.
@@ -18,40 +10,9 @@ export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "withings.disconnect" } });
const connection = await prisma.withingsConnection.findUnique({
where: { userId: user.id },
});
if (!connection) {
if ((await disconnectOAuth("withings", user.id)) === "not_connected") {
return apiError("No Withings connection", 404);
}
// Best-effort unsubscribe across every appli category we subscribed
// to in setupWebhook. A token-expired failure on one category should
// not stop us from cleaning up the others.
try {
const accessToken = decrypt(connection.accessToken);
const callbackUrl = getWithingsWebhookCallbackUrl();
for (const appli of WITHINGS_NOTIFY_APPLIS) {
try {
await unsubscribeWebhook(accessToken, callbackUrl, appli);
} catch {
// Ignore — token might be expired or the appli was never subscribed.
}
}
} catch {
// Ignore — token might be undecryptable.
}
await prisma.withingsConnection.delete({
where: { userId: user.id },
});
await auditLog("withings.disconnect", {
userId: user.id,
});
await markDisconnected(user.id, "withings");
return apiSuccess({ disconnected: true });
});
+10 -20
View File
@@ -3,9 +3,9 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
import { decrypt, encrypt } from "@/lib/crypto";
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
import { hasActivityScope, refreshAccessToken } from "@/lib/withings/client";
import { hasActivityScope } from "@/lib/withings/client";
import { obtainAccessToken } from "@/lib/integrations/oauth-kit/token";
import { withingsTokenDescriptor } from "@/lib/withings/oauth-descriptor";
import { isWithingsRefreshReauthFailure } from "@/lib/withings/sync";
import { markReauthRequired } from "@/lib/integrations/status";
@@ -33,8 +33,6 @@ export const GET = apiHandler(async () => {
where: { userId: user.id },
select: {
withingsUserId: true,
accessToken: true,
refreshToken: true,
lastSyncedAt: true,
tokenExpiresAt: true,
createdAt: true,
@@ -56,22 +54,14 @@ export const GET = apiHandler(async () => {
const shouldRefresh = tokenExpiresAt.getTime() - 60_000 <= now;
if (shouldRefresh) {
try {
const creds = await getUserWithingsCredentials(user.id);
if (creds) {
const refreshToken = decrypt(connection.refreshToken);
const refreshed = await refreshAccessToken(refreshToken, creds);
tokenExpiresAt = new Date(Date.now() + refreshed.expires_in * 1000);
// The shared token lifecycle refreshes under the same lock the sync
// takes, so a status read and a sync never spend the one-time refresh
// token twice.
const result = await obtainAccessToken(withingsTokenDescriptor, user.id);
if (result.kind === "token" && result.expiresAt) {
tokenExpiresAt = result.expiresAt;
tokenExpired = tokenExpiresAt.getTime() <= Date.now();
await prisma.withingsConnection.update({
where: { userId: user.id },
data: {
accessToken: encrypt(refreshed.access_token),
refreshToken: encrypt(refreshed.refresh_token),
tokenExpiresAt,
},
});
} else if (tokenExpired) {
} else if (result.kind !== "token" && tokenExpired) {
tokenRefreshFailed = true;
}
} catch (error) {
@@ -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&#x27;s record, not to yours.",
);
expect(html).toContain(
'href="https://docs.healthlog.dev/self-hosting/account-sharing#records-with-no-login"',
@@ -130,6 +130,10 @@ export function unavailableItemLabel(item: string, t: Translate): string {
return t("settings.integrations.oura.scope.items.cardio_age");
case "resilience":
return t("settings.integrations.oura.scope.items.resilience");
case "workout":
return t("settings.integrations.oura.scope.items.workout");
case "heartrate":
return t("settings.integrations.oura.scope.items.heartrate");
case "activity":
return t("settings.integrationCard.items.activity");
case "sleep":
@@ -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>
))}
+2
View File
@@ -463,6 +463,8 @@ export const USER_RESET = {
ouraClientIdEncrypted: null,
ouraClientSecretEncrypted: null,
ouraScopeDenied: Prisma.DbNull,
ouraBackfillProgress: Prisma.DbNull,
ouraBackfillCompletedAt: null,
stravaClientIdEncrypted: null,
stravaClientSecretEncrypted: null,
stravaAccessTokenEncrypted: null,
+4
View File
@@ -958,6 +958,10 @@ export const USER_COLUMN_BACKUP_CLASS = {
stravaLastActivityAt: "OPERATIONAL",
stravaBackfillCompletedAt: "OPERATIONAL",
stravaBackfillResumeBefore: "OPERATIONAL",
// The restored account's Oura backfill reads its history again from the
// provider; a carried note would claim history this host never imported.
ouraBackfillProgress: "OPERATIONAL",
ouraBackfillCompletedAt: "OPERATIONAL",
healthKitLastSyncedAt: "OPERATIONAL",
healthKitLastSyncTrigger: "OPERATIONAL",
healthKitLastBackgroundSyncAt: "OPERATIONAL",
@@ -0,0 +1,40 @@
/**
* Every Google Health collection the sync reads is one the backfill tracks.
*
* The backfill walks `GOOGLE_HEALTH_COLLECTION_KEYS` and stamps its marker
* only when each is done or has ended with a reason. A collection the sync
* reads but the key list leaves out would never be backfilled, and the card
* would still say the history finished. Read off the sync modules' own call
* labels so a new collection cannot join the sync without joining this list.
*/
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { GOOGLE_HEALTH_COLLECTION_KEYS } from "../collection-keys";
/** Not time series: one profile read each, outside any history walk. */
const NOT_A_HISTORY = new Set(["fetchProfile", "fetchHeight"]);
/** The sleep walk is tracked under its own key. */
const ALIASES: Record<string, string> = { fetchSleep: "sleep" };
describe("Google Health backfill coverage", () => {
it("tracks every collection the sync reads", () => {
const dir = join(process.cwd(), "src/lib/google-health");
const labels = new Set<string>();
for (const file of readdirSync(dir)) {
if (!/^sync-.*\.ts$/.test(file)) continue;
const src = readFileSync(join(dir, file), "utf8");
for (const m of src.matchAll(/"(fetch[A-Z][A-Za-z0-9]+)"/g)) {
labels.add(m[1]!);
}
}
expect(labels.size).toBeGreaterThan(10);
const keys = new Set<string>(GOOGLE_HEALTH_COLLECTION_KEYS);
const untracked = [...labels]
.filter((l) => !NOT_A_HISTORY.has(l))
.map((l) => ALIASES[l] ?? l)
.filter((l) => !keys.has(l));
expect(untracked).toEqual([]);
});
});
@@ -34,6 +34,11 @@ const {
update: vi.fn(async () => ({})),
updateMany: vi.fn(async () => ({ count: 1 })),
},
// The token refresh runs in a transaction under an advisory lock.
$queryRaw: vi.fn(async () => []),
$transaction: vi.fn(async (run: (tx: unknown) => unknown) =>
run(prismaMock),
),
},
getUserGoogleHealthCredentialsMock: vi.fn(async (): Promise<unknown> => null),
refreshAccessTokenMock: vi.fn(),
+3 -6
View File
@@ -56,6 +56,7 @@ import {
noteGoogleHealthWalkPage,
} from "./backfill-bounds";
import { walkPages } from "@/lib/integrations/page-walk";
import { retryAfterMsOf } from "@/lib/integrations/oauth-kit/retry-after";
export * from "./mappers";
@@ -1026,12 +1027,8 @@ export function googleHealthRetryDelayMs(
json: unknown,
now: number = Date.now(),
): number | undefined {
const header = res.headers.get("retry-after")?.trim();
if (header) {
if (/^\d+$/.test(header)) return Number.parseInt(header, 10) * 1000;
const at = Date.parse(header);
if (Number.isFinite(at)) return Math.max(0, at - now);
}
const fromHeader = retryAfterMsOf(res, now);
if (fromHeader !== undefined) return fromHeader;
const details = (json as { error?: { details?: unknown } } | null)?.error
?.details;
if (Array.isArray(details)) {
+47
View File
@@ -0,0 +1,47 @@
/**
* Where a Google Health grant lives and how its token endpoint is called, for
* the shared token lifecycle (`src/lib/integrations/oauth-kit/token.ts`).
*
* Google keeps an expiry on `GoogleHealthConnection`, so the token is refreshed
* ahead of it. Google does not rotate refresh tokens: a refresh usually comes
* back without one, and the kit then keeps the stored refresh token.
*/
import type { OAuthTokenDescriptor } from "@/lib/integrations/oauth-kit/token";
import { refreshAccessToken, type GoogleHealthCredentials } from "./client";
import { getUserGoogleHealthCredentials } from "./credentials";
/** Refresh this long before the stored expiry. */
export const GOOGLE_HEALTH_TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000;
export const googleHealthTokenDescriptor: OAuthTokenDescriptor<GoogleHealthCredentials> =
{
provider: "google-health",
tracksExpiry: true,
expiryBufferMs: GOOGLE_HEALTH_TOKEN_REFRESH_BUFFER_MS,
async readGrant(db, userId) {
const row = await db.googleHealthConnection.findUnique({
where: { userId },
select: { accessToken: true, refreshToken: true, tokenExpiresAt: true },
});
if (!row) return null;
return {
accessCiphertext: row.accessToken,
refreshCiphertext: row.refreshToken,
expiresAt: row.tokenExpiresAt,
};
},
async writeGrant(db, userId, spent, next) {
const { count } = await db.googleHealthConnection.updateMany({
where: { userId, refreshToken: spent },
data: {
accessToken: next.accessCiphertext,
refreshToken: next.refreshCiphertext,
...(next.expiresAt ? { tokenExpiresAt: next.expiresAt } : {}),
},
});
return count;
},
loadCredentials: (userId) => getUserGoogleHealthCredentials(userId),
refresh: (refreshToken, credentials) =>
refreshAccessToken(refreshToken, credentials),
};
+66 -98
View File
@@ -18,7 +18,6 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { encrypt, decrypt } from "@/lib/crypto";
import { annotate, getEvent } from "@/lib/logging/context";
import type { GoogleHealthCollectionKey } from "./collection-keys";
import {
@@ -52,11 +51,14 @@ import {
noteGoogleHealthMapped,
noteGoogleHealthOutcomeFailure,
noteGoogleHealthWritten,
refreshAccessToken,
type GoogleHealthResumePoint,
type GoogleHealthTokenSource,
} from "./client";
import { getUserGoogleHealthCredentials } from "./credentials";
import {
GOOGLE_HEALTH_TOKEN_REFRESH_BUFFER_MS,
googleHealthTokenDescriptor,
} from "./oauth-descriptor";
import { obtainAccessToken } from "@/lib/integrations/oauth-kit/token";
import {
GoogleHealthApiError,
classifyGoogleHealthError,
@@ -70,9 +72,6 @@ import {
*/
export const GOOGLE_HEALTH_INTEGRATION_KEY = "google-health";
/** Refresh the access token this many ms before `tokenExpiresAt`. */
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000;
/**
* Overlap window for the incremental sync, in ms. Google re-rolls daily
* summaries after the fact (a daily SpO2 / HRV / RHR can finalise hours after
@@ -142,104 +141,72 @@ export async function getValidToken(
): Promise<GoogleHealthTokenInfo | null> {
const connection = await prisma.googleHealthConnection.findUnique({
where: { userId },
select: { id: true, googleUserId: true },
});
if (!connection) return null;
const accessToken = decrypt(connection.accessToken);
const refreshToken = decrypt(connection.refreshToken);
if (
opts.forceRefresh === true ||
connection.tokenExpiresAt.getTime() - TOKEN_REFRESH_BUFFER_MS < Date.now()
) {
try {
const creds = await getUserGoogleHealthCredentials(userId);
if (!creds) {
getEvent()?.addWarning(
`No Google Health credentials found for user ${userId} during token refresh`,
);
await recordSyncFailure({
userId,
integration: GOOGLE_HEALTH_INTEGRATION_KEY,
kind: "reauth_required",
message: "Google Health credentials missing — token refresh skipped",
errorCode: "credentials_missing",
});
// A dead token means every resource "succeeds" with 0 rows — without a
// ledger entry the cycle would read as clean, stamp `markSynced`, and
// `recordSyncSuccess` would flip a parked connection back to connected
// (an hourly invalid-refresh hammer with an advancing watermark). Fail
// the cycle's verdict so the park holds.
noteHardFailure(GOOGLE_HEALTH_TOKEN_HARD_FAIL);
return null;
}
const newTokens = await refreshAccessToken(refreshToken, creds);
const expiresAt = new Date(Date.now() + newTokens.expires_in * 1000);
// Google does NOT rotate refresh tokens: a routine refresh returns a new
// access token but usually omits `refresh_token`. Persist the new access
// token + expiry and only overwrite the stored refresh token when the
// response actually carries a fresh one — otherwise keep the existing one.
await prisma.googleHealthConnection.update({
where: { id: connection.id },
data: {
accessToken: encrypt(newTokens.access_token),
tokenExpiresAt: expiresAt,
...(newTokens.refresh_token
? { refreshToken: encrypt(newTokens.refresh_token) }
: {}),
},
});
try {
const result = await obtainAccessToken(
googleHealthTokenDescriptor,
userId,
{ force: opts.forceRefresh === true },
);
if (result.kind === "token") {
return {
accessToken: newTokens.access_token,
expiresAt,
connection: {
id: connection.id,
googleUserId: connection.googleUserId,
},
accessToken: result.accessToken,
// Google always stores an expiry; the fallback only satisfies the type.
expiresAt: result.expiresAt ?? new Date(),
connection,
};
} catch (err) {
getEvent()?.addWarning(
`Google Health token refresh failed for user ${userId}: ${err}`,
);
// A revoked / expired refresh token (`invalid_grant` / 401 — the 7-day
// Testing-mode refresh expiry, or a user-revoked grant) is a SOFT
// disconnect: flag `needsReauth` on the connection so the card surfaces a
// "reconnect" CTA rather than a hard error. The callback clears it on a
// fresh consent. A transient refresh failure (network blip, 5xx) leaves
// the flag untouched so we don't nag the user to reconnect needlessly.
if (isGoogleHealthReauthRequired(err)) {
await prisma.googleHealthConnection
.update({
where: { id: connection.id },
data: { needsReauth: true },
})
.catch((e) =>
getEvent()?.addWarning(
`google-health: needsReauth flag write failed for ${userId}: ${e}`,
),
);
}
await recordGoogleHealthSyncFailure(userId, err);
// Same verdict rule as the credentials-missing branch above: a refresh
// failure must fail the cycle, or the all-zeros run stamps success,
// un-parks `error_reauth`, and the hourly cohort retries the dead
// refresh token forever while `lastSyncedAt` advances past real data.
noteHardFailure(GOOGLE_HEALTH_TOKEN_HARD_FAIL);
return null;
}
if (result.kind === "none") return null;
getEvent()?.addWarning(
`No Google Health credentials found for user ${userId} during token refresh`,
);
await recordSyncFailure({
userId,
integration: GOOGLE_HEALTH_INTEGRATION_KEY,
kind: "reauth_required",
message: "Google Health credentials missing — token refresh skipped",
errorCode: "credentials_missing",
});
// A dead token means every resource "succeeds" with 0 rows — without a
// ledger entry the cycle would read as clean, stamp `markSynced`, and
// `recordSyncSuccess` would flip a parked connection back to connected
// (an hourly invalid-refresh hammer with an advancing watermark). Fail
// the cycle's verdict so the park holds.
noteHardFailure(GOOGLE_HEALTH_TOKEN_HARD_FAIL);
return null;
} catch (err) {
getEvent()?.addWarning(
`Google Health token refresh failed for user ${userId}: ${err}`,
);
// A revoked / expired refresh token (`invalid_grant` / 401 — the 7-day
// Testing-mode refresh expiry, or a user-revoked grant) is a SOFT
// disconnect: flag `needsReauth` on the connection so the card surfaces a
// "reconnect" CTA rather than a hard error. The callback clears it on a
// fresh consent. A transient refresh failure (network blip, 5xx) leaves
// the flag untouched so we don't nag the user to reconnect needlessly.
if (isGoogleHealthReauthRequired(err)) {
await prisma.googleHealthConnection
.update({
where: { id: connection.id },
data: { needsReauth: true },
})
.catch((e) =>
getEvent()?.addWarning(
`google-health: needsReauth flag write failed for ${userId}: ${e}`,
),
);
}
await recordGoogleHealthSyncFailure(userId, err);
// Same verdict rule as the credentials-missing branch above: a refresh
// failure must fail the cycle, or the all-zeros run stamps success,
// un-parks `error_reauth`, and the hourly cohort retries the dead
// refresh token forever while `lastSyncedAt` advances past real data.
noteHardFailure(GOOGLE_HEALTH_TOKEN_HARD_FAIL);
return null;
}
return {
accessToken,
expiresAt: connection.tokenExpiresAt,
connection: {
id: connection.id,
googleUserId: connection.googleUserId,
},
};
}
/**
@@ -277,7 +244,8 @@ export function googleHealthTokenSource(
return async ({ forceRefresh }) => {
if (
forceRefresh ||
current.expiresAt.getTime() - TOKEN_REFRESH_BUFFER_MS < Date.now()
current.expiresAt.getTime() - GOOGLE_HEALTH_TOKEN_REFRESH_BUFFER_MS <
Date.now()
) {
const next = await getValidToken(userId, { forceRefresh });
if (!next) throw new GoogleHealthTokenUnavailableError();
@@ -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,148 @@
/**
* Pins what each provider's descriptor reads and writes. The kit's own
* behaviour is covered in `token.test.ts`; the claim here is that every
* descriptor reads the columns its provider always used (so no connection has
* to reconnect) and writes only while the stored refresh token is the one the
* refresh spent.
*/
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/withings/credentials", () => ({
getUserWithingsCredentials: vi.fn(),
}));
vi.mock("@/lib/whoop/credentials", () => ({
getUserWhoopCredentials: vi.fn(),
}));
vi.mock("@/lib/google-health/credentials", () => ({
getUserGoogleHealthCredentials: vi.fn(),
}));
vi.mock("@/lib/oura/credentials", () => ({
getOuraClientCredentials: vi.fn(),
}));
vi.mock("@/lib/strava/credentials", () => ({
getStravaClientCredentials: vi.fn(),
}));
import type { OAuthKitDb, OAuthTokenDescriptor } from "../token";
import { withingsTokenDescriptor } from "@/lib/withings/oauth-descriptor";
import { whoopTokenDescriptor } from "@/lib/whoop/oauth-descriptor";
import { googleHealthTokenDescriptor } from "@/lib/google-health/oauth-descriptor";
import { ouraTokenDescriptor } from "@/lib/oura/oauth-descriptor";
import { stravaTokenDescriptor } from "@/lib/strava/oauth-descriptor";
import { polarTokenDescriptor } from "@/lib/polar/oauth-descriptor";
function fakeDb(row: Record<string, unknown> | null) {
const model = {
findUnique: vi.fn(async () => row),
updateMany: vi.fn(async () => ({ count: 1 })),
};
const db = {
user: model,
withingsConnection: model,
whoopConnection: model,
googleHealthConnection: model,
} as unknown as OAuthKitDb;
return { db, model };
}
const EXP = new Date("2026-10-21T13:00:00.000Z");
const NEXT = {
accessCiphertext: "a2",
refreshCiphertext: "r2",
expiresAt: EXP,
};
type AnyDescriptor = OAuthTokenDescriptor<never>;
const CONNECTION_TABLES: Array<[string, AnyDescriptor]> = [
["withings", withingsTokenDescriptor as unknown as AnyDescriptor],
["whoop", whoopTokenDescriptor as unknown as AnyDescriptor],
["google-health", googleHealthTokenDescriptor as unknown as AnyDescriptor],
];
describe.each(CONNECTION_TABLES)("%s descriptor", (_name, d) => {
it("reads the connection row's columns", async () => {
const { db } = fakeDb({
whoopUserId: "w",
accessToken: "a1",
refreshToken: "r1",
tokenExpiresAt: EXP,
});
await expect(d.readGrant(db, "u")).resolves.toEqual({
accessCiphertext: "a1",
refreshCiphertext: "r1",
expiresAt: EXP,
});
});
it("writes only over the refresh token it spent", async () => {
const { db, model } = fakeDb(null);
await d.writeGrant(db, "u", "r1", NEXT);
expect(model.updateMany).toHaveBeenCalledWith({
where: { userId: "u", refreshToken: "r1" },
data: { accessToken: "a2", refreshToken: "r2", tokenExpiresAt: EXP },
});
});
});
describe("whoop descriptor", () => {
it("treats a connection without a WHOOP user id as no grant", async () => {
const { db } = fakeDb({
whoopUserId: null,
accessToken: "a1",
refreshToken: "r1",
tokenExpiresAt: EXP,
});
await expect(whoopTokenDescriptor.readGrant(db, "u")).resolves.toBeNull();
});
});
const USER_COLUMNS: Array<[string, AnyDescriptor, string]> = [
["oura", ouraTokenDescriptor as unknown as AnyDescriptor, "oura"],
["strava", stravaTokenDescriptor as unknown as AnyDescriptor, "strava"],
];
describe.each(USER_COLUMNS)("%s descriptor", (_name, d, prefix) => {
it("reads the User columns, with no expiry", async () => {
const { db } = fakeDb({
[`${prefix}AccessTokenEncrypted`]: "a1",
[`${prefix}RefreshTokenEncrypted`]: "r1",
});
await expect(d.readGrant(db, "u")).resolves.toEqual({
accessCiphertext: "a1",
refreshCiphertext: "r1",
expiresAt: null,
});
expect(d.tracksExpiry).toBe(false);
});
it("reads no grant when either token is missing", async () => {
const { db } = fakeDb({ [`${prefix}AccessTokenEncrypted`]: "a1" });
await expect(d.readGrant(db, "u")).resolves.toBeNull();
});
it("writes only over the refresh token it spent", async () => {
const { db, model } = fakeDb(null);
await d.writeGrant(db, "u", "r1", NEXT);
expect(model.updateMany).toHaveBeenCalledWith({
where: { id: "u", [`${prefix}RefreshTokenEncrypted`]: "r1" },
data: {
[`${prefix}AccessTokenEncrypted`]: "a2",
[`${prefix}RefreshTokenEncrypted`]: "r2",
},
});
});
});
describe("polar descriptor", () => {
it("reads a token with nothing to refresh", async () => {
const { db } = fakeDb({ polarAccessTokenEncrypted: "a1" });
await expect(polarTokenDescriptor.readGrant(db, "u")).resolves.toEqual({
accessCiphertext: "a1",
refreshCiphertext: null,
expiresAt: null,
});
expect(polarTokenDescriptor.refresh).toBeNull();
});
});
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import {
parseRetryAfterMs,
RETRY_AFTER_CAP_MS,
retryAfterMsOf,
} from "../retry-after";
const NOW = Date.parse("2026-10-21T12:00:00.000Z");
describe("parseRetryAfterMs", () => {
it("reads delay seconds", () => {
expect(parseRetryAfterMs("30", NOW)).toBe(30_000);
expect(parseRetryAfterMs(" 1.5 ", NOW)).toBe(1_500);
});
it("reads an HTTP date, a past one as zero", () => {
expect(parseRetryAfterMs("Wed, 21 Oct 2026 12:01:00 GMT", NOW)).toBe(
60_000,
);
expect(parseRetryAfterMs("Wed, 21 Oct 2026 11:00:00 GMT", NOW)).toBe(0);
});
it("caps an absurd wait", () => {
expect(parseRetryAfterMs("999999999", NOW)).toBe(RETRY_AFTER_CAP_MS);
});
it("ignores what it cannot read", () => {
expect(parseRetryAfterMs(null, NOW)).toBeUndefined();
expect(parseRetryAfterMs("", NOW)).toBeUndefined();
expect(parseRetryAfterMs("soon", NOW)).toBeUndefined();
expect(parseRetryAfterMs("-5", NOW)).toBeUndefined();
});
it("reads it off a response", () => {
const res = new Response(null, {
status: 429,
headers: { "Retry-After": "7" },
});
expect(retryAfterMsOf(res, NOW)).toBe(7_000);
});
});
@@ -0,0 +1,223 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { lock } = vi.hoisted(() => ({ lock: vi.fn(async () => undefined) }));
vi.mock("@/lib/db", () => ({
prisma: {
$transaction: async (fn: (tx: unknown) => unknown) => fn({ tx: true }),
},
}));
vi.mock("@/lib/crypto", () => ({
encrypt: (v: string) => `enc(${v})`,
decrypt: (v: string) => v.replace(/^enc\((.*)\)$/, "$1"),
}));
vi.mock("@/lib/integrations/oauth-refresh", () => ({
acquireProviderTokenRefreshLock: lock,
PROVIDER_REFRESH_TRANSACTION_OPTIONS: {},
}));
import {
obtainAccessToken,
type NextGrant,
type OAuthTokenDescriptor,
type StoredGrant,
} from "../token";
const NOW = Date.parse("2026-10-21T12:00:00.000Z");
function harness(opts: {
grant: StoredGrant | null;
tracksExpiry?: boolean;
refresh?: OAuthTokenDescriptor<string>["refresh"];
creds?: string | null;
}) {
let stored = opts.grant;
const writes: NextGrant[] = [];
const reads: Array<StoredGrant | null> = [];
const descriptor: OAuthTokenDescriptor<string> = {
provider: "withings",
tracksExpiry: opts.tracksExpiry ?? true,
expiryBufferMs: 5 * 60 * 1000,
readGrant: async () => {
reads.push(stored);
return stored;
},
writeGrant: async (_db, _user, spent, next) => {
if (!stored || stored.refreshCiphertext !== spent) return 0;
writes.push(next);
stored = next;
return 1;
},
loadCredentials: async () =>
opts.creds === undefined ? "creds" : opts.creds,
refresh:
opts.refresh === undefined
? vi.fn(async () => ({
access_token: "new-access",
refresh_token: "new-refresh",
expires_in: 3600,
}))
: opts.refresh,
};
return {
descriptor,
writes,
reads,
replace(g: StoredGrant | null) {
stored = g;
},
};
}
const grant = (over: Partial<StoredGrant> = {}): StoredGrant => ({
accessCiphertext: "enc(old-access)",
refreshCiphertext: "enc(old-refresh)",
expiresAt: new Date(NOW + 60 * 60 * 1000),
...over,
});
beforeEach(() => lock.mockClear());
describe("obtainAccessToken", () => {
it("answers none without a stored grant", async () => {
const h = harness({ grant: null });
await expect(
obtainAccessToken(h.descriptor, "u", { now: () => NOW }),
).resolves.toEqual({ kind: "none" });
});
it("returns a fresh token without taking the lock", async () => {
const h = harness({ grant: grant() });
const res = await obtainAccessToken(h.descriptor, "u", { now: () => NOW });
expect(res).toMatchObject({ kind: "token", accessToken: "old-access" });
expect(lock).not.toHaveBeenCalled();
expect(h.descriptor.refresh).not.toHaveBeenCalled();
});
it("refreshes a stale token under the lock and writes both tokens", async () => {
const h = harness({ grant: grant({ expiresAt: new Date(NOW + 1000) }) });
const res = await obtainAccessToken(h.descriptor, "u", { now: () => NOW });
expect(lock).toHaveBeenCalledWith({ tx: true }, "withings", "u");
expect(res).toMatchObject({
kind: "token",
accessToken: "new-access",
refreshed: true,
});
expect(h.writes).toEqual([
{
accessCiphertext: "enc(new-access)",
refreshCiphertext: "enc(new-refresh)",
expiresAt: new Date(NOW + 3600 * 1000),
},
]);
});
it("keeps the stored refresh token when the response carries none", async () => {
const h = harness({
grant: grant({ expiresAt: new Date(NOW - 1) }),
refresh: vi.fn(async () => ({ access_token: "a2", expires_in: 60 })),
});
await obtainAccessToken(h.descriptor, "u", { now: () => NOW });
expect(h.writes[0]?.refreshCiphertext).toBe("enc(old-refresh)");
});
it("uses a peer's refresh it finds under the lock instead of spending the token again", async () => {
const stale = grant({ expiresAt: new Date(NOW - 1) });
const h = harness({ grant: stale });
// The peer commits between the first read and the read under the lock.
let first = true;
const read = h.descriptor.readGrant;
h.descriptor.readGrant = async (db, user) => {
const g = await read(db, user);
if (first) {
first = false;
h.replace(
grant({
accessCiphertext: "enc(peer-access)",
refreshCiphertext: "enc(peer-refresh)",
}),
);
}
return g;
};
const res = await obtainAccessToken(h.descriptor, "u", { now: () => NOW });
expect(res).toMatchObject({ accessToken: "peer-access", refreshed: false });
expect(h.descriptor.refresh).not.toHaveBeenCalled();
});
it("after a refusal refreshes once, and a later refusal of the same token reuses the result", async () => {
const h = harness({
grant: grant({ expiresAt: null }),
tracksExpiry: false,
});
const a = await obtainAccessToken(h.descriptor, "u", {
afterRefusalOf: "enc(old-refresh)",
now: () => NOW,
});
const b = await obtainAccessToken(h.descriptor, "u", {
afterRefusalOf: "enc(old-refresh)",
now: () => NOW,
});
expect(a).toMatchObject({ accessToken: "new-access", refreshed: true });
expect(b).toMatchObject({ accessToken: "new-access", refreshed: false });
expect(h.descriptor.refresh).toHaveBeenCalledTimes(1);
expect(h.writes[0]?.expiresAt).toBeNull();
});
it("lets a reconnect that lands during the refresh win", async () => {
const h = harness({ grant: grant({ expiresAt: new Date(NOW - 1) }) });
h.descriptor = {
...h.descriptor,
refresh: vi.fn(async () => {
h.replace(
grant({
accessCiphertext: "enc(reconnected)",
refreshCiphertext: "enc(reconnected-refresh)",
}),
);
return { access_token: "late", refresh_token: "late-r", expires_in: 1 };
}),
};
const res = await obtainAccessToken(h.descriptor, "u", { now: () => NOW });
expect(res).toMatchObject({ accessToken: "reconnected", refreshed: false });
expect(h.writes).toEqual([]);
});
it("answers no_credentials when the client is gone", async () => {
const h = harness({
grant: grant({ expiresAt: new Date(NOW - 1) }),
creds: null,
});
await expect(
obtainAccessToken(h.descriptor, "u", { now: () => NOW }),
).resolves.toEqual({ kind: "no_credentials" });
});
it("never refreshes for a provider without refresh tokens", async () => {
const h = harness({
grant: grant({ refreshCiphertext: null, expiresAt: null }),
tracksExpiry: false,
refresh: null,
});
const res = await obtainAccessToken(h.descriptor, "u", {
force: true,
now: () => NOW,
});
expect(res).toMatchObject({ kind: "token", accessToken: "old-access" });
expect(lock).not.toHaveBeenCalled();
});
it("rethrows the provider's refusal untouched", async () => {
const boom = new Error("invalid_grant");
const h = harness({
grant: grant({ expiresAt: new Date(NOW - 1) }),
refresh: vi.fn(async () => {
throw boom;
}),
});
await expect(
obtainAccessToken(h.descriptor, "u", { now: () => NOW }),
).rejects.toBe(boom);
expect(h.writes).toEqual([]);
});
});
@@ -0,0 +1,240 @@
/**
* Starting an OAuth connect: resolve the record's provider app, mint the
* state, and build the consent URL. One implementation for every way a
* connect starts, the person's own browser navigation (`GET
* /api/<provider>/connect`) and a guardian's request for a managed profile
* (`POST /api/record-settings/integrations/<provider>/connect`).
*
* The state names exactly one record, `userId`, and is either HMAC-signed
* (Oura, Polar, Strava; `src/lib/oauth/signed-state.ts`) or a ten-minute
* server-side ledger row (Withings, WHOOP, Google Health). It is set twice:
* as the `state` URL parameter and as an httpOnly cookie, and the callback
* requires both to agree before anything else.
*/
import type { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { getEvent } from "@/lib/logging/context";
import {
OAUTH_STATE_TTL_MS,
mintSignedState,
oauthStateCookieName,
} from "@/lib/oauth/signed-state";
import { getAuthorizationUrl as withingsAuthorizationUrl } from "@/lib/withings/client";
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
import {
WITHINGS_OAUTH_STATE_COOKIE,
WITHINGS_OAUTH_STATE_TTL_MS,
mintWithingsOAuthStateNonce,
} from "@/lib/withings/oauth-state";
import { getAuthorizationUrl as whoopAuthorizationUrl } from "@/lib/whoop/client";
import { getUserWhoopCredentials } from "@/lib/whoop/credentials";
import {
WHOOP_OAUTH_STATE_COOKIE,
WHOOP_OAUTH_STATE_TTL_MS,
mintWhoopOAuthStateNonce,
} from "@/lib/whoop/oauth-state";
import {
generatePkcePair,
getAuthorizationUrl as googleHealthAuthorizationUrl,
} from "@/lib/google-health/client";
import { getUserGoogleHealthCredentials } from "@/lib/google-health/credentials";
import {
GOOGLE_HEALTH_OAUTH_STATE_COOKIE,
GOOGLE_HEALTH_OAUTH_STATE_TTL_MS,
mintGoogleHealthOAuthStateNonce,
} from "@/lib/google-health/oauth-state";
import { getAuthorizationUrl as ouraAuthorizationUrl } from "@/lib/oura/client";
import { getOuraClientCredentials } from "@/lib/oura/credentials";
import { getAuthorizationUrl as polarAuthorizationUrl } from "@/lib/polar/client";
import { getPolarClientCredentials } from "@/lib/polar/credentials";
import { getAuthorizationUrl as stravaAuthorizationUrl } from "@/lib/strava/client";
import { getStravaClientCredentials } from "@/lib/strava/credentials";
import type { OAuthKitProvider } from "./token";
export interface ConnectStateCookie {
readonly name: string;
readonly value: string;
readonly maxAgeSeconds: number;
}
export type ConnectStartResult =
| {
readonly ok: true;
readonly authorizationUrl: string;
readonly cookie: ConnectStateCookie;
}
| {
readonly ok: false;
/** `nocreds`: no provider app; `connect`: the state could not be stored. */
readonly reason: "nocreds" | "connect";
};
export interface ConnectStartOptions {
/** WHOOP only: a validated native return scheme, carried on the ledger row. */
readonly returnScheme?: string | null;
}
/** Can the record resolve a provider app (its own or the deployment's)? */
export async function recordHasClientApp(
provider: OAuthKitProvider,
userId: string,
): Promise<boolean> {
switch (provider) {
case "withings":
return (await getUserWithingsCredentials(userId)) !== null;
case "whoop":
return (await getUserWhoopCredentials(userId)) !== null;
case "google-health":
return (await getUserGoogleHealthCredentials(userId)) !== null;
case "oura":
return (await getOuraClientCredentials(userId)) !== null;
case "polar":
return (await getPolarClientCredentials(userId)) !== null;
case "strava":
return (await getStravaClientCredentials(userId)) !== null;
}
}
function signedStart(
provider: "oura" | "polar" | "strava",
userId: string,
url: (state: string) => string,
): ConnectStartResult {
const state = mintSignedState(provider, userId);
return {
ok: true,
authorizationUrl: url(state),
cookie: {
name: oauthStateCookieName(provider),
value: state,
maxAgeSeconds: Math.floor(OAUTH_STATE_TTL_MS / 1000),
},
};
}
async function ledgerStart(
write: () => Promise<unknown>,
cookie: ConnectStateCookie,
url: () => string,
): Promise<ConnectStartResult> {
try {
await write();
} catch (err) {
getEvent()?.setError(err);
return { ok: false, reason: "connect" };
}
return { ok: true, authorizationUrl: url(), cookie };
}
/** Mint the state for `userId` and build the provider's consent URL. */
export async function startOAuthConnect(
provider: OAuthKitProvider,
userId: string,
opts: ConnectStartOptions = {},
): Promise<ConnectStartResult> {
switch (provider) {
case "withings": {
const creds = await getUserWithingsCredentials(userId);
if (!creds) return { ok: false, reason: "nocreds" };
const nonce = mintWithingsOAuthStateNonce();
return ledgerStart(
() =>
prisma.withingsOAuthState.create({
data: {
nonce,
userId,
expiresAt: new Date(Date.now() + WITHINGS_OAUTH_STATE_TTL_MS),
},
}),
{
name: WITHINGS_OAUTH_STATE_COOKIE,
value: nonce,
maxAgeSeconds: Math.floor(WITHINGS_OAUTH_STATE_TTL_MS / 1000),
},
() => withingsAuthorizationUrl(nonce, creds),
);
}
case "whoop": {
const creds = await getUserWhoopCredentials(userId);
if (!creds) return { ok: false, reason: "nocreds" };
const nonce = mintWhoopOAuthStateNonce();
return ledgerStart(
() =>
prisma.whoopOAuthState.create({
data: {
nonce,
userId,
expiresAt: new Date(Date.now() + WHOOP_OAUTH_STATE_TTL_MS),
returnScheme: opts.returnScheme ?? null,
},
}),
{
name: WHOOP_OAUTH_STATE_COOKIE,
value: nonce,
maxAgeSeconds: Math.floor(WHOOP_OAUTH_STATE_TTL_MS / 1000),
},
() => whoopAuthorizationUrl(nonce, creds),
);
}
case "google-health": {
const creds = await getUserGoogleHealthCredentials(userId);
if (!creds) return { ok: false, reason: "nocreds" };
const nonce = mintGoogleHealthOAuthStateNonce();
const { verifier, challenge } = generatePkcePair();
return ledgerStart(
() =>
prisma.googleHealthOAuthState.create({
data: {
nonce,
userId,
codeVerifier: verifier,
expiresAt: new Date(
Date.now() + GOOGLE_HEALTH_OAUTH_STATE_TTL_MS,
),
},
}),
{
name: GOOGLE_HEALTH_OAUTH_STATE_COOKIE,
value: nonce,
maxAgeSeconds: Math.floor(GOOGLE_HEALTH_OAUTH_STATE_TTL_MS / 1000),
},
() => googleHealthAuthorizationUrl(nonce, creds, challenge),
);
}
case "oura": {
const creds = await getOuraClientCredentials(userId);
if (!creds) return { ok: false, reason: "nocreds" };
return signedStart("oura", userId, (s) => ouraAuthorizationUrl(s, creds));
}
case "polar": {
const creds = await getPolarClientCredentials(userId);
if (!creds) return { ok: false, reason: "nocreds" };
return signedStart("polar", userId, (s) =>
polarAuthorizationUrl(s, creds),
);
}
case "strava": {
const creds = await getStravaClientCredentials(userId);
if (!creds) return { ok: false, reason: "nocreds" };
return signedStart("strava", userId, (s) =>
stravaAuthorizationUrl(s, creds),
);
}
}
}
/** Set the state cookie a started connect needs on its response. */
export function setConnectStateCookie(
response: NextResponse,
cookie: ConnectStateCookie,
): NextResponse {
response.cookies.set(cookie.name, cookie.value, {
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: cookie.maxAgeSeconds,
path: "/",
});
return response;
}
@@ -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,144 @@
/**
* Ending an OAuth connection: tell the provider where it listens, drop the
* grant, audit it, and park the integration at `disconnected`. One
* implementation for the person's own disconnect (`POST
* /api/<provider>/disconnect`) and a guardian's for a managed profile
* (`POST /api/record-settings/integrations/<provider>/disconnect`). Imported
* data stays; a reconnect resumes the sync.
*
* The audit row is filed under the record; when a guardian acts, `auditLog`
* reads the guardian off the request and records them as the actor.
*/
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import { auditLog } from "@/lib/auth/audit";
import { decrypt } from "@/lib/crypto";
import { getEvent } from "@/lib/logging/context";
import { markDisconnected } from "@/lib/integrations/status";
import { unsubscribeWebhook } from "@/lib/withings/client";
import {
WITHINGS_NOTIFY_APPLIS,
getWithingsWebhookCallbackUrl,
} from "@/lib/withings/sync";
import { deauthorize } from "@/lib/strava/client";
import {
STRAVA_DISCONNECTED,
getStravaConnection,
} from "@/lib/strava/credentials";
import type { OAuthKitProvider } from "./token";
export type DisconnectOutcome = "disconnected" | "not_connected";
/** The audit action each provider has always filed a disconnect under. */
const AUDIT_ACTION: Record<OAuthKitProvider, string> = {
withings: "withings.disconnect",
whoop: "whoop.disconnect",
"google-health": "google_health.disconnect",
oura: "oura.disconnect",
polar: "polar.disconnect",
strava: "strava.disconnect",
};
async function dropGrant(
provider: OAuthKitProvider,
userId: string,
): Promise<boolean> {
switch (provider) {
case "withings": {
const connection = await prisma.withingsConnection.findUnique({
where: { userId },
});
if (!connection) return false;
// Withings keeps calling a subscribed URL after the grant is gone, so
// the subscriptions go first. Best effort: a refused unsubscribe must
// not keep the person connected.
try {
const accessToken = decrypt(connection.accessToken);
const callbackUrl = getWithingsWebhookCallbackUrl();
for (const appli of WITHINGS_NOTIFY_APPLIS) {
try {
await unsubscribeWebhook(accessToken, callbackUrl, appli);
} catch {
// Already gone, or the token expired; the delete below stands.
}
}
} catch {
// An undecryptable token cannot unsubscribe anything.
}
await prisma.withingsConnection.delete({ where: { userId } });
return true;
}
case "whoop": {
const connection = await prisma.whoopConnection.findUnique({
where: { userId },
});
if (!connection) return false;
await prisma.whoopConnection.delete({ where: { userId } });
return true;
}
case "google-health": {
const connection = await prisma.googleHealthConnection.findUnique({
where: { userId },
});
if (!connection) return false;
await prisma.googleHealthConnection.delete({ where: { userId } });
return true;
}
case "oura": {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { ouraAccessTokenEncrypted: true },
});
if (!row?.ouraAccessTokenEncrypted) return false;
await prisma.user.update({
where: { id: userId },
data: {
ouraAccessTokenEncrypted: null,
ouraRefreshTokenEncrypted: null,
ouraScopeDenied: Prisma.DbNull,
},
});
return true;
}
case "polar": {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { polarAccessTokenEncrypted: true },
});
if (!row?.polarAccessTokenEncrypted) return false;
await prisma.user.update({
where: { id: userId },
data: { polarAccessTokenEncrypted: null, polarUserIdEncrypted: null },
});
return true;
}
case "strava": {
const connection = await getStravaConnection(userId);
if (!connection) return false;
// Strava keeps the app authorised until it is told otherwise.
try {
await deauthorize(connection.accessToken);
} catch (err) {
getEvent()?.addWarning(
`strava: deauthorize failed on disconnect: ${err}`,
);
}
await prisma.user.update({
where: { id: userId },
data: STRAVA_DISCONNECTED,
});
return true;
}
}
}
/** Disconnect `provider` from the record `userId`. */
export async function disconnectOAuth(
provider: OAuthKitProvider,
userId: string,
): Promise<DisconnectOutcome> {
if (!(await dropGrant(provider, userId))) return "not_connected";
await auditLog(AUDIT_ACTION[provider], { userId });
await markDisconnected(userId, provider);
return "disconnected";
}
@@ -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);
}
@@ -0,0 +1,42 @@
/**
* How long a provider asked to be left alone after a 429 or 503.
*
* `Retry-After` comes in two forms (RFC 9110 10.2.3): a number of seconds, or
* an HTTP date. Every provider client reads it through here, so a date form is
* not honoured by one provider and ignored by the next.
*/
/** Upper bound on any wait a provider can ask for, so a bad header cannot park a job for days. */
export const RETRY_AFTER_CAP_MS = 24 * 60 * 60 * 1000;
/**
* The delay a `Retry-After` value asks for, in ms, or undefined when the value
* is absent or unreadable. A date in the past reads as 0.
*/
export function parseRetryAfterMs(
value: string | null | undefined,
now: number = Date.now(),
): number | undefined {
const header = value?.trim();
if (!header) return undefined;
if (/^\d+(?:\.\d+)?$/.test(header)) {
return Math.min(
RETRY_AFTER_CAP_MS,
Math.round(Number.parseFloat(header) * 1000),
);
}
// An HTTP date always names its weekday and month; a bare number that is not
// a delay (a negative one) is not a date either, whatever Date.parse thinks.
if (!/[a-z]/i.test(header)) return undefined;
const at = Date.parse(header);
if (!Number.isFinite(at)) return undefined;
return Math.min(RETRY_AFTER_CAP_MS, Math.max(0, at - now));
}
/** The `Retry-After` delay of a response, in ms. */
export function retryAfterMsOf(
res: Pick<Response, "headers">,
now: number = Date.now(),
): number | undefined {
return parseRetryAfterMs(res.headers.get("retry-after"), now);
}
+246
View File
@@ -0,0 +1,246 @@
/**
* The one token lifecycle every OAuth provider integration goes through.
*
* Six providers (Withings, WHOOP, Oura, Strava, Google Health, Polar) used to
* carry their own copy of "read the stored grant, decide whether it needs a
* refresh, refresh it, write it back". The copies drifted: two refreshed under
* the advisory lock, one only compare-and-swapped, one did neither, and the
* Withings status route refreshed with no guard at all. A provider now
* describes where its grant lives and how its token endpoint is called
* (`OAuthTokenDescriptor`), and this module owns the rest:
*
* - A fresh token is returned straight from the row, without a transaction.
* - A refresh happens under the per-provider, per-user advisory lock
* (`acquireProviderTokenRefreshLock`). Under the lock the row is read again:
* a peer that refreshed while this caller waited, or a reconnect that
* stored a new grant, wins, and its token is used instead of refreshing a
* second time. Spending a one-time refresh token twice is what an
* authorization server reads as theft.
* - The write is conditional on the refresh ciphertext the refresh spent, so
* a reconnect that lands while the provider answers (it takes no lock) is
* never overwritten by the older grant.
* - A provider that does not rotate refresh tokens keeps the stored one when
* the response carries none.
*
* Storage is unchanged by design: every descriptor reads and writes the
* columns the provider always used, so no connection has to reconnect.
*
* What stays with the provider: classifying a refused refresh, recording it on
* the integration ledger, and the provider's own reauth flag. The kit throws
* the provider's own error untouched, so those branches keep working.
*/
import { prisma } from "@/lib/db";
import type { Prisma } from "@/generated/prisma/client";
import { decrypt, encrypt } from "@/lib/crypto";
import {
acquireProviderTokenRefreshLock,
PROVIDER_REFRESH_TRANSACTION_OPTIONS,
} from "@/lib/integrations/oauth-refresh";
/** The OAuth integrations whose tokens the kit manages. */
export type OAuthKitProvider =
"withings" | "whoop" | "oura" | "strava" | "google-health" | "polar";
/** Read and write access the descriptor gets: the client or a transaction. */
export type OAuthKitDb = Prisma.TransactionClient;
/** A grant as stored, still sealed. */
export interface StoredGrant {
/** Ciphertext of the access token. */
readonly accessCiphertext: string;
/** Ciphertext of the refresh token; null for a provider without one. */
readonly refreshCiphertext: string | null;
/** When the access token stops working; null when the provider gives none. */
readonly expiresAt: Date | null;
}
/** The sealed grant a refresh writes back. */
export interface NextGrant {
readonly accessCiphertext: string;
readonly refreshCiphertext: string;
readonly expiresAt: Date | null;
}
/** What a provider's token endpoint answered on a refresh. */
export interface RefreshedGrant {
readonly access_token: string;
readonly refresh_token?: string | null;
readonly expires_in?: number | null;
}
export interface OAuthTokenDescriptor<Credentials> {
readonly provider: OAuthKitProvider;
/**
* True when the provider stores an expiry and the token is refreshed ahead
* of it. False for the providers that refresh only after a refusal (Oura,
* Strava) or never (Polar).
*/
readonly tracksExpiry: boolean;
/** How long before the expiry a token counts as stale. */
readonly expiryBufferMs: number;
/** Read the stored grant, or null when the user has no connection. */
readGrant(db: OAuthKitDb, userId: string): Promise<StoredGrant | null>;
/**
* Write a refreshed grant, only while the stored refresh ciphertext still
* equals `spentRefreshCiphertext`. Returns the number of rows written.
*/
writeGrant(
db: OAuthKitDb,
userId: string,
spentRefreshCiphertext: string,
next: NextGrant,
): Promise<number>;
/** The OAuth client the refresh authenticates as, or null when none is set. */
loadCredentials(userId: string): Promise<Credentials | null>;
/** Call the token endpoint. Null for a provider without refresh tokens. */
readonly refresh:
| ((
refreshToken: string,
credentials: Credentials,
) => Promise<RefreshedGrant>)
| null;
}
export type AccessTokenResult =
| { readonly kind: "none" }
| { readonly kind: "no_credentials" }
| {
readonly kind: "token";
readonly accessToken: string;
readonly expiresAt: Date | null;
/** The refresh ciphertext stored beside the token, for a later refusal. */
readonly refreshCiphertext: string | null;
/** True when this call refreshed the token itself. */
readonly refreshed: boolean;
};
export interface ObtainAccessTokenOptions {
/**
* The token was just refused. Refresh unless a peer already did: pass the
* refresh ciphertext the refused token was read with. A stored ciphertext
* that differs from it means the grant moved on since, and its token is
* used as it is.
*/
readonly afterRefusalOf?: string;
/** Refresh even when the stored expiry says the token is still good. */
readonly force?: boolean;
/** Clock override for tests. */
readonly now?: () => number;
}
function isFresh(
descriptor: Pick<
OAuthTokenDescriptor<unknown>,
"tracksExpiry" | "expiryBufferMs"
>,
grant: StoredGrant,
now: number,
): boolean {
if (!descriptor.tracksExpiry || grant.expiresAt === null) return true;
return grant.expiresAt.getTime() - descriptor.expiryBufferMs >= now;
}
function tokenOf(grant: StoredGrant, refreshed: boolean): AccessTokenResult {
return {
kind: "token",
accessToken: decrypt(grant.accessCiphertext),
expiresAt: grant.expiresAt,
refreshCiphertext: grant.refreshCiphertext,
refreshed,
};
}
/**
* A usable access token for the user's connection, refreshing it when it is
* stale, forced, or was just refused. Throws whatever the provider's token
* endpoint threw, for the provider to classify and record.
*/
export async function obtainAccessToken<Credentials>(
descriptor: OAuthTokenDescriptor<Credentials>,
userId: string,
opts: ObtainAccessTokenOptions = {},
): Promise<AccessTokenResult> {
const now = opts.now ?? Date.now;
const observed = await descriptor.readGrant(prisma, userId);
if (!observed) return { kind: "none" };
const wantsRefresh =
opts.force === true ||
opts.afterRefusalOf !== undefined ||
!isFresh(descriptor, observed, now());
if (
!wantsRefresh ||
descriptor.refresh === null ||
observed.refreshCiphertext === null
) {
return tokenOf(observed, false);
}
const credentials = await descriptor.loadCredentials(userId);
if (!credentials) return { kind: "no_credentials" };
const refresh = descriptor.refresh;
// What the caller's view of the grant was. A refusal names the ciphertext
// its token was read with; otherwise it is what was just read above.
const spentView = opts.afterRefusalOf ?? observed.refreshCiphertext;
return prisma.$transaction(async (tx) => {
await acquireProviderTokenRefreshLock(tx, descriptor.provider, userId);
const current = await descriptor.readGrant(tx, userId);
if (!current || current.refreshCiphertext === null) {
return { kind: "none" } as const;
}
// The grant moved on while this caller waited for the lock or used its
// token: a peer refreshed it, or a reconnect replaced it. Use that one.
// A non-rotating provider keeps its refresh ciphertext, so its access
// ciphertext tells the same story.
const movedOn =
current.refreshCiphertext !== spentView ||
(opts.afterRefusalOf === undefined &&
current.accessCiphertext !== observed.accessCiphertext);
if (movedOn && isFresh(descriptor, current, now())) {
return tokenOf(current, false);
}
if (
!movedOn &&
opts.force !== true &&
opts.afterRefusalOf === undefined &&
isFresh(descriptor, current, now())
) {
return tokenOf(current, false);
}
const rotated = await refresh(
decrypt(current.refreshCiphertext),
credentials,
);
const expiresAt = descriptor.tracksExpiry
? new Date(now() + (rotated.expires_in ?? 3600) * 1000)
: null;
const next: NextGrant = {
accessCiphertext: encrypt(rotated.access_token),
refreshCiphertext: rotated.refresh_token
? encrypt(rotated.refresh_token)
: current.refreshCiphertext,
expiresAt,
};
const written = await descriptor.writeGrant(
tx,
userId,
current.refreshCiphertext,
next,
);
if (written > 0) {
return {
kind: "token",
accessToken: rotated.access_token,
expiresAt,
refreshCiphertext: next.refreshCiphertext,
refreshed: true,
} as const;
}
// A reconnect stored a new grant while the provider answered; it wins.
const replaced = await descriptor.readGrant(tx, userId);
return replaced ? tokenOf(replaced, false) : ({ kind: "none" } as const);
}, PROVIDER_REFRESH_TRANSACTION_OPTIONS);
}
+8 -1
View File
@@ -62,7 +62,14 @@ export async function persistRotatedToken(
}
export type SerializedRefreshProvider =
"whoop" | "withings" | "oura" | "codex" | "admin-codex";
| "whoop"
| "withings"
| "oura"
| "strava"
| "google-health"
| "polar"
| "codex"
| "admin-codex";
/**
* Provider refreshes hold a database transaction open around one bounded
@@ -28,6 +28,7 @@ const providerSources = [
"lab-biomarker-backfill.ts",
"strava-backfill.ts",
"fitbit-sleep-repair.ts",
"oura-backfill.ts",
].map((file) => readFileSync(join(__dirname, "..", file), "utf8"));
describe("shared full-history backfill admission", () => {
@@ -41,9 +42,10 @@ describe("shared full-history backfill admission", () => {
"lab-biomarker-backfill",
"strava-backfill",
"fitbit-sleep-repair",
"oura-backfill",
]);
expect(INTEGRATION_BACKFILL_BOOT_ORDER.map(bootStaggerSecondsFor)).toEqual([
30, 60, 90, 120, 150, 180, 210, 240,
30, 60, 90, 120, 150, 180, 210, 240, 270,
]);
expect(INTEGRATION_BACKFILL_STAGGER_SECONDS).toBe(30);
@@ -61,6 +63,7 @@ describe("shared full-history backfill admission", () => {
"lab-biomarker-backfill": "enqueueBootTimeLabBiomarkerBackfill",
"strava-backfill": "enqueueBootTimeStravaBackfill",
"fitbit-sleep-repair": "enqueueBootTimeFitbitSleepRepair",
"oura-backfill": "enqueueBootTimeOuraBackfill",
} as const;
for (const kind of INTEGRATION_BACKFILL_BOOT_ORDER) {
expect(registrarSource).toMatch(
@@ -153,6 +156,7 @@ describe("shared full-history backfill admission", () => {
"LAB_BIOMARKER_BACKFILL_QUEUE",
"STRAVA_BACKFILL_QUEUE",
"FITBIT_SLEEP_REPAIR_QUEUE",
"OURA_BACKFILL_QUEUE",
]) {
expect(registrarSource).toMatch(
new RegExp(
@@ -0,0 +1,301 @@
/**
* The Oura backfill's slice logic with the provider and the database stubbed:
* where a slice stops, what it saves, where the next one starts, and how long
* it waits. The real walk against Postgres is
* `tests/integration/oura-backfill.test.ts`.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
stored: null as unknown,
completedAt: null as Date | null,
sends: [] as Array<{ payload: unknown; startAfter?: number }>,
windows: [] as string[],
/** Per collection: what a window read does. */
behave: {} as Record<
string,
(w: { startDate: string; endDate: string }) => Promise<unknown[]>
>,
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(async () => ({
ouraBackfillProgress: h.stored,
ouraBackfillCompletedAt: h.completedAt,
})),
update: vi.fn(
async ({
data,
}: {
data: {
ouraBackfillProgress?: unknown;
ouraBackfillCompletedAt?: Date;
};
}) => {
if (data.ouraBackfillProgress !== undefined) {
h.stored = JSON.parse(JSON.stringify(data.ouraBackfillProgress));
}
if (data.ouraBackfillCompletedAt) {
h.completedAt = data.ouraBackfillCompletedAt;
}
},
),
},
},
}));
vi.mock("@/lib/oura/retire-distance", () => ({
OURA_EQUIVALENT_DISTANCE_WHERE: {},
retireOuraEquivalentDistance: vi.fn(async () => 0),
}));
vi.mock("@/lib/oura/credentials", () => ({
getOuraConnection: vi.fn(async () => ({
accessToken: "acc",
refreshTokenCiphertext: "enc",
scopeDenied: [],
})),
}));
vi.mock("@/lib/oura/oauth-descriptor", () => ({
refreshOuraAfterRefusal: vi.fn(async () => "fresh"),
}));
vi.mock("@/lib/integrations/status", () => ({
isReauthRequired: vi.fn(async () => false),
}));
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: vi.fn(async () => "UTC"),
}));
vi.mock("@/lib/sleep/sweep-stale-segments", () => ({
sweepStaleSleepSegments: vi.fn(async () => {}),
}));
vi.mock("@/lib/oura/sync-heart-rate", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/oura/sync-heart-rate")>()),
refoldOuraTouchedDays: vi.fn(async () => {}),
}));
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: () => ({
send: async (
_queue: string,
payload: unknown,
options: { startAfter?: number },
) => {
h.sends.push({ payload, startAfter: options.startAfter });
return "job";
},
}),
}));
vi.mock("@/lib/oura/sync", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/oura/sync")>();
return {
...actual,
ouraCollectionReader:
(name: string, ctx: { query: { startDate: string; endDate: string } }) =>
async () => {
h.windows.push(`${name}:${ctx.query.startDate}..${ctx.query.endDate}`);
return (h.behave[name] ?? (async () => []))(ctx.query);
},
upsertOuraMeasurements: vi.fn(
async (_u: string, readings: unknown[]) => readings.length,
),
retireOuraSleepRecords: vi.fn(async () => 0),
recordOuraSyncFailure: vi.fn(async () => {}),
};
});
import { OuraApiError } from "@/lib/oura/response-classifier";
import { OURA_BACKFILL_ORDER, runOuraBackfillForUser } from "../oura-backfill";
const T0 = Date.parse("2026-10-01T12:00:00Z");
/** Activity on every window from mid-2026: history starts 2026-06-01. */
function activityFrom(first: string) {
return async (w: { startDate: string; endDate: string }) =>
w.endDate < first
? []
: [
{
type: "ACTIVITY_STEPS",
value: 1,
unit: "steps",
measuredAt: new Date(
`${w.startDate < first ? first : w.startDate}T12:00:00Z`,
),
externalId: `activity:${w.startDate}:steps`,
},
];
}
/** A clock that moves one second per read. */
function clock() {
let t = T0;
return () => (t += 1000);
}
beforeEach(() => {
h.stored = null;
h.completedAt = null;
h.sends = [];
h.windows = [];
h.behave = { activity: activityFrom("2026-06-01") };
});
describe("runOuraBackfillForUser", () => {
it("stops on its budget, saves where each collection got to, and resumes there", async () => {
const first = await runOuraBackfillForUser("u1", () => false, 0, {
sliceMs: 10_000,
now: clock(),
});
expect(first.continued).toBe(true);
expect(h.sends[0]!.payload).toMatchObject({
kind: "oura-backfill",
data: { userId: "u1", slice: 1 },
});
const saved = h.stored as {
anchor: string;
collections: Record<string, { before: string }>;
};
expect(saved.anchor).toBe("2026-10-01");
const lastWindow = h.windows.at(-1)!;
const [name, range] = lastWindow.split(":");
expect(saved.collections[name!]!.before).toBe(range!.split("..")[0]);
// The next slice opens with the window right before the saved edge.
h.windows = [];
await runOuraBackfillForUser("u1", () => false, 1, {
sliceMs: 10_000,
now: clock(),
});
expect(h.windows[0]!.startsWith(`${name}:`)).toBe(true);
expect(
h.windows[0]!.endsWith(`..${saved.collections[name!]!.before}`),
).toBe(true);
});
it("runs to the end and learns where history starts", async () => {
let slice = 0;
let continued = true;
while (continued) {
continued = (
await runOuraBackfillForUser("u1", () => false, slice++, {
sliceMs: 60_000,
now: clock(),
})
).continued;
expect(slice).toBeLessThan(100);
}
expect(h.completedAt).not.toBeNull();
const saved = h.stored as {
historyStart: string;
collections: Record<string, { done?: boolean; before: string }>;
};
expect(saved.historyStart).toBe("2026-06-01");
for (const name of OURA_BACKFILL_ORDER) {
expect(saved.collections[name]!.done).toBe(true);
}
// The non-probe collections stop a week before the history start.
expect(saved.collections.heartrate!.before).toBe("2026-05-25");
expect(
h.windows
.filter((w) => w.startsWith("heartrate:"))
.every((w) => {
const start = w.split(":")[1]!.split("..")[0]!;
return start >= "2026-05-25";
}),
).toBe(true);
// No window read twice.
expect(new Set(h.windows).size).toBe(h.windows.length);
});
it("waits out the Retry-After a 429 named before the next slice", async () => {
h.behave.activity = async () => {
throw new OuraApiError({
verb: "fetchDailyActivity",
classification: "transient",
httpStatus: 429,
reason: "http_429",
retryAfterMs: 900_000,
});
};
const run = await runOuraBackfillForUser("u1", () => false, 3, {
now: clock(),
});
expect(run.continued).toBe(true);
expect(h.sends).toHaveLength(1);
expect(h.sends[0]!.startAfter).toBe(900);
expect(h.sends[0]!.payload).toMatchObject({ data: { slice: 4 } });
});
it("marks a collection the grant refuses as denied, finishes, and asks again on the next chain", async () => {
h.behave.workout = async () => {
throw new OuraApiError({
verb: "fetchWorkouts",
classification: "reauth_required",
httpStatus: 401,
reason: "scope_missing",
missingScope: "workout",
});
};
let slice = 0;
let continued = true;
while (continued) {
continued = (
await runOuraBackfillForUser("u1", () => false, slice++, {
sliceMs: 60_000,
now: clock(),
})
).continued;
}
expect(h.completedAt).not.toBeNull();
const saved = h.stored as {
collections: Record<string, { denied?: boolean }>;
};
expect(saved.collections.workout!.denied).toBe(true);
expect(h.windows.filter((w) => w.startsWith("workout:"))).toHaveLength(1);
// A connect reopens it; the first slice asks for workouts again and
// nothing else.
h.completedAt = null;
h.windows = [];
delete h.behave.workout;
await runOuraBackfillForUser("u1", () => false, 0, {
sliceMs: 60_000,
now: clock(),
});
expect(new Set(h.windows.map((w) => w.split(":")[0]))).toEqual(
new Set(["workout"]),
);
expect(h.completedAt).not.toBeNull();
});
it("goes on with the other collections past a failing one, and throws when nothing moved", async () => {
h.behave.spo2 = async () => {
throw new OuraApiError({
verb: "fetchDailySpo2",
classification: "transient",
httpStatus: 502,
reason: "http_502",
});
};
const run = await runOuraBackfillForUser("u1", () => false, 0, {
sliceMs: 600_000,
now: clock(),
});
// Everything but spo2 moved, so the chain goes on.
expect(run.continued).toBe(true);
const saved = h.stored as {
collections: Record<string, { done?: boolean }>;
};
expect(saved.collections.heartrate!.done).toBe(true);
expect(saved.collections.spo2!.done).toBeUndefined();
// Now only spo2 is left, and it fails again: no progress, so it throws
// for the pg-boss retry instead of chaining forever.
await expect(
runOuraBackfillForUser("u1", () => false, 1, {
sliceMs: 600_000,
now: clock(),
}),
).rejects.toThrow(/oura backfill incomplete/);
expect(h.completedAt).toBeNull();
});
});
@@ -90,6 +90,7 @@ describe("integration-sync registrar queue policies", () => {
["FITBIT_BACKFILL_QUEUE", "exclusive"],
["GOOGLE_HEALTH_BACKFILL_QUEUE", "exclusive"],
["STRAVA_BACKFILL_QUEUE", "exclusive"],
["OURA_BACKFILL_QUEUE", "exclusive"],
["GOOGLE_HEALTH_SLEEP_REPAIR_QUEUE", "exclusive"],
["FITBIT_SLEEP_REPAIR_QUEUE", "exclusive"],
["SLEEP_TIMELINE_BACKFILL_QUEUE", "exclusive"],
@@ -19,6 +19,7 @@ export const INTEGRATION_BACKFILL_BOOT_ORDER = [
// Appended, not slotted next to `fitbit-backfill`, so every existing
// provider's boot stagger stays exactly where it was.
"fitbit-sleep-repair",
"oura-backfill",
] as const;
export type IntegrationBackfillKind =
@@ -22,6 +22,7 @@ import { runGoogleHealthBackfillForUser } from "@/lib/jobs/google-health-backfil
import { runGoogleHealthSleepRepairForUser } from "@/lib/jobs/google-health-sleep-repair";
import { runFitbitSleepRepairForUser } from "@/lib/jobs/fitbit-sleep-repair";
import { runStravaBackfillForUser } from "@/lib/jobs/strava-backfill";
import { runOuraBackfillForUser } from "@/lib/jobs/oura-backfill";
import { runSleepTimelineBackfillForUser } from "@/lib/jobs/sleep-timeline-backfill";
import { runLabBiomarkerBackfillForUser } from "@/lib/jobs/lab-biomarker-backfill";
import {
@@ -144,6 +145,20 @@ async function runAdmitted(
);
return;
}
case "oura-backfill": {
const { imported, continued } = await runOuraBackfillForUser(
userId,
shouldStop,
data.slice ?? 0,
);
totals.imported += imported;
if (continued) totals.continued += 1;
workerLog(
"info",
`[oura-backfill] user=${userId} imported=${imported}${continued ? " continued" : ""}`,
);
return;
}
}
}
+516
View File
@@ -0,0 +1,516 @@
/**
* v1.44 — the Oura history backfill: every collection's history, read once,
* in resumable slices through the shared integration backfill lane.
*
* The hourly sync reads a window of days (hours for heart rate); everything
* older is this job's. Discovery finds every Oura connection whose
* `ouraBackfillCompletedAt` is unset (an existing connection included: the
* column arrives empty, so no reconnect is needed), and the slices walk each
* collection from the day the backfill began back to the start of the
* account's history, window by window, noting after every window where they
* got to (`../oura/backfill-progress.ts`). A slice ends on its own budget, on
* the job's, when another heavy job wants the lane, or on Oura's rate limit,
* and the next slice picks up at the window it stopped before. A collection
* is never read again from the anchor once it has moved.
*
* Memory: a window of a day-keyed collection is at most ninety days of
* records; heart rate is streamed a page at a time (`syncOuraHeartRateRange`)
* and keeps only the days it touched. Nothing accumulates across windows.
*
* The first thing a backfill does, once per account, is retire the
* equivalent-distance rows earlier syncs wrote (`retireOuraEquivalentDistance`).
* An account that holds such rows but no longer has an Oura connection is
* offered too: it retires them and is stamped complete, and a later connect
* reopens the backfill (the callback clears the stamp).
*
* The marker is stamped only when every collection is done or refused for a
* scope the grant lacks; the progress note stays beside it, so a reconnect
* that grants the missing scope reads only those collections: the first slice
* of every chain asks again for the refused ones.
*/
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import {
enqueueIntegrationBackfillAdmission,
INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS,
integrationBackfillSourceOptions,
} from "@/lib/jobs/integration-backfill-admission";
import { LANE_YIELD_DELAY_SECONDS, laneWanted } from "@/lib/jobs/heavy-lane";
import { reportJobProgress } from "@/lib/jobs/job-observer";
import { isReauthRequired } from "@/lib/integrations/status";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
import { sweepStaleSleepSegments } from "@/lib/sleep/sweep-stale-segments";
import { getOuraConnection } from "@/lib/oura/credentials";
import { refreshOuraAfterRefusal } from "@/lib/oura/oauth-descriptor";
import { OuraApiError } from "@/lib/oura/response-classifier";
import { OURA_COLLECTIONS, type OuraCollectionName } from "@/lib/oura/scope";
import {
isNamedScopeRefusal,
isTokenRefusal,
ouraCollectionReader,
ouraRunContext,
recordOuraSyncFailure,
retireOuraSleepRecords,
upsertOuraMeasurements,
} from "@/lib/oura/sync";
import { refoldOuraTouchedDays } from "@/lib/oura/sync-heart-rate";
import {
OURA_EQUIVALENT_DISTANCE_WHERE,
retireOuraEquivalentDistance,
} from "@/lib/oura/retire-distance";
import {
OURA_HISTORY_FLOOR,
ouraBackfillProgressJson,
readOuraBackfillProgress,
withoutDenied,
type OuraBackfillProgress,
} from "@/lib/oura/backfill-progress";
export const OURA_BACKFILL_QUEUE = "oura-backfill";
/** One account at a time; the shared lane bounds the instance. */
export const OURA_BACKFILL_CONCURRENCY = 1;
/**
* One slice: five minutes of walking, then the account is let go and the
* rest is the next slice's. Short so a slice never sits long on the lane.
*/
export const OURA_BACKFILL_SLICE_MS = 5 * 60 * 1000;
/** The pause before the next slice after a rate limit that named no wait. */
export const OURA_BACKFILL_THROTTLED_DELAY_SECONDS = 5 * 60;
/**
* The highest slice a chain may reach. Eleven years of history is about 600
* heart-rate windows and a few hundred more for the rest, and a slice writes
* dozens of windows; two thousand slices bounds nothing a working backfill
* reaches, and stops one that counts progress it does not make.
*/
export const OURA_BACKFILL_MAX_SLICE = 2000;
/** Days per window, by collection: dense or heavy ones read shorter windows. */
const WINDOW_DAYS: Partial<Record<OuraCollectionName, number>> = {
sleep: 30,
heartrate: 7,
};
const DEFAULT_WINDOW_DAYS = 90;
/** The collections that find where history starts; walked first. */
const HISTORY_PROBES: readonly OuraCollectionName[] = ["activity", "sleep"];
/** How far before the learned history start the other collections reach. */
const HISTORY_MARGIN_DAYS = 7;
/** Walk order: the history probes first, heart rate (the dense one) last. */
export const OURA_BACKFILL_ORDER: readonly OuraCollectionName[] = [
...HISTORY_PROBES,
...OURA_COLLECTIONS.map((c) => c.name).filter(
(n) => !HISTORY_PROBES.includes(n) && n !== "heartrate",
),
"heartrate",
];
export interface OuraBackfillPayload {
userId: string;
enqueuedAt: string;
}
/** Test seam: a slice's budget and clock. */
export interface OuraBackfillSliceOptions {
sliceMs?: number;
now?: () => number;
}
type StopReason = "budget" | "lane_wanted" | "rate_limited";
function minDay(a: string | undefined, b: string): string {
return a === undefined || b < a ? b : a;
}
/** The floor a collection walks back to, or undefined while not yet known. */
function floorFor(
name: OuraCollectionName,
progress: OuraBackfillProgress,
): string | undefined {
if (HISTORY_PROBES.includes(name)) return OURA_HISTORY_FLOOR;
if (progress.historyStart === undefined) return undefined;
// No activity and no sleep anywhere: there is no history to read.
if (progress.historyStart === null) return progress.anchor;
const margin = shiftDateKey(progress.historyStart, -HISTORY_MARGIN_DAYS);
return margin < OURA_HISTORY_FLOOR ? OURA_HISTORY_FLOOR : margin;
}
/** Settle `historyStart` once both probes are finished. */
function settleHistoryStart(progress: OuraBackfillProgress): void {
if (progress.historyStart !== undefined) return;
const finished = HISTORY_PROBES.every((n) => {
const s = progress.collections[n];
return s?.done || s?.denied;
});
if (finished) progress.historyStart = progress.oldestSeen ?? null;
}
async function saveProgress(
userId: string,
progress: OuraBackfillProgress,
): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: { ouraBackfillProgress: ouraBackfillProgressJson(progress) },
});
}
/**
* Read and write one window of one collection. Returns the oldest day a
* history probe returned, if any.
*/
async function runWindow(
userId: string,
tz: string,
name: OuraCollectionName,
window: { startDate: string; endDate: string },
token: string,
): Promise<{ oldestDay?: string; imported: number }> {
const ctx = ouraRunContext(
userId,
tz,
window,
// The heart-rate window covers the same local days, end day included.
{
start: startOfLocalDayKey(window.startDate, tz),
end: startOfLocalDayKey(shiftDateKey(window.endDate, 1), tz),
},
false,
);
const readings = await ouraCollectionReader(name, ctx)(token);
await sweepStaleSleepSegments(userId, "OURA", ctx.sleepSweeps);
await retireOuraSleepRecords(userId, ctx.sleepRetirements);
const written = await upsertOuraMeasurements(userId, readings, {
emitArrivals: false,
});
await refoldOuraTouchedDays(userId, ctx.heartRateTouched);
let oldestDay: string | undefined;
if (HISTORY_PROBES.includes(name)) {
for (const r of readings) {
oldestDay = minDay(oldestDay, userDayKey(r.measuredAt, tz));
}
// A probe that only retired periods still saw history.
if (ctx.sleepRetirements.length > 0 || ctx.sleepSweeps.length > 0) {
oldestDay = minDay(oldestDay, window.startDate);
}
}
return { oldestDay, imported: written + ctx.sideImported };
}
/**
* Run one slice of the backfill for one account. Returns how many rows it
* wrote and whether it sent the next slice.
*/
export async function runOuraBackfillForUser(
userId: string,
shouldStop: () => boolean = () => false,
slice: number = 0,
options: OuraBackfillSliceOptions = {},
): Promise<{ imported: number; continued: boolean }> {
if (slice > OURA_BACKFILL_MAX_SLICE) {
throw new Error(
`oura backfill for user ${userId} reached slice ${slice}, past the ceiling of ${OURA_BACKFILL_MAX_SLICE}`,
);
}
const now = options.now ?? Date.now;
const user = await prisma.user.findUnique({
where: { id: userId },
select: { ouraBackfillProgress: true, ouraBackfillCompletedAt: true },
});
if (!user || user.ouraBackfillCompletedAt) {
annotate({
action: { name: "oura.backfill.already_complete", details: { slice } },
});
return { imported: 0, continued: false };
}
const tz = await resolveUserTimezone(userId);
const stored = readOuraBackfillProgress(
user.ouraBackfillProgress,
OURA_BACKFILL_ORDER,
);
// The first slice of a chain (a boot's, or a connect's) asks again for
// what an earlier grant refused: the grant may cover it now. One request
// each finds out; everything already walked stays walked.
const progress: OuraBackfillProgress = stored
? slice === 0
? withoutDenied(stored)
: stored
: { anchor: userDayKey(new Date(now()), tz), collections: {} };
if (!progress.distanceRetired) {
const retired = await retireOuraEquivalentDistance(userId);
progress.distanceRetired = true;
await saveProgress(userId, progress);
annotate({ meta: { "oura.backfill.distance_retired": retired } });
}
const conn = await getOuraConnection(userId);
if (!conn) {
// Nothing to read: the account only had legacy rows to retire. A later
// connect reopens the backfill.
await prisma.user.update({
where: { id: userId },
data: { ouraBackfillCompletedAt: new Date(now()) },
});
return { imported: 0, continued: false };
}
if (await isReauthRequired(userId, "oura")) {
annotate({
action: { name: "oura.backfill.skipped_reauth", details: { slice } },
});
return { imported: 0, continued: false };
}
const deadline = now() + (options.sliceMs ?? OURA_BACKFILL_SLICE_MS);
const knownDenied = new Set(conn.scopeDenied ?? []);
let token = conn.accessToken;
let refreshed = false;
let imported = 0;
let windowsWritten = 0;
let newlySettled = 0;
let stopReason: StopReason | null = null;
let retryAfterMs: number | undefined;
let failure: unknown;
const failures: OuraCollectionName[] = [];
walk: for (const name of OURA_BACKFILL_ORDER) {
const state = progress.collections[name] ?? { before: progress.anchor };
progress.collections[name] = state;
if (state.done || state.denied) continue;
const floor = floorFor(name, progress);
// The other collections wait for the probes to say where history starts;
// the probes come first, so this is only a slice that ended in them.
if (floor === undefined) break;
while (!state.done && !state.denied) {
if (state.before <= floor) {
state.done = true;
newlySettled += 1;
settleHistoryStart(progress);
await saveProgress(userId, progress);
break;
}
if (shouldStop() || now() >= deadline) {
stopReason = "budget";
break walk;
}
if (laneWanted()) {
stopReason = "lane_wanted";
break walk;
}
const windowDays = WINDOW_DAYS[name] ?? DEFAULT_WINDOW_DAYS;
const shifted = shiftDateKey(state.before, -windowDays);
const startDate = shifted < floor ? floor : shifted;
const window = { startDate, endDate: state.before };
let result: { oldestDay?: string; imported: number };
try {
try {
result = await runWindow(userId, tz, name, window, token);
} catch (err) {
if (!isTokenRefusal(err) || refreshed) throw err;
// A refused token: refresh once for the whole slice, then retry.
refreshed = true;
let next: string | null;
try {
next = await refreshOuraAfterRefusal(
userId,
conn.refreshTokenCiphertext,
);
} catch (refreshErr) {
await recordOuraSyncFailure(userId, refreshErr);
throw refreshErr;
}
if (!next) throw err;
token = next;
result = await runWindow(userId, tz, name, window, token);
}
} catch (err) {
if (
isNamedScopeRefusal(err) ||
(isTokenRefusal(err) && knownDenied.has(name))
) {
// The grant does not cover this collection: a scope note, not a
// failure. The others go on; a reconnect reopens it.
state.denied = true;
newlySettled += 1;
settleHistoryStart(progress);
await saveProgress(userId, progress);
annotate({ meta: { "oura.backfill.denied": name } });
break;
}
if (err instanceof OuraApiError && err.httpStatus === 429) {
stopReason = "rate_limited";
retryAfterMs = err.retryAfterMs;
break walk;
}
// One collection's failure stays with it: the slice goes on with the
// next one, and the failed window is read again next slice.
failures.push(name);
failure ??= err;
getEvent()?.addWarning(
`oura backfill: ${name} ${window.startDate}..${window.endDate} failed: ${err}`,
);
continue walk;
}
imported += result.imported;
windowsWritten += 1;
if (result.oldestDay) {
progress.oldestSeen = minDay(progress.oldestSeen, result.oldestDay);
}
state.before = startDate;
state.windows = (state.windows ?? 0) + 1;
await saveProgress(userId, progress);
}
}
const settled = OURA_BACKFILL_ORDER.every((n) => {
const s = progress.collections[n];
return s?.done || s?.denied;
});
const report = {
slice,
stopReason,
failures,
windowsWritten,
imported,
edges: Object.fromEntries(
Object.entries(progress.collections).map(([n, s]) => [
n,
s!.done ? "done" : s!.denied ? "denied" : s!.before,
]),
),
historyStart: progress.historyStart ?? null,
};
annotate({ meta: { "oura.backfill.slice": report } });
reportJobProgress({
oura_backfill_slice: slice,
oura_backfill_windows: windowsWritten,
});
if (settled && stopReason === null && failures.length === 0) {
await prisma.user.update({
where: { id: userId },
data: {
ouraBackfillCompletedAt: new Date(now()),
ouraBackfillProgress: ouraBackfillProgressJson(progress),
},
});
annotate({
action: { name: "oura.backfill.complete", details: { imported, slice } },
});
return { imported, continued: false };
}
const progressed = windowsWritten > 0 || newlySettled > 0;
// A rate limit passes on its own: the next slice goes after it, progress
// or not, as Strava's backfill does. Anything else needs progress to go on.
if (progressed || stopReason === "rate_limited") {
const delaySeconds =
stopReason === "rate_limited"
? Math.max(
INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS,
retryAfterMs !== undefined
? Math.ceil(retryAfterMs / 1000)
: OURA_BACKFILL_THROTTLED_DELAY_SECONDS,
)
: stopReason === "lane_wanted"
? LANE_YIELD_DELAY_SECONDS
: INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS;
if (await sendNextSlice(userId, slice, delaySeconds)) {
annotate({
action: {
name: "oura.backfill.continued",
details: { slice, stopReason, windowsWritten, delaySeconds },
},
});
return { imported, continued: true };
}
}
// Nothing moved, or no next slice could be sent: the pg-boss retry and,
// after it, the next boot's discovery take it from here. The progress
// note stands, so whatever runs next starts where this one stopped.
throw new Error(
`oura backfill incomplete for user ${userId} (slice ${slice}${stopReason ? `, stopped on ${stopReason}` : ""}${failure ? `: ${failure instanceof Error ? failure.message : String(failure)}` : ""})`,
);
}
async function sendNextSlice(
userId: string,
slice: number,
delaySeconds: number,
): Promise<boolean> {
if (slice + 1 > OURA_BACKFILL_MAX_SLICE) return false;
const boss = getGlobalBoss();
if (!boss) return false;
const sent = await enqueueIntegrationBackfillAdmission(
boss,
{
kind: "oura-backfill",
data: { userId, enqueuedAt: new Date().toISOString(), slice: slice + 1 },
},
delaySeconds,
);
return Boolean(sent);
}
/**
* Boot-time discovery: every account with an Oura connection whose backfill
* is not stamped, and every account still holding equivalent-distance rows
* from before the fix. Idempotent across reboots; errors come back in the
* result so the worker boot never fails on a miss.
*/
export async function enqueueBootTimeOuraBackfill(
startAfterSeconds: number = 0,
): Promise<{ enqueued: number; skipped: number; error: string | null }> {
const boss = getGlobalBoss();
if (!boss) return { enqueued: 0, skipped: 0, error: null };
try {
const users = await prisma.user.findMany({
where: {
ouraBackfillCompletedAt: null,
OR: [
{ ouraAccessTokenEncrypted: { not: null } },
{ measurements: { some: OURA_EQUIVALENT_DISTANCE_WHERE } },
],
},
select: { id: true },
});
let enqueued = 0;
let skipped = 0;
for (const { id: userId } of users) {
const payload: OuraBackfillPayload = {
userId,
enqueuedAt: new Date().toISOString(),
};
const jobId = await boss.send(
OURA_BACKFILL_QUEUE,
payload,
integrationBackfillSourceOptions(
`oura-backfill|${userId}`,
startAfterSeconds,
),
);
if (jobId) enqueued += 1;
else skipped += 1;
}
return { enqueued, skipped, error: null };
} catch (err) {
return {
enqueued: 0,
skipped: 0,
error: err instanceof Error ? err.message : String(err),
};
}
}
+1
View File
@@ -130,6 +130,7 @@ export const QUEUE_RUNTIME: Readonly<Record<string, QueueRuntime>> = {
"oura-sync": cohortSync("Oura"),
"strava-sync": cohortSync("Strava"),
"strava-backfill": forwarder,
"oura-backfill": forwarder,
// ── Status and insights ────────────────────────────────────────────
"insights-general-status": {
@@ -50,6 +50,12 @@ import {
enqueueBootTimeStravaBackfill,
type StravaBackfillPayload,
} from "@/lib/jobs/strava-backfill";
import {
OURA_BACKFILL_QUEUE,
OURA_BACKFILL_CONCURRENCY,
enqueueBootTimeOuraBackfill,
type OuraBackfillPayload,
} from "@/lib/jobs/oura-backfill";
import {
SLEEP_TIMELINE_BACKFILL_QUEUE,
SLEEP_TIMELINE_BACKFILL_CONCURRENCY,
@@ -325,6 +331,8 @@ const allQueues = [
// v1.17.0 — Polar + Oura OAuth poll sync.
POLAR_SYNC_QUEUE,
OURA_SYNC_QUEUE,
// v1.44 — Oura history backfill, sliced through the shared lane.
OURA_BACKFILL_QUEUE,
// v1.28.x — Strava OAuth poll sync + self-converging boot backfill.
STRAVA_SYNC_QUEUE,
STRAVA_BACKFILL_QUEUE,
@@ -455,6 +463,11 @@ const queuePolicies: QueuePolicyTable = {
reason:
"Per-connection full-history backfill; boot-discovery driven and self-converging.",
},
[OURA_BACKFILL_QUEUE]: {
policy: "exclusive",
reason:
"Per-connection full-history backfill; boot-discovery driven and self-converging.",
},
[GOOGLE_HEALTH_SLEEP_REPAIR_QUEUE]: {
policy: "exclusive",
reason:
@@ -787,6 +800,23 @@ export async function registerIntegrationSyncQueues(
return jobDone({ jobs: jobs.length });
},
);
// v1.44 — Oura history backfill. The source queue keeps the per-account
// singleton and forwards admitted work to the shared drain, where it runs
// in resumable slices.
await createAndWork<OuraBackfillPayload>(
boss,
OURA_BACKFILL_QUEUE,
{ localConcurrency: OURA_BACKFILL_CONCURRENCY },
async (jobs) => {
for (const job of jobs) {
await enqueueIntegrationBackfillAdmission(boss, {
kind: "oura-backfill",
data: job.data,
});
}
return jobDone({ jobs: jobs.length });
},
);
return allQueues;
}
@@ -943,6 +973,29 @@ export async function enqueueIntegrationSyncBootDiscovery(): Promise<void> {
);
}
// v1.44 — Oura history backfill. Finds every Oura connection not yet
// backfilled (and every account still holding the retired
// equivalent-distance rows) and enqueues one sliced backfill per account.
try {
const { enqueued, skipped, error } = await enqueueBootTimeOuraBackfill(
bootStaggerSecondsFor("oura-backfill"),
);
if (error) {
workerLog("error", `[oura-backfill] boot discovery failed: ${error}`);
} else {
workerLog(
"info",
`[oura-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
);
}
} catch (err) {
workerLog(
"error",
"[oura-backfill] boot discovery threw an unexpected error",
err,
);
}
// v1.18.1 — one-shot lab-biomarker backfill. Finds every user holding an
// un-linked live lab reading and enqueues one job each. A completed pass
// links every reading, dropping the user from the discovery set.
+1
View File
@@ -46,6 +46,7 @@ const subsystemSurface = {
"integration-watchdog-hourly": { audience: "system" },
"integration-watchdog-daily": { audience: "system" },
"strava-backfill": { audience: "system" },
"oura-backfill": { audience: "system" },
"oidc-native-handoff-cleanup": { audience: "system" },
// register-status.ts
+105 -2
View File
@@ -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"],
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import {
ouraBackfillProgressJson,
readOuraBackfillProgress,
withoutDenied,
} from "../backfill-progress";
import { OURA_BACKFILL_ORDER } from "@/lib/jobs/oura-backfill";
describe("Oura backfill progress", () => {
it("round-trips a note through the column", () => {
const progress = {
anchor: "2026-10-01",
oldestSeen: "2024-03-02",
historyStart: "2024-03-02",
distanceRetired: true,
collections: {
activity: { before: "2015-01-01", done: true, windows: 47 },
heartrate: { before: "2025-06-10", windows: 3 },
workout: { before: "2026-10-01", denied: true },
},
};
const json = ouraBackfillProgressJson(progress);
expect(readOuraBackfillProgress(json, OURA_BACKFILL_ORDER)).toEqual(
progress,
);
});
it("keeps an empty history start apart from an unknown one", () => {
const none = readOuraBackfillProgress(
{ anchor: "2026-10-01", historyStart: null, collections: {} },
OURA_BACKFILL_ORDER,
);
expect(none?.historyStart).toBeNull();
const unknown = readOuraBackfillProgress(
{ anchor: "2026-10-01", collections: {} },
OURA_BACKFILL_ORDER,
);
expect(unknown).not.toHaveProperty("historyStart");
});
it("drops a malformed entry on its own and keeps the rest", () => {
const read = readOuraBackfillProgress(
{
anchor: "2026-10-01",
collections: {
sleep: { before: "yesterday" },
activity: { before: "2025-01-01" },
not_a_collection: { before: "2025-01-01" },
},
},
OURA_BACKFILL_ORDER,
);
expect(read?.collections).toEqual({ activity: { before: "2025-01-01" } });
});
it("reads no note at all without an anchor", () => {
expect(readOuraBackfillProgress(null, OURA_BACKFILL_ORDER)).toBeNull();
expect(
readOuraBackfillProgress({ collections: {} }, OURA_BACKFILL_ORDER),
).toBeNull();
});
it("asks again for what a grant refused, and only that", () => {
const cleared = withoutDenied({
anchor: "2026-10-01",
collections: {
workout: { before: "2026-10-01", denied: true },
activity: { before: "2015-01-01", done: true },
},
});
expect(cleared.collections).toEqual({
workout: { before: "2026-10-01" },
activity: { before: "2015-01-01", done: true },
});
});
it("walks the history probes first and heart rate last", () => {
expect(OURA_BACKFILL_ORDER.slice(0, 2)).toEqual(["activity", "sleep"]);
expect(OURA_BACKFILL_ORDER.at(-1)).toBe("heartrate");
expect(new Set(OURA_BACKFILL_ORDER).size).toBe(OURA_BACKFILL_ORDER.length);
});
});
+10 -8
View File
@@ -77,7 +77,9 @@ describe("getAuthorizationUrl", () => {
it("builds the Oura authorize URL", () => {
const url = getAuthorizationUrl("signed", CREDS);
expect(url).toContain("cloud.ouraring.com/oauth/authorize");
expect(url).toContain("scope=daily+spo2+heart_health+stress+personal");
expect(url).toContain(
"scope=daily+spo2+heart_health+stress+workout+heartrate+personal",
);
expect(url).toContain("state=signed");
});
});
@@ -471,7 +473,7 @@ describe("mapSleep", () => {
});
describe("mapDailyActivity", () => {
it("maps steps, active energy, and equivalent walking distance (m)", () => {
it("maps steps and active energy, and never the equivalent walking distance", () => {
const a: OuraDailyActivity = {
id: "1",
day: "2026-06-10",
@@ -484,12 +486,12 @@ describe("mapDailyActivity", () => {
expect(mapped.find((m) => m.type === "ACTIVE_ENERGY_BURNED")?.value).toBe(
412,
);
const dist = mapped.find((m) => m.type === "WALKING_RUNNING_DISTANCE");
expect(dist).toMatchObject({
value: 6543,
unit: "m",
fieldTag: "distance",
});
// An activity equivalent, not distance travelled: no distance row at all.
expect(mapped.map((m) => m.type).sort()).toEqual([
"ACTIVE_ENERGY_BURNED",
"ACTIVITY_STEPS",
]);
expect(mapped.some((m) => m.fieldTag === "distance")).toBe(false);
});
});
+12 -1
View File
@@ -30,6 +30,8 @@ describe("Oura scope map", () => {
"spo2",
"heart_health",
"stress",
"workout",
"heartrate",
"personal",
]);
});
@@ -40,6 +42,8 @@ describe("Oura scope map", () => {
"vo2max",
"cardio_age",
"resilience",
"workout",
"heartrate",
]);
expect(ouraScopeReconnectList(["resilience", "readiness", "spo2"])).toEqual(
["spo2", "resilience"],
@@ -65,11 +69,18 @@ describe("granted scopes", () => {
ouraCollectionsMissingFromGrant(
"extapi:daily extapi:personal extapi:spo2 extapi:heart_health",
),
).toEqual(["resilience"]);
).toEqual(["resilience", "workout", "heartrate"]);
// A grant from before workouts and heart rate were read: those two are
// the scope note, nothing else.
expect(
ouraCollectionsMissingFromGrant(
"daily personal spo2 heart_health stress",
),
).toEqual(["workout", "heartrate"]);
expect(
ouraCollectionsMissingFromGrant(
"daily personal spo2 heart_health stress workout heartrate",
),
).toEqual([]);
});
});
+69 -11
View File
@@ -21,6 +21,8 @@ const {
fetchCardioAgeMock,
fetchResilienceMock,
fetchCyclePhasesMock,
fetchWorkoutsMock,
heartRatePagesMock,
refreshMock,
createManyAndReturnMock,
updateMock,
@@ -48,6 +50,8 @@ const {
fetchCardioAgeMock: vi.fn(),
fetchResilienceMock: vi.fn(),
fetchCyclePhasesMock: vi.fn(),
fetchWorkoutsMock: vi.fn(),
heartRatePagesMock: vi.fn(),
refreshMock: vi.fn(),
createManyAndReturnMock: vi.fn(),
updateMock: vi.fn(),
@@ -70,11 +74,12 @@ const {
// these cases pin.
vi.mock("../credentials", () => ({
getOuraConnection: getConnMock,
refreshOuraAccessToken: async (
userId: string,
spentCiphertext: string,
creds: unknown,
) => {
syncOuraScopeDenied: scopeDeniedMock,
}));
vi.mock("../oauth-descriptor", () => ({
refreshOuraAfterRefusal: async (userId: string, spentCiphertext: string) => {
const creds = await getCredsMock(userId);
if (!creds) return null;
const rotated = await refreshMock("ref", creds);
return storeTokensMock(
userId,
@@ -83,8 +88,6 @@ vi.mock("../credentials", () => ({
spentCiphertext,
);
},
getOuraClientCredentials: getCredsMock,
syncOuraScopeDenied: scopeDeniedMock,
}));
vi.mock("@/lib/db", () => ({
@@ -143,6 +146,8 @@ vi.mock("../client", async (importOriginal) => {
fetchCardiovascularAge: fetchCardioAgeMock,
fetchResilience: fetchResilienceMock,
fetchDailyCyclePhases: fetchCyclePhasesMock,
fetchWorkouts: fetchWorkoutsMock,
forEachHeartRatePage: heartRatePagesMock,
refreshAccessToken: refreshMock,
};
});
@@ -159,6 +164,13 @@ import { isSyncFailureRecorded } from "@/lib/integrations/status";
const DAY_MS = 24 * 60 * 60 * 1000;
/** The day-window watermark reads (the heart-rate window's carries a type). */
function dayWatermarkReads() {
return findFirstMock.mock.calls.filter(
([args]) => !(args as { where: { type?: string } }).where.type,
);
}
const CONN = {
accessToken: "acc",
refreshTokenCiphertext: "enc(ref)",
@@ -181,6 +193,10 @@ beforeEach(() => {
fetchCardioAgeMock.mockReset().mockResolvedValue([]);
fetchResilienceMock.mockReset().mockResolvedValue([]);
fetchCyclePhasesMock.mockReset().mockResolvedValue([]);
fetchWorkoutsMock.mockReset().mockResolvedValue([]);
heartRatePagesMock
.mockReset()
.mockResolvedValue({ pages: 1, truncated: false });
refreshMock.mockReset();
scopeDeniedMock.mockReset().mockResolvedValue(undefined);
transactionMock
@@ -903,9 +919,10 @@ describe("syncUserOura — window resolution", () => {
await syncUserOura("u1");
expect(windowDays()).toBe(7);
// One indexed read per tick, and tombstones are excluded from it — a
// soft-deleted row must not pass for a live one.
expect(findFirstMock).toHaveBeenCalledTimes(1);
// One indexed read per tick for the day window (the heart-rate window
// reads its own, typed watermark), and tombstones are excluded from it —
// a soft-deleted row must not pass for a live one.
expect(dayWatermarkReads()).toHaveLength(1);
expect(findFirstMock).toHaveBeenCalledWith({
where: { userId: "u1", source: "OURA", deletedAt: null },
orderBy: { measuredAt: "desc" },
@@ -959,7 +976,7 @@ describe("syncUserOura — window resolution", () => {
await syncUserOura("u1", { lookbackDays: 3 });
expect(windowDays()).toBe(3);
expect(findFirstMock).not.toHaveBeenCalled();
expect(dayWatermarkReads()).toHaveLength(0);
});
it("passes the resolved window to the cycle-phases leg too", async () => {
@@ -1114,6 +1131,8 @@ describe("syncUserOura — a grant without the spo2 / heart_health scopes", () =
fetchVo2MaxMock,
fetchCardioAgeMock,
fetchResilienceMock,
fetchWorkoutsMock,
heartRatePagesMock,
]) {
m.mockRejectedValue(err401());
}
@@ -1287,3 +1306,42 @@ describe("syncUserOura — Oura names the missing scope", () => {
);
});
});
describe("syncUserOura — a grant from before workouts and heart rate were read", () => {
it("notes the two collections calmly: no refresh, no failure, everything else imports", async () => {
getConnMock.mockResolvedValue(CONN);
fetchActivityMock.mockResolvedValue([
{ id: "a1", day: "2026-06-10", steps: 1200, active_calories: 80 },
]);
fetchWorkoutsMock.mockRejectedValue(errScope401("workout"));
heartRatePagesMock.mockRejectedValue(errScope401("heartrate"));
const result = await syncUserOura("u1");
expect(result.failed).toBe(false);
expect(result.imported).toBeGreaterThan(0);
expect(refreshMock).not.toHaveBeenCalled();
expect(recordFailureMock).not.toHaveBeenCalled();
expect(recordSuccessMock).toHaveBeenCalledWith("u1", "oura");
expect(scopeDeniedMock).toHaveBeenCalledWith(
"u1",
expect.arrayContaining(["workout", "heartrate"]),
);
expect(scopeDeniedMock.mock.calls[0]![1]).toHaveLength(2);
});
it("reads heart rate over a window of hours, not the day window", async () => {
getConnMock.mockResolvedValue(CONN);
findFirstMock.mockResolvedValue(null);
await syncUserOura("u1");
const range = heartRatePagesMock.mock.calls[0]![1] as {
start: Date;
end: Date;
};
expect(range.end.getTime() - range.start.getTime()).toBeLessThanOrEqual(
72 * 60 * 60 * 1000,
);
});
});
@@ -0,0 +1,298 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { createManyAndReturnMock, findFirstMock, updateMock, safeFetchMock } =
vi.hoisted(() => ({
createManyAndReturnMock: vi.fn(),
findFirstMock: vi.fn(),
updateMock: vi.fn(),
safeFetchMock: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
createManyAndReturn: createManyAndReturnMock,
findFirst: findFirstMock,
},
workout: {
createManyAndReturn: createManyAndReturnMock,
update: updateMock,
},
},
}));
vi.mock("@/lib/safe-fetch", () => ({ safeFetch: safeFetchMock }));
vi.mock("@/lib/arrivals/measurement-emit", () => ({
emitInsertedMeasurementArrivals: vi.fn(async () => {}),
}));
vi.mock("@/lib/arrivals/workout-emit", () => ({
emitInsertedWorkoutArrival: vi.fn(async () => {}),
}));
vi.mock("@/lib/rollups/measurement-rollups", async (importOriginal) => ({
...(await importOriginal<
typeof import("@/lib/rollups/measurement-rollups")
>()),
recomputeBucketsForKeys: vi.fn(async () => {}),
}));
vi.mock("@/lib/insights/comprehensive-generate", () => ({
invalidateStatusInsightsForTypes: vi.fn(async () => {}),
}));
import { mapHeartRateSample } from "../client";
import { OuraApiError } from "../response-classifier";
import { mapOuraSportType } from "../sport-map";
import {
OURA_HEART_RATE_LOOKBACK_HOURS,
OURA_HEART_RATE_MAX_LOOKBACK_HOURS,
OURA_HEART_RATE_WRITE_CHUNK,
resolveOuraHeartRateWindow,
syncOuraHeartRateRange,
} from "../sync-heart-rate";
import { mapOuraWorkout, upsertOuraWorkouts } from "../sync-workouts";
const HOUR = 60 * 60 * 1000;
function jsonResponse(body: unknown, status = 200, headers = {}) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json", ...headers },
});
}
beforeEach(() => {
createManyAndReturnMock.mockReset();
findFirstMock.mockReset();
updateMock.mockReset();
safeFetchMock.mockReset();
});
describe("mapOuraSportType", () => {
it("maps Oura's labels onto the canonical buckets", () => {
expect(mapOuraSportType("walking")).toBe("walking");
expect(mapOuraSportType("strength_training")).toBe("strength");
expect(mapOuraSportType("Strength Training")).toBe("strength");
expect(mapOuraSportType("cycling")).toBe("cycling");
expect(mapOuraSportType("kayaking")).toBe("rowing");
expect(mapOuraSportType("cross_country_skiing")).toBe("other");
});
it("lands an unknown or blank label on other", () => {
expect(mapOuraSportType("underwater_hockey")).toBe("other");
expect(mapOuraSportType("")).toBe("other");
expect(mapOuraSportType(null)).toBe("other");
});
});
describe("mapOuraWorkout", () => {
const base = {
id: "w-1",
activity: "cycling",
calories: 412.6,
distance: 18250.4,
intensity: "moderate",
source: "autodetected",
label: "Commute with a friend",
start_datetime: "2026-10-01T07:00:00+02:00",
end_datetime: "2026-10-01T07:45:30+02:00",
};
it("maps times, energy, real distance and provenance", () => {
expect(mapOuraWorkout(base)).toEqual({
externalId: "w-1",
sportType: "cycling",
startedAt: new Date("2026-10-01T05:00:00Z"),
endedAt: new Date("2026-10-01T05:45:30Z"),
durationSec: 2730,
totalEnergyKcal: 413,
totalDistanceM: 18250.4,
metadata: {
ouraActivity: "cycling",
ouraIntensity: "moderate",
ouraSource: "autodetected",
},
});
});
it("never carries the person's free-text label", () => {
expect(JSON.stringify(mapOuraWorkout(base))).not.toContain("Commute");
});
it("refuses a record without an id or with an end before its start", () => {
expect(mapOuraWorkout({ ...base, id: "" })).toBeNull();
expect(
mapOuraWorkout({ ...base, end_datetime: "2026-10-01T06:00:00+02:00" }),
).toBeNull();
expect(mapOuraWorkout({ ...base, start_datetime: "nope" })).toBeNull();
});
it("keeps a missing energy or distance null rather than zero", () => {
const row = mapOuraWorkout({ ...base, calories: null, distance: -1 });
expect(row?.totalEnergyKcal).toBeNull();
expect(row?.totalDistanceM).toBeNull();
});
});
describe("upsertOuraWorkouts", () => {
it("inserts a new workout and overwrites an existing one in place", async () => {
const row = mapOuraWorkout({
id: "w-1",
activity: "running",
start_datetime: "2026-10-01T07:00:00Z",
end_datetime: "2026-10-01T07:30:00Z",
})!;
createManyAndReturnMock
.mockResolvedValueOnce([{ id: "x", startedAt: row.startedAt }])
.mockResolvedValueOnce([]);
expect(await upsertOuraWorkouts("u1", [row])).toBe(1);
expect(createManyAndReturnMock.mock.calls[0]![0].data).toMatchObject({
userId: "u1",
source: "OURA",
externalId: "w-1",
sportType: "running",
});
expect(updateMock).not.toHaveBeenCalled();
expect(await upsertOuraWorkouts("u1", [row])).toBe(1);
expect(updateMock).toHaveBeenCalledWith(
expect.objectContaining({
where: {
userId_source_externalId: {
userId: "u1",
source: "OURA",
externalId: "w-1",
},
},
}),
);
});
});
describe("mapHeartRateSample", () => {
it("maps a sample to a PULSE row keyed on its instant", () => {
expect(
mapHeartRateSample({
bpm: 61.4,
source: "awake",
timestamp: "2026-10-01T10:05:00+00:00",
}),
).toEqual({
type: "PULSE",
value: 61,
unit: "bpm",
measuredAt: new Date("2026-10-01T10:05:00Z"),
fieldTag: "hr",
externalId: "heartrate:2026-10-01T10:05:00.000Z",
});
});
it("drops a sample without an instant or outside a human band", () => {
expect(mapHeartRateSample({ bpm: 60, timestamp: null })).toBeNull();
expect(mapHeartRateSample({ bpm: 60, timestamp: "x" })).toBeNull();
expect(
mapHeartRateSample({ bpm: 0, timestamp: "2026-10-01T10:05:00Z" }),
).toBeNull();
expect(
mapHeartRateSample({ bpm: 400, timestamp: "2026-10-01T10:05:00Z" }),
).toBeNull();
});
});
describe("syncOuraHeartRateRange", () => {
it("writes every page as it arrives, in bounded batches, following next_token", async () => {
const start = new Date("2026-10-01T00:00:00Z");
const page = (offset: number, n: number) =>
Array.from({ length: n }, (_, i) => ({
bpm: 60,
source: "awake",
timestamp: new Date(
start.getTime() + (offset + i) * 60_000,
).toISOString(),
}));
safeFetchMock
.mockResolvedValueOnce(
jsonResponse({ data: page(0, 1200), next_token: "p2" }),
)
.mockResolvedValueOnce(
jsonResponse({ data: page(1200, 10), next_token: null }),
);
createManyAndReturnMock.mockImplementation(
async ({ data }: { data: Array<{ measuredAt: Date }> }) =>
data.map((d, i) => ({ id: `r${i}`, type: "PULSE", ...d })),
);
const result = await syncOuraHeartRateRange("u1", "tok", {
start,
end: new Date(start.getTime() + 2 * 24 * HOUR),
});
expect(result).toEqual({ imported: 1210, truncated: false });
// 1 200 samples in chunks of 500, then the second page.
expect(
createManyAndReturnMock.mock.calls.map(([a]) => a.data.length),
).toEqual([
OURA_HEART_RATE_WRITE_CHUNK,
OURA_HEART_RATE_WRITE_CHUNK,
200,
10,
]);
expect(createManyAndReturnMock.mock.calls[0]![0].skipDuplicates).toBe(true);
const firstUrl = new URL(safeFetchMock.mock.calls[0]![0] as string);
expect(firstUrl.pathname).toBe("/v2/usercollection/heartrate");
expect(firstUrl.searchParams.get("start_datetime")).toBe(
start.toISOString(),
);
const secondUrl = new URL(safeFetchMock.mock.calls[1]![0] as string);
expect(secondUrl.searchParams.get("next_token")).toBe("p2");
});
it("carries Retry-After on a 429", async () => {
safeFetchMock.mockResolvedValueOnce(
jsonResponse({ detail: "slow down" }, 429, { "retry-after": "120" }),
);
const err = await syncOuraHeartRateRange("u1", "tok", {
start: new Date("2026-10-01T00:00:00Z"),
end: new Date("2026-10-02T00:00:00Z"),
}).catch((e: unknown) => e);
expect(err).toBeInstanceOf(OuraApiError);
expect((err as OuraApiError).retryAfterMs).toBe(120_000);
expect((err as OuraApiError).classification).toBe("transient");
});
});
describe("resolveOuraHeartRateWindow", () => {
const now = new Date("2026-10-21T12:00:00Z");
it("reads the last few hours while readings are flowing", async () => {
findFirstMock.mockResolvedValue({
measuredAt: new Date(now.getTime() - 20 * 60_000),
});
const w = await resolveOuraHeartRateWindow("u1", now);
expect(now.getTime() - w.start.getTime()).toBe(
OURA_HEART_RATE_LOOKBACK_HOURS * HOUR,
);
expect(w.end).toEqual(now);
});
it("reaches back to the newest reading after a gap, up to the ceiling", async () => {
findFirstMock.mockResolvedValueOnce({
measuredAt: new Date(now.getTime() - 30 * HOUR),
});
let w = await resolveOuraHeartRateWindow("u1", now);
expect(now.getTime() - w.start.getTime()).toBe(31 * HOUR);
findFirstMock.mockResolvedValueOnce({
measuredAt: new Date(now.getTime() - 30 * 24 * HOUR),
});
w = await resolveOuraHeartRateWindow("u1", now);
expect(now.getTime() - w.start.getTime()).toBe(
OURA_HEART_RATE_MAX_LOOKBACK_HOURS * HOUR,
);
});
it("leaves history to the backfill on a fresh connection", async () => {
findFirstMock.mockResolvedValue(null);
const w = await resolveOuraHeartRateWindow("u1", now);
expect(now.getTime() - w.start.getTime()).toBe(
OURA_HEART_RATE_LOOKBACK_HOURS * HOUR,
);
});
});
+141
View File
@@ -0,0 +1,141 @@
/**
* The `ouraBackfillProgress` column: how far the Oura history backfill has
* got, carried from one slice to the next.
*
* Each collection is walked from the day the backfill began (`anchor`) back
* toward the start of the account's history, one window at a time. After a
* window is written its start becomes the collection's `before`, and the next
* window ends there, so a slice that stops (its budget, a rate limit, a failed
* window) loses at most the window it was in, and the next slice goes on from
* the last one written instead of from the newest day. A collection that
* reached the start of history is `done`; one the grant does not cover is
* `denied` and is asked for again by the first slice of the next chain.
*
* Where history starts is learned, not assumed: `activity` and `sleep` are
* walked first, back to `OURA_HISTORY_FLOOR`, and the oldest day either
* returned (`oldestSeen`) becomes `historyStart`. Every other collection stops
* a week before it, so an account with two years of history costs two years of
* requests, not eleven.
*
* The column is JSON the code wrote, read leniently: a malformed entry is
* dropped on its own and that collection starts over from the anchor, which
* is safe because every write is keyed.
*/
import { z } from "zod";
import type { OuraCollectionName } from "./scope";
/** The oldest day any Oura ring could have recorded (the first ring shipped in 2015). */
export const OURA_HISTORY_FLOOR = "2015-01-01";
export interface OuraBackfillCollectionState {
/** Upper bound (YYYY-MM-DD) of the next window to read. */
before: string;
done?: boolean;
denied?: boolean;
/** Windows written across every slice, for the record only. */
windows?: number;
}
export interface OuraBackfillProgress {
anchor: string;
/** Oldest day `activity` or `sleep` returned so far. */
oldestSeen?: string;
/**
* Where the account's history starts, once `activity` and `sleep` are both
* walked to the floor; `null` when they found nothing at all.
*/
historyStart?: string | null;
/** The legacy equivalent-distance rows are retired. */
distanceRetired?: boolean;
collections: Partial<Record<OuraCollectionName, OuraBackfillCollectionState>>;
}
const day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const stateSchema = z.object({
before: day,
done: z.boolean().optional().catch(undefined),
denied: z.boolean().optional().catch(undefined),
windows: z.number().int().min(0).optional().catch(undefined),
});
/** Read the column; null when there is no usable progress yet. */
export function readOuraBackfillProgress(
value: unknown,
known: readonly OuraCollectionName[],
): OuraBackfillProgress | null {
if (value === null || typeof value !== "object") return null;
const raw = value as Record<string, unknown>;
const anchor = day.safeParse(raw.anchor);
if (!anchor.success) return null;
const collections: OuraBackfillProgress["collections"] = {};
const rawCollections =
raw.collections && typeof raw.collections === "object"
? (raw.collections as Record<string, unknown>)
: {};
for (const name of known) {
const parsed = stateSchema.safeParse(rawCollections[name]);
if (parsed.success) {
const s = parsed.data;
collections[name] = {
before: s.before,
...(s.done ? { done: true } : {}),
...(s.denied ? { denied: true } : {}),
...(s.windows !== undefined ? { windows: s.windows } : {}),
};
}
}
const oldestSeen = day.safeParse(raw.oldestSeen);
const historyStart =
raw.historyStart === null ? null : day.safeParse(raw.historyStart);
return {
anchor: anchor.data,
...(oldestSeen.success ? { oldestSeen: oldestSeen.data } : {}),
...(historyStart === null
? { historyStart: null }
: historyStart.success
? { historyStart: historyStart.data }
: {}),
...(raw.distanceRetired === true ? { distanceRetired: true } : {}),
collections,
};
}
/** The column's value for a progress note. */
export function ouraBackfillProgressJson(progress: OuraBackfillProgress) {
return {
anchor: progress.anchor,
...(progress.oldestSeen ? { oldestSeen: progress.oldestSeen } : {}),
...(progress.historyStart !== undefined
? { historyStart: progress.historyStart }
: {}),
...(progress.distanceRetired ? { distanceRetired: true } : {}),
collections: Object.fromEntries(
Object.entries(progress.collections).map(([name, s]) => [
name,
{
before: s!.before,
...(s!.done ? { done: true } : {}),
...(s!.denied ? { denied: true } : {}),
...(s!.windows !== undefined ? { windows: s!.windows } : {}),
},
]),
),
};
}
/** The progress with every `denied` mark cleared, for a fresh grant. */
export function withoutDenied(
progress: OuraBackfillProgress,
): OuraBackfillProgress {
return {
...progress,
collections: Object.fromEntries(
Object.entries(progress.collections).map(([name, s]) => {
const { denied: _denied, ...rest } = s!;
return [name, rest];
}),
),
};
}
+201 -44
View File
@@ -23,6 +23,7 @@ import { OuraApiError, classifyOuraResponse } from "./response-classifier";
import { envOr, envValue } from "@/lib/env";
import { importValue } from "@/lib/integrations/import-value";
import { walkPages } from "@/lib/integrations/page-walk";
import { retryAfterMsOf } from "@/lib/integrations/oauth-kit/retry-after";
import { OURA_OAUTH_SCOPE, parseOuraMissingScope } from "./scope";
const OURA_API_BASE = "https://api.ouraring.com";
@@ -449,7 +450,11 @@ export interface OuraDailyActivity {
steps?: number | null;
active_calories?: number | null;
total_calories?: number | null;
/** Equivalent walking distance for the day, metres. */
/**
* Oura's activity-equivalent distance, metres: what a walk of the day's
* effort would have covered. Not distance travelled; never imported (see
* `mapDailyActivity`).
*/
equivalent_walking_distance?: number | null;
}
@@ -459,6 +464,13 @@ interface DateRangeQuery {
maxPages?: number;
}
/** A time-range read (`heartrate`): instants, not calendar dates. */
export interface DateTimeRangeQuery {
start: Date;
end: Date;
maxPages?: number;
}
/**
* The error for a refused collection read. Oura's status alone does not say
* what went wrong; its body does (see `./scope`):
@@ -469,13 +481,16 @@ interface DateRangeQuery {
* - any other 401: the token is dead or expired (refresh, then reconnect).
* - 403: a lapsed Oura membership, per the API reference. A reconnect does
* not fix that, so it is a persistent failure rather than a reauth.
* - 429 (and 503): transient, with the `Retry-After` delay on
* `retryAfterMs` so a backfill can wait it out instead of guessing.
*/
function collectionRefusal(
verb: string,
status: number,
res: Pick<Response, "status" | "headers">,
verdict: ReturnType<typeof classifyOuraResponse>,
body: unknown,
): OuraApiError {
const status = res.status;
if (status === 401) {
const missingScope = parseOuraMissingScope(body);
if (missingScope) {
@@ -501,9 +516,43 @@ function collectionRefusal(
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
...(status === 429 || status === 503
? { retryAfterMs: retryAfterMsOf(res) }
: {}),
});
}
/** Read one page of a collection; throws the classified refusal. */
async function readCollectionPage<T>(
path: string,
accessToken: string,
verb: string,
params: URLSearchParams,
page: number,
): Promise<{ items: T[]; next: string | null }> {
const pageStart = performance.now();
const res = await safeFetch(`${OURA_API_BASE}${path}?${params}`, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
});
const json = (await res.json().catch(() => null)) as OuraCollection<T> | null;
const verdict = classifyOuraResponse(res.status);
getEvent()?.addExternalCall({
service: "oura",
method: `${verb}(page=${page})`,
duration_ms: Math.round(performance.now() - pageStart),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw collectionRefusal(verb, res, verdict, json);
}
return { items: json?.data ?? [], next: json?.next_token ?? null };
}
async function fetchCollection<T>(
path: string,
accessToken: string,
@@ -513,42 +562,61 @@ async function fetchCollection<T>(
const walk = await walkPages<string, T>(
`oura.${verb}`,
query.maxPages ?? 100,
async (nextToken, page) => {
(nextToken, page) => {
const params = new URLSearchParams({
start_date: query.startDate,
end_date: query.endDate,
});
if (nextToken) params.set("next_token", nextToken);
const pageStart = performance.now();
const res = await safeFetch(`${OURA_API_BASE}${path}?${params}`, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
});
const json = (await res
.json()
.catch(() => null)) as OuraCollection<T> | null;
const verdict = classifyOuraResponse(res.status);
getEvent()?.addExternalCall({
service: "oura",
method: `${verb}(page=${page})`,
duration_ms: Math.round(performance.now() - pageStart),
status: res.status,
error:
verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw collectionRefusal(verb, res.status, verdict, json);
}
return { items: json?.data ?? [], next: json?.next_token ?? null };
return readCollectionPage<T>(path, accessToken, verb, params, page);
},
);
return walk.items;
}
/** What a streamed walk came to. */
export interface OuraStreamedWalk {
pages: number;
/** Stopped at the page ceiling or on a repeated cursor with pages left. */
truncated: boolean;
}
/**
* Walk a time-range collection page by page, handing each page to `onPage`
* before the next is read. Nothing is collected: the walk keeps one page at a
* time, so a dense stream (heart rate, a sample every few minutes for years)
* costs the memory of a page, not of the history.
*/
async function streamCollection<T>(
path: string,
accessToken: string,
verb: string,
query: DateTimeRangeQuery,
onPage: (items: T[]) => Promise<void>,
): Promise<OuraStreamedWalk> {
const walk = await walkPages<string, never>(
`oura.${verb}`,
query.maxPages ?? 200,
async (nextToken, page) => {
const params = new URLSearchParams({
start_datetime: query.start.toISOString(),
end_datetime: query.end.toISOString(),
});
if (nextToken) params.set("next_token", nextToken);
const { items, next } = await readCollectionPage<T>(
path,
accessToken,
verb,
params,
page,
);
await onPage(items);
return { items: [], next };
},
);
return { pages: walk.pages, truncated: walk.truncated };
}
export function fetchSleep(
accessToken: string,
query: DateRangeQuery,
@@ -665,6 +733,68 @@ export function fetchDailyCyclePhases(
);
}
/**
* Oura `workout` — one activity session, auto-detected or entered. `activity`
* is Oura's own lowercase label (`walking`, `cycling`, `strength_training`,
* ...); `distance` is metres actually covered (a GPS or step-derived figure,
* unlike the daily activity-equivalent distance); `calories` is kcal.
*/
export interface OuraWorkout {
id: string;
day?: string;
activity?: string | null;
calories?: number | null;
distance?: number | null;
start_datetime: string;
end_datetime: string;
/** `easy` | `moderate` | `hard` */
intensity?: string | null;
/** `manual` | `autodetected` | `confirmed` | `workout_heart_rate` */
source?: string | null;
/** A name the person gave the workout. Free text; never imported. */
label?: string | null;
}
export function fetchWorkouts(
accessToken: string,
query: DateRangeQuery,
): Promise<OuraWorkout[]> {
return fetchCollection<OuraWorkout>(
"/v2/usercollection/workout",
accessToken,
"fetchWorkouts",
query,
);
}
/** One `heartrate` sample: a reading at an instant, tagged with its context. */
export interface OuraHeartRateSample {
bpm?: number | null;
/** `awake` | `rest` | `sleep` | `session` | `live` | `workout` */
source?: string | null;
timestamp?: string | null;
}
/**
* Stream the `heartrate` collection for a time range, one page at a time.
* Oura answers it by instant (`start_datetime` / `end_datetime`) and pages
* with `next_token`; a page goes to `onPage` and is dropped before the next
* is read.
*/
export function forEachHeartRatePage(
accessToken: string,
query: DateTimeRangeQuery,
onPage: (samples: OuraHeartRateSample[]) => Promise<void>,
): Promise<OuraStreamedWalk> {
return streamCollection<OuraHeartRateSample>(
"/v2/usercollection/heartrate",
accessToken,
"fetchHeartRate",
query,
onPage,
);
}
// ─── Field → Measurement mapping ───────────────────────────────
const SEC_TO_MIN = 1 / 60;
@@ -972,8 +1102,17 @@ export function mapDailySpo2(
];
}
/** Map one Oura daily-activity record: steps, active energy, and the
* equivalent walking distance (B5 — the OURA distance ladder slot was dead). */
/**
* Map one Oura daily-activity record: steps and active energy.
*
* `equivalent_walking_distance` is deliberately not mapped. Oura derives it
* from the day's whole activity (the distance a walk of the same effort would
* have covered), so an hour of cycling or yoga adds kilometres nobody walked.
* Stored as `WALKING_RUNNING_DISTANCE` it read as real distance, and on a day
* no other source covered it became the day's distance. The rows earlier
* syncs wrote under `activity:<day>:distance` are retired on their own
* (`./retire-distance`).
*/
export function mapDailyActivity(
a: OuraDailyActivity,
tz?: string,
@@ -999,20 +1138,6 @@ export function mapDailyActivity(
fieldTag: "active_energy",
});
}
if (
typeof a.equivalent_walking_distance === "number" &&
a.equivalent_walking_distance >= 0
) {
out.push({
type: "WALKING_RUNNING_DISTANCE",
// Oura reports the equivalent walking distance in metres — the canonical
// WALKING_RUNNING_DISTANCE unit (no conversion).
value: importValue(a.equivalent_walking_distance),
unit: "m",
measuredAt,
fieldTag: "distance",
});
}
return out;
}
@@ -1106,6 +1231,38 @@ export function mapResilience(
];
}
/** Heart-rate readings outside this band are not a ring's reading. */
const OURA_BPM_MIN = 20;
const OURA_BPM_MAX = 250;
/**
* Map one `heartrate` sample → `PULSE` (bpm), the intraday heart-rate type
* Google Health and Fitbit write their minute stream to. Keyed on the
* sample's own instant (`heartrate:<ISO>`): Oura emits one reading per
* instant, and a sample is final once it is in the cloud, so a re-read lands
* on the same key. A sample without a parseable instant or with a bpm
* outside a human band maps to nothing.
*/
export function mapHeartRateSample(
sample: OuraHeartRateSample,
): MappedMeasurement | null {
const bpm = sample.bpm;
if (typeof bpm !== "number" || !Number.isFinite(bpm)) return null;
if (bpm < OURA_BPM_MIN || bpm > OURA_BPM_MAX) return null;
if (typeof sample.timestamp !== "string") return null;
const at = new Date(sample.timestamp);
if (Number.isNaN(at.getTime())) return null;
const iso = at.toISOString();
return {
type: "PULSE",
value: Math.round(bpm),
unit: "bpm",
measuredAt: at,
fieldTag: "hr",
externalId: `heartrate:${iso}`,
};
}
// ─── Cycle-phase → CycleDayLog mapping (v1.29.x) ───────────────────
//
// `daily_cycle_phases` does NOT feed `Measurement` like every collection
+2 -80
View File
@@ -13,15 +13,7 @@
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import { decrypt, encrypt } from "@/lib/crypto";
import {
acquireProviderTokenRefreshLock,
PROVIDER_REFRESH_TRANSACTION_OPTIONS,
} from "@/lib/integrations/oauth-refresh";
import {
getOuraCredentials,
refreshAccessToken,
type OuraCredentials,
} from "./client";
import { getOuraCredentials, type OuraCredentials } from "./client";
/**
* Resolve the user's Oura OAuth client id/secret, DB-first then env.
@@ -87,7 +79,7 @@ export interface OuraConnection {
accessToken: string;
/**
* The exact stored refresh-token ciphertext (NOT the decrypted value).
* `refreshOuraAccessToken` compares against it under the refresh lock to
* `refreshOuraAfterRefusal` compares against it under the refresh lock to
* tell whether a peer already rotated the token this sync started with.
*/
refreshTokenCiphertext: string;
@@ -116,76 +108,6 @@ export async function getOuraConnection(
};
}
/**
* Refresh the user's Oura access token once, however many syncs ask at the
* same moment.
*
* Oura's refresh tokens are single-use, and spending one twice is what an
* authorization server treats as theft: the second request is refused and the
* grant behind it may be revoked, parking a healthy connection at reconnect.
* The refresh is reactive on a 401, so an hourly tick, a manual sync and the
* first sync after a reconnect can all reach it together. A per-user advisory
* lock lets one of them refresh; each later one re-reads the stored refresh
* ciphertext under the lock and, finding it changed from the one its own sync
* started with, takes the access token stored beside it instead of refreshing
* again. The write is still conditional on the ciphertext the refresh spent,
* because a reconnect stores a new grant without taking the lock: a refresh of
* the old grant finishing after it must not put the old grant back.
*
* Returns the access token to retry with, or null when the connection is gone.
* A refused refresh throws for the caller to classify.
*/
export async function refreshOuraAccessToken(
userId: string,
spentRefreshCiphertext: string,
creds: OuraCredentials,
): Promise<string | null> {
return prisma.$transaction(async (tx) => {
await acquireProviderTokenRefreshLock(tx, "oura", userId);
const current = await tx.user.findUnique({
where: { id: userId },
select: {
ouraAccessTokenEncrypted: true,
ouraRefreshTokenEncrypted: true,
},
});
if (
!current?.ouraAccessTokenEncrypted ||
!current.ouraRefreshTokenEncrypted
) {
return null;
}
if (current.ouraRefreshTokenEncrypted !== spentRefreshCiphertext) {
// Rotated by a peer, or replaced by a reconnect, while this sync ran.
return decrypt(current.ouraAccessTokenEncrypted);
}
const rotated = await refreshAccessToken(
decrypt(current.ouraRefreshTokenEncrypted),
creds,
);
const { count } = await tx.user.updateMany({
where: {
id: userId,
ouraRefreshTokenEncrypted: spentRefreshCiphertext,
},
data: {
ouraAccessTokenEncrypted: encrypt(rotated.access_token),
ouraRefreshTokenEncrypted: encrypt(rotated.refresh_token),
},
});
if (count > 0) return rotated.access_token;
// A reconnect stored a new grant while Oura answered; that one wins.
const fresh = await tx.user.findUnique({
where: { id: userId },
select: { ouraAccessTokenEncrypted: true },
});
return fresh?.ouraAccessTokenEncrypted
? decrypt(fresh.ouraAccessTokenEncrypted)
: null;
}, PROVIDER_REFRESH_TRANSACTION_OPTIONS);
}
/**
* Collections the current Oura grant does not cover, by collection name. Read
* by the settings envelope; an absent or malformed value reads as none.
+66
View File
@@ -0,0 +1,66 @@
/**
* Where an Oura grant lives and how its token endpoint is called, for the
* shared token lifecycle (`src/lib/integrations/oauth-kit/token.ts`).
*
* Oura's tokens sit on `User`, with no expiry column: the sync refreshes after
* a refused read, and Oura rotates the refresh token on every use.
*/
import {
obtainAccessToken,
type OAuthTokenDescriptor,
} from "@/lib/integrations/oauth-kit/token";
import { refreshAccessToken, type OuraCredentials } from "./client";
import { getOuraClientCredentials } from "./credentials";
export const ouraTokenDescriptor: OAuthTokenDescriptor<OuraCredentials> = {
provider: "oura",
tracksExpiry: false,
expiryBufferMs: 0,
async readGrant(db, userId) {
const row = await db.user.findUnique({
where: { id: userId },
select: {
ouraAccessTokenEncrypted: true,
ouraRefreshTokenEncrypted: true,
},
});
if (!row?.ouraAccessTokenEncrypted || !row.ouraRefreshTokenEncrypted) {
return null;
}
return {
accessCiphertext: row.ouraAccessTokenEncrypted,
refreshCiphertext: row.ouraRefreshTokenEncrypted,
expiresAt: null,
};
},
async writeGrant(db, userId, spent, next) {
const { count } = await db.user.updateMany({
where: { id: userId, ouraRefreshTokenEncrypted: spent },
data: {
ouraAccessTokenEncrypted: next.accessCiphertext,
ouraRefreshTokenEncrypted: next.refreshCiphertext,
},
});
return count;
},
loadCredentials: (userId) => getOuraClientCredentials(userId),
refresh: (refreshToken, credentials) =>
refreshAccessToken(refreshToken, credentials),
};
/**
* Refresh after Oura refused the token read with `spentRefreshCiphertext`,
* once however many syncs ask at the same moment. Returns the access token to
* retry with, or null when there is nothing to retry with (no connection, or
* no OAuth client to refresh as). A refused refresh throws for the caller to
* classify.
*/
export async function refreshOuraAfterRefusal(
userId: string,
spentRefreshCiphertext: string,
): Promise<string | null> {
const result = await obtainAccessToken(ouraTokenDescriptor, userId, {
afterRefusalOf: spentRefreshCiphertext,
});
return result.kind === "token" ? result.accessToken : null;
}
+9 -1
View File
@@ -36,6 +36,12 @@ export class OuraApiError extends IntegrationApiError {
* scope."}`). Undefined for every other failure, a dead token included.
*/
readonly missingScope: string | undefined;
/**
* How long Oura asked to be left alone, from the `Retry-After` of a 429 (or
* 503). Undefined when the refusal carried none or was not a rate limit.
* A backfill waits at least this long before its next slice.
*/
readonly retryAfterMs: number | undefined;
constructor(opts: {
verb: string;
@@ -44,8 +50,9 @@ export class OuraApiError extends IntegrationApiError {
reason: string;
upstreamError?: string;
missingScope?: string;
retryAfterMs?: number;
}) {
const { missingScope, ...rest } = opts;
const { missingScope, retryAfterMs, ...rest } = opts;
super({
vendor: "Oura",
...rest,
@@ -55,6 +62,7 @@ export class OuraApiError extends IntegrationApiError {
});
this.name = "OuraApiError";
this.missingScope = missingScope;
this.retryAfterMs = retryAfterMs;
}
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Retire the `WALKING_RUNNING_DISTANCE` rows earlier Oura syncs wrote from
* `equivalent_walking_distance`.
*
* That field is an activity equivalent (the distance a walk of the day's
* effort would have covered), not distance travelled, so the rows were wrong
* from the start and the sync no longer writes them (`mapDailyActivity`).
* What is already stored is soft-deleted here, in bounded batches, and the
* touched days are refolded through the shared mutation tail so the rollups
* stop counting them, the same way `retireOuraSleepRecords` retires a
* rejected sleep period.
*
* Only rows the activity mapper keyed (`activity:<day>:distance`) of source
* OURA match; another source's distance, and any OURA row of another shape,
* stay. Idempotent: a second run finds nothing live and returns 0. The Oura
* backfill runs it before anything else on every connected account, and the
* backfill's discovery also offers an account that still holds such rows
* after its Oura connection was removed.
*/
import { prisma } from "@/lib/db";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { annotate } from "@/lib/logging/context";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import type { Prisma } from "@/generated/prisma/client";
/** Rows per batch: one bounded read, one update, one refold. */
export const OURA_DISTANCE_RETIRE_BATCH = 500;
/** The rows the activity mapper wrote from the equivalent distance. */
export const OURA_EQUIVALENT_DISTANCE_WHERE = {
source: "OURA",
type: "WALKING_RUNNING_DISTANCE",
deletedAt: null,
externalId: { startsWith: "activity:", endsWith: ":distance" },
} as const satisfies Prisma.MeasurementWhereInput;
/**
* Soft-delete every live equivalent-distance row of one account. Returns how
* many rows were retired. Throws on a database failure: the caller decides
* whether that holds its own progress back.
*/
export async function retireOuraEquivalentDistance(
userId: string,
): Promise<number> {
let retired = 0;
for (;;) {
const rows = await prisma.measurement.findMany({
where: { userId, ...OURA_EQUIVALENT_DISTANCE_WHERE },
select: { id: true, type: true, measuredAt: true },
orderBy: { measuredAt: "asc" },
take: OURA_DISTANCE_RETIRE_BATCH,
});
if (rows.length === 0) break;
const res = await prisma.measurement.updateMany({
where: { userId, id: { in: rows.map((r) => r.id) }, deletedAt: null },
data: { deletedAt: new Date() },
});
retired += res.count;
await afterMeasurementMutation(userId, rows, {
source: "sync",
label: "oura",
});
// A batch the update found already retired (a concurrent run) still
// leaves the read empty next time round, so the loop ends.
if (rows.length < OURA_DISTANCE_RETIRE_BATCH) break;
}
if (retired > 0) {
invalidateUserMeasurements(userId);
annotate({ meta: { oura_equivalent_distance_retired: retired } });
}
return retired;
}
+6
View File
@@ -34,6 +34,8 @@
* vo2max vO2_max heart_health
* cardio_age daily_cardiovascular_age heart_health
* resilience daily_resilience stress
* workout workout workout
* heartrate heartrate heartrate
* (profile) personal_info personal
* (cycle) daily_cycle_phases reproductive_cycle
*
@@ -53,8 +55,12 @@ export const OURA_COLLECTIONS = [
{ name: "vo2max", scope: "heart_health" },
{ name: "cardio_age", scope: "heart_health" },
{ name: "resilience", scope: "stress" },
{ name: "workout", scope: "workout" },
{ name: "heartrate", scope: "heartrate" },
] as const;
export type OuraCollectionName = (typeof OURA_COLLECTIONS)[number]["name"];
type OuraCollectionEntry = (typeof OURA_COLLECTIONS)[number];
/** The scope the profile probe (`personal_info`) needs. */
+133
View File
@@ -0,0 +1,133 @@
/**
* Oura workout `activity` → HealthLog canonical `WorkoutSportType`.
*
* Oura labels a workout with its own lowercase snake_case vocabulary
* (`walking`, `cycling`, `strength_training`, `cross_country_skiing`, ...)
* and publishes no closed list of it. The table below covers the activities
* the Oura app offers; the bucket choices follow the Polar and Strava tables
* (paddle sports → rowing, racket sports → tennis, climbing and functional
* training → crossTraining, mobility work → mindAndBody, snow sports → other),
* so the same real-world session lands in the same bucket whichever source
* reported it. Anything unknown lands on `"other"`, the row still persists,
* and the raw label is kept in `Workout.metadata.ouraActivity`.
*/
import type { WorkoutSportType } from "@/lib/validations/workout";
const OURA_SPORT_TABLE: ReadonlyArray<readonly [string, WorkoutSportType]> = [
// running
["running", "running"],
["jogging", "running"],
["trail_running", "running"],
["treadmill_running", "running"],
// cycling
["cycling", "cycling"],
["indoor_cycling", "cycling"],
["mountain_biking", "cycling"],
["road_cycling", "cycling"],
["e_biking", "cycling"],
["spinning", "cycling"],
// walking / hiking
["walking", "walking"],
["nordic_walking", "walking"],
["treadmill_walking", "walking"],
["hiking", "hiking"],
["backpacking", "hiking"],
// water
["swimming", "swimming"],
["open_water_swimming", "swimming"],
["rowing", "rowing"],
["indoor_rowing", "rowing"],
["kayaking", "rowing"],
["canoeing", "rowing"],
["paddling", "rowing"],
["stand_up_paddling", "rowing"],
// machines
["elliptical", "elliptical"],
["cross_trainer", "elliptical"],
["stair_exercise", "stairClimber"],
["stair_climbing", "stairClimber"],
["stairs", "stairClimber"],
// strength / intervals / functional
["strength_training", "strength"],
["weight_training", "strength"],
["weightlifting", "strength"],
["core_training", "strength"],
["hiit", "hiit"],
["high_intensity_interval_training", "hiit"],
["circuit_training", "hiit"],
["cross_training", "crossTraining"],
["crossfit", "crossTraining"],
["functional_training", "crossTraining"],
["climbing", "crossTraining"],
["bouldering", "crossTraining"],
["rock_climbing", "crossTraining"],
["mixed_cardio", "mixedCardio"],
["cardio", "mixedCardio"],
["aerobics", "dance"],
// mind and body
["yoga", "yoga"],
["hot_yoga", "yoga"],
["pilates", "mindAndBody"],
["stretching", "mindAndBody"],
["mobility", "mindAndBody"],
["barre", "mindAndBody"],
["tai_chi", "mindAndBody"],
// dance
["dancing", "dance"],
["dance", "dance"],
["zumba", "dance"],
// ball and racket sports
["golf", "golf"],
["tennis", "tennis"],
["table_tennis", "tennis"],
["squash", "tennis"],
["padel", "tennis"],
["pickleball", "tennis"],
["racquetball", "tennis"],
["badminton", "badminton"],
["basketball", "basketball"],
["soccer", "soccer"],
["football", "soccer"],
// everything else that persists outside a bucket
["skiing", "other"],
["cross_country_skiing", "other"],
["downhill_skiing", "other"],
["snowboarding", "other"],
["skating", "other"],
["ice_skating", "other"],
["surfing", "other"],
["boxing", "other"],
["martial_arts", "other"],
["volleyball", "other"],
["hockey", "other"],
["housework", "other"],
["gardening", "other"],
["other", "other"],
];
/** Fold case and drop separators, so `Strength Training` meets `strength_training`. */
function normaliseOuraActivity(raw: string): string {
return raw
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "");
}
const OURA_SPORT_MAP: ReadonlyMap<string, WorkoutSportType> = new Map(
OURA_SPORT_TABLE.map(([name, canonical]) => [
normaliseOuraActivity(name),
canonical,
]),
);
/**
* Resolve an Oura workout's `activity` to a canonical `WorkoutSportType`;
* `"other"` for an absent, blank or unknown label. Never returns the raw
* label.
*/
export function mapOuraSportType(
activity: string | null | undefined,
): WorkoutSportType {
if (typeof activity !== "string" || activity.trim() === "") return "other";
return OURA_SPORT_MAP.get(normaliseOuraActivity(activity)) ?? "other";
}
+200
View File
@@ -0,0 +1,200 @@
/**
* Oura intraday heart rate (`/v2/usercollection/heartrate`) → `PULSE`.
*
* The ring writes a reading every few minutes through the day, more during a
* workout or a session, so a history of years is hundreds of thousands of
* rows. The walk is streamed (`forEachHeartRatePage`): each page is mapped and
* written in bounded batches before the next page is read, and nothing of a
* page outlives its write. What the window keeps is the set of touched days
* (a handful per window), which the caller refolds once at the end.
*
* Writes are first-write-wins: a sample is final once it is in Oura's cloud,
* and it is keyed on its own instant (`heartrate:<ISO>`), so a re-read of the
* same range is an insert that `skipDuplicates` drops. A row the person
* deleted keeps its tombstone; the re-read does not bring it back. Oura's
* samples go raw, like Google Health's and Fitbit's minute stream: the dense
* hourly fold covers only the export sources (`DENSE_INTRADAY_FOLD_SOURCES`).
*/
import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
import { dropImplausibleMeasurements } from "@/lib/measurements/plausibility-gate";
import {
collapseToTypeDayKeys,
recomputeBucketsForKeys,
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { getEvent } from "@/lib/logging/context";
import {
forEachHeartRatePage,
mapHeartRateSample,
type MappedMeasurement,
type OuraHeartRateSample,
} from "./client";
/** Rows per `createMany`: bounded whatever size of page Oura sends. */
export const OURA_HEART_RATE_WRITE_CHUNK = 500;
/**
* The hourly window: how far back an hourly sync reads heart rate. Short on
* purpose, the history belongs to the backfill. A ring that has not synced
* for a while widens it up to the ceiling, anchored on the newest reading
* already held (`resolveOuraHeartRateWindow`).
*/
export const OURA_HEART_RATE_LOOKBACK_HOURS = 6;
export const OURA_HEART_RATE_MAX_LOOKBACK_HOURS = 72;
const HOUR_MS = 60 * 60 * 1000;
/** Day keys touched by a write, collapsed as they arrive. */
export class TouchedDays {
private readonly keys = new Map<
string,
{ type: MeasurementType; measuredAt: Date }
>();
add(rows: Array<{ type: MeasurementType; measuredAt: Date }>): void {
for (const k of collapseToTypeDayKeys(rows)) {
this.keys.set(`${k.type}|${k.measuredAt.getTime()}`, k);
}
}
get size(): number {
return this.keys.size;
}
list(): Array<{ type: MeasurementType; measuredAt: Date }> {
return [...this.keys.values()];
}
}
/**
* Write one page of samples. Returns how many rows were inserted; the days
* the page reached go into `touched`.
*/
export async function writeOuraHeartRatePage(
userId: string,
samples: readonly OuraHeartRateSample[],
touched: TouchedDays,
opts: { emitArrivals?: boolean } = {},
): Promise<number> {
const mapped: MappedMeasurement[] = [];
for (const s of samples) {
const m = mapHeartRateSample(s);
if (m) mapped.push(m);
}
const kept = dropImplausibleMeasurements("oura", mapped, (m) => ({
type: m.type,
value: m.value,
}));
let inserted = 0;
for (let i = 0; i < kept.length; i += OURA_HEART_RATE_WRITE_CHUNK) {
const chunk = kept.slice(i, i + OURA_HEART_RATE_WRITE_CHUNK);
const rows = await prisma.measurement.createManyAndReturn({
data: chunk.map((m) => ({
userId,
type: m.type as MeasurementType,
source: "OURA" as const,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: m.externalId!,
})),
skipDuplicates: true,
select: { id: true, type: true, measuredAt: true },
});
inserted += rows.length;
touched.add(rows);
if (opts.emitArrivals !== false && rows.length > 0) {
void emitInsertedMeasurementArrivals(userId, rows, "oura").catch(
() => {},
);
}
}
return inserted;
}
/** Refold the touched days once a window is written. Best-effort. */
export async function refoldOuraTouchedDays(
userId: string,
touched: TouchedDays,
): Promise<void> {
if (touched.size === 0) return;
const keys = touched.list();
try {
await recomputeBucketsForKeys(userId, keys);
invalidateStatusInsightsForTypes(
userId,
[...new Set(keys.map((k) => k.type))],
{ source: "sync" },
).catch((err) => {
getEvent()?.addWarning(
`oura: status-insight invalidate failed for ${userId}: ${err}`,
);
});
} catch (err) {
getEvent()?.addWarning(
`oura: heart-rate rollup recompute failed for ${userId}: ${err}`,
);
}
}
/**
* Read and write one time range of heart rate, page by page. Returns how many
* rows were inserted and whether the walk stopped short (page ceiling or a
* cursor Oura handed out twice). A refused page throws the classified
* `OuraApiError` (a 429 with its `retryAfterMs`); the pages written before it
* stay written.
*/
export async function syncOuraHeartRateRange(
userId: string,
accessToken: string,
range: { start: Date; end: Date },
opts: { emitArrivals?: boolean; touched?: TouchedDays } = {},
): Promise<{ imported: number; truncated: boolean }> {
const touched = opts.touched ?? new TouchedDays();
let imported = 0;
const walk = await forEachHeartRatePage(
accessToken,
{ start: range.start, end: range.end },
async (samples) => {
imported += await writeOuraHeartRatePage(userId, samples, touched, {
emitArrivals: opts.emitArrivals,
});
},
);
if (!opts.touched) await refoldOuraTouchedDays(userId, touched);
return { imported, truncated: walk.truncated };
}
/**
* The hourly heart-rate window: the last `OURA_HEART_RATE_LOOKBACK_HOURS`, or
* back to an hour before the newest reading held when that is older, never
* further than `OURA_HEART_RATE_MAX_LOOKBACK_HOURS`. The ring uploads what it
* stored all at once when the app next opens, so a gap closes in one window.
*/
export async function resolveOuraHeartRateWindow(
userId: string,
now: Date = new Date(),
): Promise<{ start: Date; end: Date }> {
const newest = await prisma.measurement.findFirst({
where: {
userId,
source: "OURA",
type: "PULSE",
deletedAt: null,
externalId: { startsWith: "heartrate:" },
},
orderBy: { measuredAt: "desc" },
select: { measuredAt: true },
});
const floor = now.getTime() - OURA_HEART_RATE_MAX_LOOKBACK_HOURS * HOUR_MS;
const steady = now.getTime() - OURA_HEART_RATE_LOOKBACK_HOURS * HOUR_MS;
const fromNewest = newest
? newest.measuredAt.getTime() - HOUR_MS
: Number.POSITIVE_INFINITY;
return {
start: new Date(Math.max(floor, Math.min(steady, fromNewest))),
end: now,
};
}
+150
View File
@@ -0,0 +1,150 @@
/**
* Oura workouts → `Workout` rows (`source = OURA`).
*
* Each Oura workout becomes one row keyed on `(userId, source, externalId)`
* with the Oura id as `externalId`, so a re-read (the hourly window, a
* backfill slice, a workout Oura revised) overwrites in place. The write is
* the same `createManyAndReturn` + `skipDuplicates`, update-on-conflict shape
* the Polar, WHOOP and Strava ingests use; the arrival event fires only for a
* fresh insert.
*
* Cross-source dedup is not done here: an Oura-detected walk and the same walk
* from Apple Health stay two rows, and the read-time `pickCanonicalWorkoutRows`
* collapses the twin by the user's source ladder, like every other provider.
*
* `sportType` is the canonical bucket from `mapOuraSportType`; the raw label
* travels in `metadata.ouraActivity`, with Oura's intensity grade and how the
* workout was recorded (`manual`, `autodetected`, ...). The person's own name
* for the workout (`label`) is free text and is not imported.
*/
import { prisma } from "@/lib/db";
import { emitInsertedWorkoutArrival } from "@/lib/arrivals/workout-emit";
import { annotate, getEvent } from "@/lib/logging/context";
import type { Prisma } from "@/generated/prisma/client";
import type { WorkoutSportType } from "@/lib/validations/workout";
import type { OuraWorkout } from "./client";
import { mapOuraSportType } from "./sport-map";
export interface OuraWorkoutRow {
externalId: string;
sportType: WorkoutSportType;
startedAt: Date;
endedAt: Date;
durationSec: number;
totalEnergyKcal: number | null;
totalDistanceM: number | null;
metadata: Prisma.InputJsonValue;
}
function finiteNonNegative(v: number | null | undefined): number | null {
return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
}
/**
* Map one Oura workout to a row, or null when it has no id or no usable time
* range (an end before its start included).
*/
export function mapOuraWorkout(w: OuraWorkout): OuraWorkoutRow | null {
if (typeof w.id !== "string" || w.id === "") return null;
const startedAt = new Date(w.start_datetime);
const endedAt = new Date(w.end_datetime);
if (Number.isNaN(startedAt.getTime()) || Number.isNaN(endedAt.getTime())) {
return null;
}
if (endedAt.getTime() < startedAt.getTime()) return null;
const activity = typeof w.activity === "string" ? w.activity : null;
const metadata: Prisma.InputJsonValue = {
...(activity ? { ouraActivity: activity } : {}),
...(typeof w.intensity === "string" ? { ouraIntensity: w.intensity } : {}),
...(typeof w.source === "string" ? { ouraSource: w.source } : {}),
};
const kcal = finiteNonNegative(w.calories);
return {
externalId: w.id,
sportType: mapOuraSportType(activity),
startedAt,
endedAt,
durationSec: Math.round((endedAt.getTime() - startedAt.getTime()) / 1000),
totalEnergyKcal: kcal === null ? null : Math.round(kcal),
totalDistanceM: finiteNonNegative(w.distance),
metadata,
};
}
/**
* Upsert mapped Oura workouts. Every row is attempted; the first failure is
* rethrown afterwards so a partially written batch is never reported clean.
* `emitArrivals: false` (the history backfill) writes without arrival events:
* a workout from three years ago is not news.
*/
export async function upsertOuraWorkouts(
userId: string,
rows: readonly OuraWorkoutRow[],
opts: { emitArrivals?: boolean } = {},
): Promise<number> {
if (rows.length === 0) return 0;
const emitArrivals = opts.emitArrivals ?? true;
let imported = 0;
let firstError: unknown;
let hadError = false;
for (const r of rows) {
const data = {
sportType: r.sportType,
startedAt: r.startedAt,
endedAt: r.endedAt,
durationSec: r.durationSec,
totalEnergyKcal: r.totalEnergyKcal,
totalDistanceM: r.totalDistanceM,
metadata: r.metadata,
};
try {
const [inserted] = await prisma.workout.createManyAndReturn({
data: { userId, source: "OURA", externalId: r.externalId, ...data },
skipDuplicates: true,
select: { id: true, startedAt: true },
});
if (inserted) {
if (emitArrivals) {
void emitInsertedWorkoutArrival(userId, inserted, "oura").catch(
() => {},
);
}
} else {
await prisma.workout.update({
where: {
userId_source_externalId: {
userId,
source: "OURA",
externalId: r.externalId,
},
},
data,
});
}
imported += 1;
} catch (err) {
getEvent()?.addWarning(`oura: failed to upsert workout: ${err}`);
if (!hadError) {
firstError = err;
hadError = true;
}
}
}
if (hadError) throw firstError;
annotate({ action: { name: "oura.workout.ingest", details: { imported } } });
return imported;
}
/** Map and write one window's workouts; unmappable records are skipped. */
export async function writeOuraWorkouts(
userId: string,
workouts: readonly OuraWorkout[],
opts: { emitArrivals?: boolean } = {},
): Promise<number> {
const rows: OuraWorkoutRow[] = [];
for (const w of workouts) {
const row = mapOuraWorkout(w);
if (row) rows.push(row);
}
return upsertOuraWorkouts(userId, rows, opts);
}
+217 -159
View File
@@ -4,7 +4,7 @@
* Pulls daily readiness (→ RECOVERY_SCORE + BODY_TEMPERATURE_DEVIATION), sleep
* (real per-segment hypnogram timeline when present, else per-stage totals;
* efficiency, HRV, RHR, respiratory rate), daily activity (steps, active
* energy, equivalent walking distance), the daily Sleep Score (→ SLEEP_SCORE),
* energy), the daily Sleep Score (→ SLEEP_SCORE),
* daily SpO2 (→ OXYGEN_SATURATION), the dedicated vO2_max collection (→
* VO2_MAX), and the daily cardiovascular-age estimate (→ VASCULAR_AGE) for one
* connected user, mapping each into `Measurement` rows tagged `source = OURA`.
@@ -75,6 +75,7 @@ import {
fetchResilience,
fetchSleep,
fetchVo2Max,
fetchWorkouts,
isRetiredOuraSleepPeriod,
mapCardiovascularAge,
mapDailyActivity,
@@ -86,15 +87,22 @@ import {
mapVo2Max,
type MappedMeasurement,
} from "./client";
import {
getOuraClientCredentials,
getOuraConnection,
refreshOuraAccessToken,
syncOuraScopeDenied,
} from "./credentials";
import { getOuraConnection, syncOuraScopeDenied } from "./credentials";
import { refreshOuraAfterRefusal } from "./oauth-descriptor";
import { OuraApiError, classifyOuraError } from "./response-classifier";
import { syncUserOuraCyclePhases } from "./cycle-sync";
import { isOuraScopeGatedCollection } from "./scope";
import {
OURA_COLLECTIONS,
isOuraScopeGatedCollection,
type OuraCollectionName,
} from "./scope";
import { writeOuraWorkouts } from "./sync-workouts";
import {
refoldOuraTouchedDays,
resolveOuraHeartRateWindow,
syncOuraHeartRateRange,
TouchedDays,
} from "./sync-heart-rate";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
@@ -224,9 +232,12 @@ function toUpsert(
/** One Oura collection: its ledger name and a self-contained fetch+map closure
* that resolves to the collection's mapped upserts. Each closure owns its own
* client call and mapper so a throw is contained to this one collection. */
* client call and mapper so a throw is contained to this one collection. A
* collection that writes rows of its own (workouts, the heart-rate stream)
* writes inside the closure, counts on `OuraRunContext.sideImported`, and
* resolves to no upserts. */
interface OuraCollectionSpec {
name: string;
name: OuraCollectionName;
collect: (token: string) => Promise<OuraMeasurementUpsert[]>;
}
@@ -265,12 +276,167 @@ export interface OuraFetchResult {
scopeDenied: string[] | null;
}
/**
* What one run reads: the day window of the day-keyed collections, the time
* range of the heart-rate stream, the user's zone, and the sinks the sleep
* collection and the self-writing collections fill as they go.
*/
export interface OuraRunContext {
userId: string;
tz: string;
query: { startDate: string; endDate: string };
heartRate: { start: Date; end: Date };
/** False on a history backfill: old rows are not arrivals. */
emitArrivals: boolean;
sleepSweeps: SleepSegmentSweep[];
sleepRetirements: string[];
/** Rows written by the collections that write their own. */
sideImported: number;
/** Days the heart-rate stream touched; refolded once by the caller. */
heartRateTouched: TouchedDays;
}
export function ouraRunContext(
userId: string,
tz: string,
query: { startDate: string; endDate: string },
heartRate: { start: Date; end: Date },
emitArrivals = true,
): OuraRunContext {
return {
userId,
tz,
query,
heartRate,
emitArrivals,
sleepSweeps: [],
sleepRetirements: [],
sideImported: 0,
heartRateTouched: new TouchedDays(),
};
}
/**
* The fetch+map (or fetch+write) closure of one collection over the context's
* window. The one place each collection's read is defined, for the hourly
* sync and the history backfill alike.
*/
export function ouraCollectionReader(
name: OuraCollectionName,
ctx: OuraRunContext,
): (token: string) => Promise<OuraMeasurementUpsert[]> {
const { tz, query } = ctx;
switch (name) {
case "readiness":
return async (t) =>
(await fetchReadiness(t, query)).flatMap((r) =>
toUpsert(
mapReadiness(r, tz),
"readiness",
r.timestamp ? undefined : r.day,
),
);
case "sleep":
return async (t) => {
const records = await fetchSleep(t, query);
const ups: OuraMeasurementUpsert[] = [];
for (const s of records) {
if (isRetiredOuraSleepPeriod(s)) {
if (s.id) ctx.sleepRetirements.push(`sleep:${s.id}:`);
continue;
}
const rows = toUpsert(
mapSleep(s, tz),
"sleep",
s.bedtime_end ? undefined : s.day,
);
ups.push(...rows);
// Record-scoped sweep entry: every SLEEP_DURATION row of this
// record keys under `sleep:<id>:` (hypnogram runs AND the
// stage-total fallback), so the prefix bounds the cleanup to this
// one record. Clears whatever a revised hypnogram orphaned —
// shifted run boundaries, legacy run-indexed `seg:<i>` rows, and a
// stale stage-total set once the hypnogram appears.
ctx.sleepSweeps.push({
prefix: `sleep:${s.id}:`,
keepIds: rows
.filter((r) => r.type === "SLEEP_DURATION")
.map((r) => r.externalId),
});
}
return ups;
};
case "activity":
return async (t) =>
(await fetchDailyActivity(t, query)).flatMap((a) =>
toUpsert(
mapDailyActivity(a, tz),
"activity",
a.timestamp ? undefined : a.day,
),
);
case "daily_sleep":
return async (t) =>
(await fetchDailySleep(t, query)).flatMap((d) =>
toUpsert(
mapDailySleep(d, tz),
"daily_sleep",
d.timestamp ? undefined : d.day,
),
);
case "spo2":
return async (t) =>
(await fetchDailySpo2(t, query)).flatMap((s) =>
toUpsert(mapDailySpo2(s, tz), "spo2", s.day),
);
case "vo2max":
return async (t) =>
(await fetchVo2Max(t, query)).flatMap((v) =>
toUpsert(mapVo2Max(v, tz), "vo2max", v.timestamp ? undefined : v.day),
);
case "cardio_age":
return async (t) =>
(await fetchCardiovascularAge(t, query)).flatMap((c) =>
toUpsert(mapCardiovascularAge(c, tz), "cardio_age", c.day),
);
case "resilience":
return async (t) =>
(await fetchResilience(t, query)).flatMap((r) =>
toUpsert(mapResilience(r, tz), "resilience", r.day),
);
case "workout":
return async (t) => {
const workouts = await fetchWorkouts(t, query);
ctx.sideImported += await writeOuraWorkouts(ctx.userId, workouts, {
emitArrivals: ctx.emitArrivals,
});
return [];
};
case "heartrate":
return async (t) => {
const { imported, truncated } = await syncOuraHeartRateRange(
ctx.userId,
t,
ctx.heartRate,
{ emitArrivals: ctx.emitArrivals, touched: ctx.heartRateTouched },
);
ctx.sideImported += imported;
if (truncated) {
// A window Oura could not page to its end is not read: fail it so
// the run is not reported clean over a gap.
throw new Error("oura heart-rate walk stopped short of its window");
}
return [];
};
}
}
/**
* A 401 whose body names the missing scope (see `./scope`): Oura checked the
* token and found it valid, the grant just does not cover this collection. A
* refresh cannot widen a grant, so none is spent on it.
*/
function isNamedScopeRefusal(err: unknown): boolean {
export function isNamedScopeRefusal(err: unknown): boolean {
return (
err instanceof OuraApiError &&
err.httpStatus === 401 &&
@@ -283,7 +449,7 @@ function isNamedScopeRefusal(err: unknown): boolean {
* no body to tell. This is what the one refresh is for. A 403 is neither: Oura
* answers it for a lapsed membership, which no refresh or reconnect fixes.
*/
function isTokenRefusal(err: unknown): boolean {
export function isTokenRefusal(err: unknown): boolean {
return (
err instanceof OuraApiError &&
err.httpStatus === 401 &&
@@ -350,124 +516,17 @@ function splitScopeDenied(
* connection at `reauth_required`.
*/
async function fetchAll(
userId: string,
ctx: OuraRunContext,
accessToken: string,
refreshTokenCiphertext: string,
lookbackDays: number,
knownScopeDenied: readonly string[] = [],
): Promise<OuraFetchResult> {
// Oura keys its daily collections by the user's calendar date, so the
// window is cut on the user's own days, and date-only records are anchored
// at local noon of their date in this zone.
const tz = await resolveUserTimezone(userId);
const today = userDayKey(new Date(), tz);
const query = {
startDate: shiftDateKey(today, -lookbackDays),
endDate: today,
};
// Filled by the sleep collection's closure as it maps records; carried on
// the result so the caller can run the record-scoped sweep before the
// upsert. A collection that throws pushes nothing (the fetch is its first
// await), so a 401-retry cannot double-enter a record.
const sleepSweeps: SleepSegmentSweep[] = [];
const sleepRetirements: string[] = [];
const collections: OuraCollectionSpec[] = [
{
name: "readiness",
collect: async (t) =>
(await fetchReadiness(t, query)).flatMap((r) =>
toUpsert(
mapReadiness(r, tz),
"readiness",
r.timestamp ? undefined : r.day,
),
),
},
{
name: "sleep",
collect: async (t) => {
const records = await fetchSleep(t, query);
const ups: OuraMeasurementUpsert[] = [];
for (const s of records) {
if (isRetiredOuraSleepPeriod(s)) {
if (s.id) sleepRetirements.push(`sleep:${s.id}:`);
continue;
}
const rows = toUpsert(
mapSleep(s, tz),
"sleep",
s.bedtime_end ? undefined : s.day,
);
ups.push(...rows);
// Record-scoped sweep entry: every SLEEP_DURATION row of this
// record keys under `sleep:<id>:` (hypnogram runs AND the
// stage-total fallback), so the prefix bounds the cleanup to this
// one record. Clears whatever a revised hypnogram orphaned —
// shifted run boundaries, legacy run-indexed `seg:<i>` rows, and a
// stale stage-total set once the hypnogram appears.
sleepSweeps.push({
prefix: `sleep:${s.id}:`,
keepIds: rows
.filter((r) => r.type === "SLEEP_DURATION")
.map((r) => r.externalId),
});
}
return ups;
},
},
{
name: "activity",
collect: async (t) =>
(await fetchDailyActivity(t, query)).flatMap((a) =>
toUpsert(
mapDailyActivity(a, tz),
"activity",
a.timestamp ? undefined : a.day,
),
),
},
{
name: "daily_sleep",
collect: async (t) =>
(await fetchDailySleep(t, query)).flatMap((d) =>
toUpsert(
mapDailySleep(d, tz),
"daily_sleep",
d.timestamp ? undefined : d.day,
),
),
},
{
name: "spo2",
collect: async (t) =>
(await fetchDailySpo2(t, query)).flatMap((s) =>
toUpsert(mapDailySpo2(s, tz), "spo2", s.day),
),
},
{
name: "vo2max",
collect: async (t) =>
(await fetchVo2Max(t, query)).flatMap((v) =>
toUpsert(mapVo2Max(v, tz), "vo2max", v.timestamp ? undefined : v.day),
),
},
{
name: "cardio_age",
collect: async (t) =>
(await fetchCardiovascularAge(t, query)).flatMap((c) =>
toUpsert(mapCardiovascularAge(c, tz), "cardio_age", c.day),
),
},
{
name: "resilience",
collect: async (t) =>
(await fetchResilience(t, query)).flatMap((r) =>
toUpsert(mapResilience(r, tz), "resilience", r.day),
),
},
];
const userId = ctx.userId;
const { sleepSweeps, sleepRetirements } = ctx;
const collections: OuraCollectionSpec[] = OURA_COLLECTIONS.map((c) => ({
name: c.name,
collect: ouraCollectionReader(c.name, ctx),
}));
// Run a set of collections isolated from one another. A rejection is captured
// per-collection, never propagated, so siblings still resolve.
@@ -524,21 +583,9 @@ async function fetchAll(
// same one-time refresh token twice, and retry the collections that failed
// for any reason but a named scope. A failed refresh throws so the caller
// parks reauth_required.
const creds = await getOuraClientCredentials(userId);
if (!creds)
return {
readings: first.readings,
// The refresh never happened, so no sibling's success can be trusted
// over it; a refusal that names its scope still says what it says.
...splitScopeDenied(first.failures, false),
sleepSweeps,
sleepRetirements,
};
const usableToken = await refreshOuraAccessToken(
const usableToken = await refreshOuraAfterRefusal(
userId,
refreshTokenCiphertext,
creds,
);
if (!usableToken)
return {
@@ -645,13 +692,25 @@ export async function syncUserOura(
const lookbackDays =
opts.lookbackDays ?? (await resolveOuraLookbackDays(userId));
// Oura keys its daily collections by the user's calendar date, so the
// window is cut on the user's own days, and date-only records are anchored
// at local noon of their date in this zone. Heart rate is read by instant
// over a window of hours (`resolveOuraHeartRateWindow`).
const tz = await resolveUserTimezone(userId);
const today = userDayKey(new Date(), tz);
const ctx = ouraRunContext(
userId,
tz,
{ startDate: shiftDateKey(today, -lookbackDays), endDate: today },
await resolveOuraHeartRateWindow(userId),
);
let result: OuraFetchResult;
try {
result = await fetchAll(
userId,
ctx,
conn.accessToken,
conn.refreshTokenCiphertext,
lookbackDays,
conn.scopeDenied,
);
} catch (err) {
@@ -693,13 +752,15 @@ export async function syncUserOura(
// Import everything the healthy collections returned regardless of whether a
// sibling collection failed — one bad collection must not blank the source.
let insertedSleepMeasuredAts: Date[] = [];
const imported = await upsertOuraMeasurements(userId, result.readings, {
onInserted: (rows) => {
insertedSleepMeasuredAts = rows
.filter((row) => row.type === "SLEEP_DURATION")
.map((row) => row.measuredAt);
},
});
const imported =
(await upsertOuraMeasurements(userId, result.readings, {
onInserted: (rows) => {
insertedSleepMeasuredAts = rows
.filter((row) => row.type === "SLEEP_DURATION")
.map((row) => row.measuredAt);
},
})) + ctx.sideImported;
await refoldOuraTouchedDays(userId, ctx.heartRateTouched);
// v1.29.x — best-effort Cycle Insights import. Fully isolated from the
// measurement sync's status ledger on purpose: `daily_cycle_phases` sits
@@ -711,14 +772,7 @@ export async function syncUserOura(
try {
const freshConn = await getOuraConnection(userId);
if (freshConn) {
const cycleToday = userDayKey(
new Date(),
await resolveUserTimezone(userId),
);
await syncUserOuraCyclePhases(userId, freshConn.accessToken, {
startDate: shiftDateKey(cycleToday, -lookbackDays),
endDate: cycleToday,
});
await syncUserOuraCyclePhases(userId, freshConn.accessToken, ctx.query);
}
} catch (err) {
getEvent()?.addWarning(
@@ -777,6 +831,8 @@ export async function upsertOuraMeasurements(
readings: OuraMeasurementUpsert[],
opts: {
onInserted?: (rows: InsertedMeasurementArrivalRow[]) => void;
/** False on a history backfill: old rows are not arrivals. */
emitArrivals?: boolean;
} = {},
): Promise<number> {
if (readings.length === 0) return 0;
@@ -834,9 +890,11 @@ export async function upsertOuraMeasurements(
}
opts.onInserted?.(insertedRows);
void emitInsertedMeasurementArrivals(userId, insertedRows, "oura").catch(
() => {},
);
if (opts.emitArrivals !== false) {
void emitInsertedMeasurementArrivals(userId, insertedRows, "oura").catch(
() => {},
);
}
try {
const keys = collapseToTypeDayKeys(touched);
await recomputeBucketsForKeys(userId, keys);
+9 -9
View File
@@ -11,7 +11,9 @@
*/
import { prisma } from "@/lib/db";
import { decrypt, encrypt } from "@/lib/crypto";
import { obtainAccessToken } from "@/lib/integrations/oauth-kit/token";
import { getPolarCredentials, type PolarCredentials } from "./client";
import { polarTokenDescriptor } from "./oauth-descriptor";
/**
* Resolve the user's Polar OAuth client id/secret, DB-first then env.
@@ -80,24 +82,22 @@ export interface PolarConnection {
}
/**
* Fetch + decrypt the user's stored Polar token. Returns null when the user has
* Fetch + decrypt the user's stored Polar token, read through the shared OAuth
* token lifecycle like every other provider's. Returns null when the user has
* not connected Polar (no access token) or the member id is missing.
*/
export async function getPolarConnection(
userId: string,
): Promise<PolarConnection | null> {
const grant = await obtainAccessToken(polarTokenDescriptor, userId);
if (grant.kind !== "token") return null;
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
polarAccessTokenEncrypted: true,
polarUserIdEncrypted: true,
},
select: { polarUserIdEncrypted: true },
});
if (!user?.polarAccessTokenEncrypted || !user?.polarUserIdEncrypted) {
return null;
}
if (!user?.polarUserIdEncrypted) return null;
return {
accessToken: decrypt(user.polarAccessTokenEncrypted),
accessToken: grant.accessToken,
polarUserId: decrypt(user.polarUserIdEncrypted),
};
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Where a Polar grant lives, for the shared token lifecycle
* (`src/lib/integrations/oauth-kit/token.ts`).
*
* Polar AccessLink tokens do not expire and come without a refresh token, so
* the kit only reads them: there is nothing to refresh and nothing to write
* back. A revoked grant surfaces as a refused read, and the person reconnects.
*/
import type { OAuthTokenDescriptor } from "@/lib/integrations/oauth-kit/token";
export const polarTokenDescriptor: OAuthTokenDescriptor<never> = {
provider: "polar",
tracksExpiry: false,
expiryBufferMs: 0,
async readGrant(db, userId) {
const row = await db.user.findUnique({
where: { id: userId },
select: { polarAccessTokenEncrypted: true },
});
if (!row?.polarAccessTokenEncrypted) return null;
return {
accessCiphertext: row.polarAccessTokenEncrypted,
refreshCiphertext: null,
expiresAt: null,
};
},
// Never reached: a grant without a refresh token is never refreshed.
writeGrant: async () => 0,
loadCredentials: async () => null,
refresh: null,
};
+1 -56
View File
@@ -22,7 +22,7 @@ vi.mock("../client", () => ({
getStravaCredentials: () => getStravaCredentials(),
}));
import { getStravaClientCredentials, storeStravaTokens } from "../credentials";
import { getStravaClientCredentials } from "../credentials";
describe("getStravaClientCredentials — DB-first then env", () => {
beforeEach(() => {
@@ -57,58 +57,3 @@ describe("getStravaClientCredentials — DB-first then env", () => {
expect(getStravaCredentials).toHaveBeenCalledOnce();
});
});
describe("storeStravaTokens — rotating-refresh compare-and-swap", () => {
beforeEach(() => {
findUnique.mockReset();
updateMany.mockReset();
});
it("persists and returns the fresh access token when the CAS wins", async () => {
updateMany.mockResolvedValue({ count: 1 });
const token = await storeStravaTokens(
"user-1",
"fresh-access",
"fresh-refresh",
"stored-refresh-ciphertext",
);
expect(token).toBe("fresh-access");
// The guard keys on the exact stored ciphertext, not a re-encryption.
expect(updateMany).toHaveBeenCalledWith({
where: {
id: "user-1",
stravaRefreshTokenEncrypted: "stored-refresh-ciphertext",
},
data: {
stravaAccessTokenEncrypted: "enc(fresh-access)",
stravaRefreshTokenEncrypted: "enc(fresh-refresh)",
},
});
});
it("returns the peer's rotated access token when the CAS loses the race", async () => {
updateMany.mockResolvedValue({ count: 0 });
findUnique.mockResolvedValue({
stravaAccessTokenEncrypted: "peer-access-cipher",
});
const token = await storeStravaTokens(
"user-1",
"fresh-access",
"fresh-refresh",
"stale-ciphertext",
);
expect(token).toBe("dec(peer-access-cipher)");
});
it("returns null when the connection vanished mid-race", async () => {
updateMany.mockResolvedValue({ count: 0 });
findUnique.mockResolvedValue({ stravaAccessTokenEncrypted: null });
const token = await storeStravaTokens(
"user-1",
"fresh-access",
"fresh-refresh",
"stale-ciphertext",
);
expect(token).toBeNull();
});
});
+7 -3
View File
@@ -23,6 +23,7 @@
*/
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import { parseRetryAfterMs } from "@/lib/integrations/oauth-kit/retry-after";
import type { Prisma } from "@/generated/prisma/client";
import { StravaApiError, classifyStravaResponse } from "./response-classifier";
import { mapStravaSportType } from "./sport-map";
@@ -282,9 +283,12 @@ export function stravaRateLimitRetryAfterSeconds(
headers: Headers,
now: Date = new Date(),
): number {
const retryAfter = Number(headers.get("retry-after"));
if (Number.isFinite(retryAfter) && retryAfter > 0) {
return Math.ceil(retryAfter);
const retryAfterMs = parseRetryAfterMs(
headers.get("retry-after"),
now.getTime(),
);
if (retryAfterMs !== undefined && retryAfterMs > 0) {
return Math.ceil(retryAfterMs / 1000);
}
const pair = (name: string): [number, number] | null => {
const parts = (headers.get(name) ?? "").split(",").map(Number);
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more