mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(oura): ask for the spo2 and heart_health scopes, explain a missing scope calmly
Oura's current authorization server enforces scopes per collection. daily_spo2 needs spo2; vO2_max, daily_cardiovascular_age and daily_resilience need heart_health. The authorize request now asks for both. A grant issued before keeps answering 401 on those four collections while every other one succeeds. Such a 401 next to a success is now recorded as a scope gap rather than a failure: it no longer counts toward the reconnect threshold, it no longer forces a token refresh once known, and the Oura card asks for one reconnect instead. The Withings card names the readings the account's API plan withholds.
This commit is contained in:
1 parent
39b0cabb43
commit
044f9008ba
29 files changed
+824
-101
No files matched your search
@@ -75326,6 +75326,36 @@ components:
|
||||
hasActivityScope:
|
||||
description: Withings only.
|
||||
type: boolean
|
||||
planUnavailable:
|
||||
description: Withings only. The readings this connection's Withings API plan withholds (status 277 since the 2026-10-12
|
||||
plan split), deduplicated and in display order. Empty when the plan refuses nothing. The sync skips these
|
||||
and keeps importing everything else; the list refreshes when the weekly re-probe asks again.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- spo2
|
||||
- vascularAge
|
||||
- pulseWaveVelocity
|
||||
- vo2Max
|
||||
- visceralFat
|
||||
- skinTemperature
|
||||
- ecg
|
||||
- sleepScore
|
||||
- sleepHrv
|
||||
- sleepHeartRate
|
||||
- sleepRespiratoryRate
|
||||
- sleepSpo2
|
||||
- bodyTemperature
|
||||
- bodyFat
|
||||
- fatMass
|
||||
- fatFreeMass
|
||||
- muscleMass
|
||||
- bodyWater
|
||||
- boneMass
|
||||
- heartRate
|
||||
- bloodPressure
|
||||
- weight
|
||||
needsReauth:
|
||||
description: Google Health only.
|
||||
type: boolean
|
||||
@@ -75335,6 +75365,18 @@ components:
|
||||
hasOwnCredentials:
|
||||
description: Oura / Polar / Strava only.
|
||||
type: boolean
|
||||
scopeReconnect:
|
||||
description: "Oura only. Collections the stored grant does not cover: they answered 401 while sibling collections
|
||||
succeeded with the same token. Not a failure and not counted toward the reconnect threshold; one reconnect
|
||||
asks for the missing scopes. Empty when the grant covers everything HealthLog reads."
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- spo2
|
||||
- vo2max
|
||||
- cardio_age
|
||||
- resilience
|
||||
hasToken:
|
||||
description: Nightscout only.
|
||||
type: boolean
|
||||
|
||||
@@ -6505,6 +6505,47 @@
|
||||
"body": "Deine Withings-Verbindung wurde autorisiert, bevor der Aktivitäts-Sync hinzugefügt wurde. Verbinde einmal neu, um Schritte, aktive Energie, Geh- und Laufdistanz sowie Stockwerke aus deinen Withings-Geräten zu übernehmen.",
|
||||
"action": "Withings neu verbinden"
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"title": "Einige Werte brauchen den kostenpflichtigen API-Plan von Withings",
|
||||
"body": "Withings bietet {list} jetzt nur noch im kostenpflichtigen API-Plan an, und deine Withings-Entwickler-App nutzt den kostenlosen Plan. Gewicht, Blutdruck, Körperzusammensetzung, Herzfrequenz und Schlaf werden weiter synchronisiert.",
|
||||
"link": "Mehr zu den API-Plänen von Withings",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vascularAge": "Gefäßalter",
|
||||
"pulseWaveVelocity": "Pulswellengeschwindigkeit",
|
||||
"vo2Max": "VO2max",
|
||||
"visceralFat": "Viszeralfett",
|
||||
"skinTemperature": "Hauttemperatur",
|
||||
"ecg": "EKG und Vorhofflimmern",
|
||||
"sleepScore": "Schlaf-Score",
|
||||
"sleepHrv": "nächtliche HRV",
|
||||
"sleepHeartRate": "nächtliche Herzfrequenz",
|
||||
"sleepRespiratoryRate": "nächtliche Atemfrequenz",
|
||||
"sleepSpo2": "nächtliche SpO2",
|
||||
"bodyTemperature": "Körpertemperatur",
|
||||
"bodyFat": "Körperfett",
|
||||
"fatMass": "Fettmasse",
|
||||
"fatFreeMass": "fettfreie Masse",
|
||||
"muscleMass": "Muskelmasse",
|
||||
"bodyWater": "Körperwasser",
|
||||
"boneMass": "Knochenmasse",
|
||||
"heartRate": "Herzfrequenz",
|
||||
"bloodPressure": "Blutdruck",
|
||||
"weight": "Gewicht"
|
||||
}
|
||||
}
|
||||
},
|
||||
"oura": {
|
||||
"scope": {
|
||||
"body": "Verbinde Oura einmal neu, um {list} freizugeben. Alles andere wird weiter synchronisiert.",
|
||||
"action": "Oura neu verbinden",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vo2max": "VO2max",
|
||||
"cardio_age": "kardiovaskuläres Alter",
|
||||
"resilience": "Resilienz"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6505,6 +6505,47 @@
|
||||
"body": "Your Withings connection was authorised before activity sync was added. Reconnect once to enable steps, active energy, walking + running distance, and floors-climbed ingest from your Withings devices.",
|
||||
"action": "Reconnect Withings"
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"title": "Some readings need Withings' paid API plan",
|
||||
"body": "Withings now offers {list} only in its paid API plan, and your Withings developer app is on the free plan. Weight, blood pressure, body composition, heart rate and sleep keep syncing.",
|
||||
"link": "About the Withings API plans",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vascularAge": "vascular age",
|
||||
"pulseWaveVelocity": "pulse wave velocity",
|
||||
"vo2Max": "VO2 max",
|
||||
"visceralFat": "visceral fat",
|
||||
"skinTemperature": "skin temperature",
|
||||
"ecg": "ECG and AFib",
|
||||
"sleepScore": "sleep score",
|
||||
"sleepHrv": "overnight HRV",
|
||||
"sleepHeartRate": "overnight heart rate",
|
||||
"sleepRespiratoryRate": "overnight breathing rate",
|
||||
"sleepSpo2": "overnight SpO2",
|
||||
"bodyTemperature": "body temperature",
|
||||
"bodyFat": "body fat",
|
||||
"fatMass": "fat mass",
|
||||
"fatFreeMass": "fat-free mass",
|
||||
"muscleMass": "muscle mass",
|
||||
"bodyWater": "body water",
|
||||
"boneMass": "bone mass",
|
||||
"heartRate": "heart rate",
|
||||
"bloodPressure": "blood pressure",
|
||||
"weight": "weight"
|
||||
}
|
||||
}
|
||||
},
|
||||
"oura": {
|
||||
"scope": {
|
||||
"body": "Reconnect Oura once to allow {list}. Everything else keeps syncing.",
|
||||
"action": "Reconnect Oura",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vo2max": "VO2 max",
|
||||
"cardio_age": "cardiovascular age",
|
||||
"resilience": "resilience"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6505,6 +6505,47 @@
|
||||
"body": "Tu conexión con Withings se autorizó antes de añadir la sincronización de actividad. Vuelve a conectar una vez para habilitar la importación de pasos, energía activa, distancia caminando y corriendo, y pisos subidos desde tus dispositivos Withings.",
|
||||
"action": "Reconectar Withings"
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"title": "Algunos valores requieren el plan de API de pago de Withings",
|
||||
"body": "Withings ahora ofrece {list} solo en su plan de API de pago, y tu app de desarrollador de Withings usa el plan gratuito. El peso, la presión arterial, la composición corporal, la frecuencia cardíaca y el sueño se siguen sincronizando.",
|
||||
"link": "Sobre los planes de API de Withings",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vascularAge": "edad vascular",
|
||||
"pulseWaveVelocity": "velocidad de onda de pulso",
|
||||
"vo2Max": "VO2 máx.",
|
||||
"visceralFat": "grasa visceral",
|
||||
"skinTemperature": "temperatura de la piel",
|
||||
"ecg": "ECG y fibrilación auricular",
|
||||
"sleepScore": "puntuación de sueño",
|
||||
"sleepHrv": "VFC nocturna",
|
||||
"sleepHeartRate": "frecuencia cardíaca nocturna",
|
||||
"sleepRespiratoryRate": "frecuencia respiratoria nocturna",
|
||||
"sleepSpo2": "SpO2 nocturna",
|
||||
"bodyTemperature": "temperatura corporal",
|
||||
"bodyFat": "grasa corporal",
|
||||
"fatMass": "masa grasa",
|
||||
"fatFreeMass": "masa libre de grasa",
|
||||
"muscleMass": "masa muscular",
|
||||
"bodyWater": "agua corporal",
|
||||
"boneMass": "masa ósea",
|
||||
"heartRate": "frecuencia cardíaca",
|
||||
"bloodPressure": "presión arterial",
|
||||
"weight": "peso"
|
||||
}
|
||||
}
|
||||
},
|
||||
"oura": {
|
||||
"scope": {
|
||||
"body": "Vuelve a conectar Oura una vez para permitir {list}. Todo lo demás se sigue sincronizando.",
|
||||
"action": "Volver a conectar Oura",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vo2max": "VO2 máx.",
|
||||
"cardio_age": "edad cardiovascular",
|
||||
"resilience": "resiliencia"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6505,6 +6505,47 @@
|
||||
"body": "Votre connexion Withings a été autorisée avant l’ajout de la synchronisation d’activité. Reconnectez-vous une fois pour activer l’import des pas, de l’énergie active, de la distance à pied et en course, et des étages gravis depuis vos appareils Withings.",
|
||||
"action": "Reconnecter Withings"
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"title": "Certaines mesures nécessitent l’offre API payante de Withings",
|
||||
"body": "Withings ne propose désormais {list} que dans son offre API payante, et votre application développeur Withings utilise l’offre gratuite. Le poids, la tension artérielle, la composition corporelle, la fréquence cardiaque et le sommeil continuent d’être synchronisés.",
|
||||
"link": "En savoir plus sur les offres API de Withings",
|
||||
"items": {
|
||||
"spo2": "la SpO2",
|
||||
"vascularAge": "l’âge vasculaire",
|
||||
"pulseWaveVelocity": "la vitesse de l’onde de pouls",
|
||||
"vo2Max": "la VO2 max",
|
||||
"visceralFat": "la graisse viscérale",
|
||||
"skinTemperature": "la température cutanée",
|
||||
"ecg": "l’ECG et la fibrillation auriculaire",
|
||||
"sleepScore": "le score de sommeil",
|
||||
"sleepHrv": "la VFC nocturne",
|
||||
"sleepHeartRate": "la fréquence cardiaque nocturne",
|
||||
"sleepRespiratoryRate": "la fréquence respiratoire nocturne",
|
||||
"sleepSpo2": "la SpO2 nocturne",
|
||||
"bodyTemperature": "la température corporelle",
|
||||
"bodyFat": "le taux de graisse corporelle",
|
||||
"fatMass": "la masse grasse",
|
||||
"fatFreeMass": "la masse maigre",
|
||||
"muscleMass": "la masse musculaire",
|
||||
"bodyWater": "l’eau corporelle",
|
||||
"boneMass": "la masse osseuse",
|
||||
"heartRate": "la fréquence cardiaque",
|
||||
"bloodPressure": "la tension artérielle",
|
||||
"weight": "le poids"
|
||||
}
|
||||
}
|
||||
},
|
||||
"oura": {
|
||||
"scope": {
|
||||
"body": "Reconnectez Oura une fois pour autoriser {list}. Tout le reste continue d’être synchronisé.",
|
||||
"action": "Reconnecter Oura",
|
||||
"items": {
|
||||
"spo2": "la SpO2",
|
||||
"vo2max": "la VO2 max",
|
||||
"cardio_age": "l’âge cardiovasculaire",
|
||||
"resilience": "la résilience"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6505,6 +6505,47 @@
|
||||
"body": "La tua connessione Withings è stata autorizzata prima dell’aggiunta della sincronizzazione attività. Riconnettiti una volta per abilitare l’importazione di passi, energia attiva, distanza a piedi e di corsa, e piani saliti dai tuoi dispositivi Withings.",
|
||||
"action": "Riconnetti Withings"
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"title": "Alcuni valori richiedono il piano API a pagamento di Withings",
|
||||
"body": "Withings ora offre {list} solo nel suo piano API a pagamento, e la tua app sviluppatore Withings usa il piano gratuito. Peso, pressione arteriosa, composizione corporea, frequenza cardiaca e sonno continuano a sincronizzarsi.",
|
||||
"link": "Informazioni sui piani API di Withings",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vascularAge": "età vascolare",
|
||||
"pulseWaveVelocity": "velocità dell’onda di polso",
|
||||
"vo2Max": "VO2 max",
|
||||
"visceralFat": "grasso viscerale",
|
||||
"skinTemperature": "temperatura cutanea",
|
||||
"ecg": "ECG e fibrillazione atriale",
|
||||
"sleepScore": "punteggio del sonno",
|
||||
"sleepHrv": "HRV notturna",
|
||||
"sleepHeartRate": "frequenza cardiaca notturna",
|
||||
"sleepRespiratoryRate": "frequenza respiratoria notturna",
|
||||
"sleepSpo2": "SpO2 notturna",
|
||||
"bodyTemperature": "temperatura corporea",
|
||||
"bodyFat": "grasso corporeo",
|
||||
"fatMass": "massa grassa",
|
||||
"fatFreeMass": "massa magra",
|
||||
"muscleMass": "massa muscolare",
|
||||
"bodyWater": "acqua corporea",
|
||||
"boneMass": "massa ossea",
|
||||
"heartRate": "frequenza cardiaca",
|
||||
"bloodPressure": "pressione arteriosa",
|
||||
"weight": "peso"
|
||||
}
|
||||
}
|
||||
},
|
||||
"oura": {
|
||||
"scope": {
|
||||
"body": "Riconnetti Oura una volta per consentire {list}. Tutto il resto continua a sincronizzarsi.",
|
||||
"action": "Riconnetti Oura",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vo2max": "VO2 max",
|
||||
"cardio_age": "età cardiovascolare",
|
||||
"resilience": "resilienza"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6505,6 +6505,47 @@
|
||||
"body": "활동 동기화가 추가되기 전에 Withings 연결을 승인했어요. 한 번만 다시 연결하면 Withings 기기에서 걸음 수, 활동 에너지, 걷기·달리기 거리, 오른 층수를 가져올 수 있어요.",
|
||||
"action": "Withings 다시 연결"
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"title": "일부 측정값은 Withings 유료 API 요금제가 필요해요",
|
||||
"body": "Withings는 이제 {list} 데이터를 유료 API 요금제에서만 제공하고, 사용 중인 Withings 개발자 앱은 무료 요금제예요. 체중, 혈압, 체성분, 심박수, 수면은 계속 동기화돼요.",
|
||||
"link": "Withings API 요금제 알아보기",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vascularAge": "혈관 나이",
|
||||
"pulseWaveVelocity": "맥파 속도",
|
||||
"vo2Max": "VO2 max",
|
||||
"visceralFat": "내장 지방",
|
||||
"skinTemperature": "피부 온도",
|
||||
"ecg": "심전도와 심방세동",
|
||||
"sleepScore": "수면 점수",
|
||||
"sleepHrv": "야간 HRV",
|
||||
"sleepHeartRate": "야간 심박수",
|
||||
"sleepRespiratoryRate": "야간 호흡수",
|
||||
"sleepSpo2": "야간 SpO2",
|
||||
"bodyTemperature": "체온",
|
||||
"bodyFat": "체지방률",
|
||||
"fatMass": "체지방량",
|
||||
"fatFreeMass": "제지방량",
|
||||
"muscleMass": "근육량",
|
||||
"bodyWater": "체수분",
|
||||
"boneMass": "골량",
|
||||
"heartRate": "심박수",
|
||||
"bloodPressure": "혈압",
|
||||
"weight": "체중"
|
||||
}
|
||||
}
|
||||
},
|
||||
"oura": {
|
||||
"scope": {
|
||||
"body": "{list} 데이터를 허용하려면 Oura를 한 번 다시 연결하세요. 나머지는 계속 동기화돼요.",
|
||||
"action": "Oura 다시 연결",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vo2max": "VO2 max",
|
||||
"cardio_age": "심혈관 나이",
|
||||
"resilience": "회복탄력성"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6505,6 +6505,47 @@
|
||||
"body": "Twoje połączenie z Withings zostało autoryzowane przed dodaniem synchronizacji aktywności. Połącz ponownie jeden raz, aby włączyć import kroków, energii aktywnej, dystansu marszu i biegu oraz pokonanych pięter z urządzeń Withings.",
|
||||
"action": "Połącz ponownie Withings"
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"title": "Niektóre pomiary wymagają płatnego planu API Withings",
|
||||
"body": "Withings udostępnia teraz te dane tylko w płatnym planie API: {list}. Twoja aplikacja deweloperska Withings korzysta z bezpłatnego planu. Waga, ciśnienie krwi, skład ciała, tętno i sen są nadal synchronizowane.",
|
||||
"link": "Więcej o planach API Withings",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vascularAge": "wiek naczyniowy",
|
||||
"pulseWaveVelocity": "prędkość fali tętna",
|
||||
"vo2Max": "VO2 max",
|
||||
"visceralFat": "tłuszcz trzewny",
|
||||
"skinTemperature": "temperatura skóry",
|
||||
"ecg": "EKG i migotanie przedsionków",
|
||||
"sleepScore": "wynik snu",
|
||||
"sleepHrv": "nocne HRV",
|
||||
"sleepHeartRate": "nocne tętno",
|
||||
"sleepRespiratoryRate": "nocna częstość oddechów",
|
||||
"sleepSpo2": "nocne SpO2",
|
||||
"bodyTemperature": "temperatura ciała",
|
||||
"bodyFat": "tkanka tłuszczowa",
|
||||
"fatMass": "masa tłuszczowa",
|
||||
"fatFreeMass": "beztłuszczowa masa ciała",
|
||||
"muscleMass": "masa mięśniowa",
|
||||
"bodyWater": "woda w organizmie",
|
||||
"boneMass": "masa kostna",
|
||||
"heartRate": "tętno",
|
||||
"bloodPressure": "ciśnienie krwi",
|
||||
"weight": "waga"
|
||||
}
|
||||
}
|
||||
},
|
||||
"oura": {
|
||||
"scope": {
|
||||
"body": "Połącz ponownie Oura jeden raz, aby zezwolić na te dane: {list}. Wszystko inne jest nadal synchronizowane.",
|
||||
"action": "Połącz ponownie Oura",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vo2max": "VO2 max",
|
||||
"cardio_age": "wiek sercowo-naczyniowy",
|
||||
"resilience": "odporność"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -103,6 +103,8 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
// "{hours} h {minutes} min": the SI symbols for hour and minute.
|
||||
"measurements.durationHoursMinutes",
|
||||
"settings.sections.sources.metrics.vo2Max",
|
||||
"settings.integrations.withings.plan.items.vo2Max",
|
||||
"settings.integrations.oura.scope.items.vo2max",
|
||||
// Brand / product / proper nouns.
|
||||
"settings.googleHealth",
|
||||
"admin.glitchtipDsn",
|
||||
|
||||
@@ -35,7 +35,19 @@ import {
|
||||
resolveSyncVerdict,
|
||||
type SyncHealth,
|
||||
} from "@/lib/integrations/sync-verdict";
|
||||
import { getOuraClientCredentials } from "@/lib/oura/credentials";
|
||||
import {
|
||||
getOuraClientCredentials,
|
||||
readOuraScopeDenied,
|
||||
} from "@/lib/oura/credentials";
|
||||
import {
|
||||
ouraScopeReconnectList,
|
||||
type OuraScopeGatedCollection,
|
||||
} from "@/lib/oura/scope";
|
||||
import { parseWithingsApiPlanState } from "@/lib/withings/api-plan";
|
||||
import {
|
||||
withingsPlanUnavailableFeatures,
|
||||
type WithingsPlanFeature,
|
||||
} from "@/lib/withings/plan-features";
|
||||
import { getPolarClientCredentials } from "@/lib/polar/credentials";
|
||||
import { getStravaClientCredentials } from "@/lib/strava/credentials";
|
||||
import { hasActivityScope } from "@/lib/withings/client";
|
||||
@@ -92,6 +104,7 @@ export const GET = apiHandler(async () => {
|
||||
ouraAccessTokenEncrypted: true,
|
||||
ouraClientIdEncrypted: true,
|
||||
ouraClientSecretEncrypted: true,
|
||||
ouraScopeDenied: true,
|
||||
stravaAccessTokenEncrypted: true,
|
||||
stravaClientIdEncrypted: true,
|
||||
stravaClientSecretEncrypted: true,
|
||||
@@ -109,6 +122,7 @@ export const GET = apiHandler(async () => {
|
||||
lastSyncedAt: true,
|
||||
createdAt: true,
|
||||
scope: true,
|
||||
apiPlanState: true,
|
||||
},
|
||||
}),
|
||||
prisma.whoopConnection.findUnique({
|
||||
@@ -280,6 +294,13 @@ export const GET = apiHandler(async () => {
|
||||
// Null `scope` = legacy connection that predates activity-scope reads.
|
||||
scope: withingsConn?.scope ?? null,
|
||||
hasActivityScope: hasActivityScope(withingsConn?.scope ?? null),
|
||||
// What this connection's Withings API plan withholds (status 277),
|
||||
// resolved here so the card only renders it.
|
||||
planUnavailable: withingsConn
|
||||
? withingsPlanUnavailableFeatures(
|
||||
parseWithingsApiPlanState(withingsConn.apiPlanState),
|
||||
)
|
||||
: [],
|
||||
...resolve("withings", withingsStatus, {
|
||||
connected: !!withingsConn,
|
||||
configured: withingsConfigured,
|
||||
@@ -374,11 +395,17 @@ export const GET = apiHandler(async () => {
|
||||
hasOwnCredentials:
|
||||
!!dbUser?.ouraClientIdEncrypted &&
|
||||
!!dbUser?.ouraClientSecretEncrypted,
|
||||
// Collections the stored grant does not cover; one reconnect asks for
|
||||
// the missing scopes. Not a failure, so it never colours the pill.
|
||||
scopeReconnect: ouraConnected
|
||||
? ouraScopeReconnectList(readOuraScopeDenied(dbUser?.ouraScopeDenied))
|
||||
: [],
|
||||
...resolve("oura", ouraStatus, {
|
||||
connected: ouraConnected,
|
||||
configured: ouraConnected,
|
||||
}),
|
||||
} satisfies IntegrationViewModel & OAuthProviderExtras,
|
||||
} satisfies IntegrationViewModel &
|
||||
OAuthProviderExtras & { scopeReconnect: OuraScopeGatedCollection[] },
|
||||
{
|
||||
...publish(stravaStatus),
|
||||
connected: stravaConnected,
|
||||
@@ -442,6 +469,7 @@ interface WithingsExtras {
|
||||
tokenExpired: boolean | null;
|
||||
scope: string | null;
|
||||
hasActivityScope: boolean;
|
||||
planUnavailable: WithingsPlanFeature[];
|
||||
}
|
||||
|
||||
interface WhoopExtras {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
@@ -87,6 +88,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
data: {
|
||||
ouraAccessTokenEncrypted: encrypt(tokens.access_token),
|
||||
ouraRefreshTokenEncrypted: encrypt(tokens.refresh_token),
|
||||
// A new grant carries the scopes asked for now; whatever the old one
|
||||
// lacked is found out again by the first sync.
|
||||
ouraScopeDenied: Prisma.DbNull,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
@@ -96,6 +97,7 @@ export const DELETE = apiHandler(async () => {
|
||||
data: {
|
||||
ouraAccessTokenEncrypted: null,
|
||||
ouraRefreshTokenEncrypted: null,
|
||||
ouraScopeDenied: Prisma.DbNull,
|
||||
},
|
||||
});
|
||||
await clearOuraClientCredentials(user.id);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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 { apiSuccess, apiError } from "@/lib/api-response";
|
||||
@@ -37,6 +38,7 @@ export const POST = apiHandler(async () => {
|
||||
data: {
|
||||
ouraAccessTokenEncrypted: null,
|
||||
ouraRefreshTokenEncrypted: null,
|
||||
ouraScopeDenied: Prisma.DbNull,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
|
||||
import { OuraScopeNote } from "./oura-scope-note";
|
||||
import {
|
||||
readSyncOutcome,
|
||||
useSyncOutcomeMessage,
|
||||
@@ -72,6 +73,7 @@ import {
|
||||
type IntegrationDocsProvider,
|
||||
} from "./setup-guide-link";
|
||||
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
|
||||
import type { OuraScopeGatedCollection } from "@/lib/oura/scope";
|
||||
import { loadDocument } from "@/lib/navigation/load-document";
|
||||
|
||||
export interface OAuthProviderStatus {
|
||||
@@ -99,6 +101,8 @@ export interface OAuthProviderStatus {
|
||||
/** The server-resolved liveness verdict; the pill is a projection of it. */
|
||||
syncHealth?: SyncHealth;
|
||||
metricFreshness?: MetricFreshnessEntry[];
|
||||
/** Oura: collections the stored grant does not cover. */
|
||||
scopeReconnect?: OuraScopeGatedCollection[];
|
||||
}
|
||||
|
||||
export interface OAuthProviderCardProps {
|
||||
@@ -330,6 +334,14 @@ export function OAuthProviderCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider === "oura" && status?.connected && (
|
||||
<OuraScopeNote
|
||||
collections={status.scopeReconnect}
|
||||
onReconnect={handleConnect}
|
||||
disabled={serverUnavailable}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status?.connected && (
|
||||
<MetricFreshnessDisclosure
|
||||
entries={status.metricFreshness}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { Link2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { OuraScopeGatedCollection } from "@/lib/oura/scope";
|
||||
|
||||
/**
|
||||
* A calm note on the Oura card when the stored grant does not cover some
|
||||
* collections (they answer 401 while their siblings succeed). Not an error and
|
||||
* not counted toward the reconnect threshold: everything else keeps syncing,
|
||||
* and one reconnect asks Oura for the missing scopes.
|
||||
*/
|
||||
export function OuraScopeNote({
|
||||
collections,
|
||||
onReconnect,
|
||||
disabled,
|
||||
}: {
|
||||
collections: readonly OuraScopeGatedCollection[] | undefined;
|
||||
onReconnect: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
if (!collections || collections.length === 0) return null;
|
||||
|
||||
const labels: Record<OuraScopeGatedCollection, string> = {
|
||||
spo2: t("settings.integrations.oura.scope.items.spo2"),
|
||||
vo2max: t("settings.integrations.oura.scope.items.vo2max"),
|
||||
cardio_age: t("settings.integrations.oura.scope.items.cardio_age"),
|
||||
resilience: t("settings.integrations.oura.scope.items.resilience"),
|
||||
};
|
||||
const list = new Intl.ListFormat(locale, {
|
||||
style: "long",
|
||||
type: "conjunction",
|
||||
}).format(collections.map((c) => labels[c]));
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="oura-scope-note"
|
||||
className="border-info/30 bg-info/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<p className="min-w-0 break-words">
|
||||
{t("settings.integrations.oura.scope.body", { list })}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onReconnect}
|
||||
disabled={disabled}
|
||||
data-testid="oura-scope-reconnect"
|
||||
className="min-h-11"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t("settings.integrations.oura.scope.action")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import type {
|
||||
SyncVerdict,
|
||||
} from "@/lib/integrations/sync-verdict";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { WithingsPlanFeature } from "@/lib/withings/plan-features";
|
||||
import type { OuraScopeGatedCollection } from "@/lib/oura/scope";
|
||||
|
||||
// v1.4.15 Phase B2: shared status payload for both integration cards.
|
||||
// v1.4.19 Phase A5: the redundant in-card status banner is gone — the
|
||||
@@ -70,6 +72,10 @@ export interface IntegrationStatusViewModel {
|
||||
// Withings activity-scope reconnect banner.
|
||||
scope?: string | null;
|
||||
hasActivityScope?: boolean;
|
||||
// Withings: readings the account's API plan withholds (server-resolved).
|
||||
planUnavailable?: WithingsPlanFeature[];
|
||||
// Oura: collections the stored grant does not cover (server-resolved).
|
||||
scopeReconnect?: OuraScopeGatedCollection[];
|
||||
// WHOOP / Fitbit backfill-in-progress note.
|
||||
backfillCompleted?: boolean | null;
|
||||
// v1.27.0 — Google Health surfaces `needsReauth` when the refresh token has
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
type IntegrationStatusViewModel,
|
||||
} from "./shared";
|
||||
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
|
||||
import { WithingsPlanNote } from "./withings-plan-note";
|
||||
import {
|
||||
readSyncOutcome,
|
||||
useSyncOutcomeMessage,
|
||||
@@ -316,6 +317,10 @@ export function WithingsCard({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status?.connected && (
|
||||
<WithingsPlanNote features={status.planUnavailable} />
|
||||
)}
|
||||
|
||||
{status?.connected && (
|
||||
<MetricFreshnessDisclosure
|
||||
entries={viewModel?.metricFreshness}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
WITHINGS_API_PLANS_URL,
|
||||
type WithingsPlanFeature,
|
||||
} from "@/lib/withings/plan-features";
|
||||
|
||||
/**
|
||||
* A calm note on the Withings card when the account's API plan withholds some
|
||||
* readings (status 277 since the 2026-10-12 plan split). Not an error: the
|
||||
* sync skips those readings and keeps importing the rest, so this sits in the
|
||||
* card body in the info wash, never in the pill.
|
||||
*/
|
||||
export function WithingsPlanNote({
|
||||
features,
|
||||
}: {
|
||||
features: readonly WithingsPlanFeature[] | undefined;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
if (!features || features.length === 0) return null;
|
||||
|
||||
const labels: Record<WithingsPlanFeature, string> = {
|
||||
spo2: t("settings.integrations.withings.plan.items.spo2"),
|
||||
vascularAge: t("settings.integrations.withings.plan.items.vascularAge"),
|
||||
pulseWaveVelocity: t(
|
||||
"settings.integrations.withings.plan.items.pulseWaveVelocity",
|
||||
),
|
||||
vo2Max: t("settings.integrations.withings.plan.items.vo2Max"),
|
||||
visceralFat: t("settings.integrations.withings.plan.items.visceralFat"),
|
||||
skinTemperature: t(
|
||||
"settings.integrations.withings.plan.items.skinTemperature",
|
||||
),
|
||||
ecg: t("settings.integrations.withings.plan.items.ecg"),
|
||||
sleepScore: t("settings.integrations.withings.plan.items.sleepScore"),
|
||||
sleepHrv: t("settings.integrations.withings.plan.items.sleepHrv"),
|
||||
sleepHeartRate: t(
|
||||
"settings.integrations.withings.plan.items.sleepHeartRate",
|
||||
),
|
||||
sleepRespiratoryRate: t(
|
||||
"settings.integrations.withings.plan.items.sleepRespiratoryRate",
|
||||
),
|
||||
sleepSpo2: t("settings.integrations.withings.plan.items.sleepSpo2"),
|
||||
bodyTemperature: t(
|
||||
"settings.integrations.withings.plan.items.bodyTemperature",
|
||||
),
|
||||
bodyFat: t("settings.integrations.withings.plan.items.bodyFat"),
|
||||
fatMass: t("settings.integrations.withings.plan.items.fatMass"),
|
||||
fatFreeMass: t("settings.integrations.withings.plan.items.fatFreeMass"),
|
||||
muscleMass: t("settings.integrations.withings.plan.items.muscleMass"),
|
||||
bodyWater: t("settings.integrations.withings.plan.items.bodyWater"),
|
||||
boneMass: t("settings.integrations.withings.plan.items.boneMass"),
|
||||
heartRate: t("settings.integrations.withings.plan.items.heartRate"),
|
||||
bloodPressure: t("settings.integrations.withings.plan.items.bloodPressure"),
|
||||
weight: t("settings.integrations.withings.plan.items.weight"),
|
||||
};
|
||||
const list = new Intl.ListFormat(locale, {
|
||||
style: "long",
|
||||
type: "conjunction",
|
||||
}).format(features.map((f) => labels[f]));
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="withings-plan-note"
|
||||
className="border-info/30 bg-info/10 space-y-1 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<p className="font-medium">
|
||||
{t("settings.integrations.withings.plan.title")}
|
||||
</p>
|
||||
<p>{t("settings.integrations.withings.plan.body", { list })}</p>
|
||||
<a
|
||||
href={WITHINGS_API_PLANS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary inline-flex items-center gap-1 text-xs font-medium underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("settings.integrations.withings.plan.link")}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -439,6 +439,7 @@ export const USER_RESET = {
|
||||
ouraRefreshTokenEncrypted: null,
|
||||
ouraClientIdEncrypted: null,
|
||||
ouraClientSecretEncrypted: null,
|
||||
ouraScopeDenied: Prisma.DbNull,
|
||||
stravaClientIdEncrypted: null,
|
||||
stravaClientSecretEncrypted: null,
|
||||
stravaAccessTokenEncrypted: null,
|
||||
|
||||
@@ -885,6 +885,9 @@ export const USER_COLUMN_BACKUP_CLASS = {
|
||||
ouraRefreshTokenEncrypted: "CREDENTIAL",
|
||||
ouraClientIdEncrypted: "CREDENTIAL",
|
||||
ouraClientSecretEncrypted: "CREDENTIAL",
|
||||
// What the Oura grant on this host does not cover; meaningless beside a
|
||||
// grant that does not travel, and found out again by the first sync.
|
||||
ouraScopeDenied: "CREDENTIAL",
|
||||
stravaClientIdEncrypted: "CREDENTIAL",
|
||||
stravaClientSecretEncrypted: "CREDENTIAL",
|
||||
stravaAccessTokenEncrypted: "CREDENTIAL",
|
||||
|
||||
@@ -34,6 +34,8 @@ import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { fitbitCredentialsSchema } from "@/lib/validations/fitbit";
|
||||
import { googleHealthCredentialsSchema } from "@/lib/validations/google-health";
|
||||
import { WITHINGS_PLAN_FEATURES } from "@/lib/withings/plan-features";
|
||||
import { OURA_SCOPE_GATED_COLLECTIONS } from "@/lib/oura/scope";
|
||||
import { ouraCredentialsSchema } from "@/lib/validations/oura";
|
||||
import { polarCredentialsSchema } from "@/lib/validations/polar";
|
||||
import { stravaCredentialsSchema } from "@/lib/validations/strava";
|
||||
@@ -925,12 +927,24 @@ const integrationEnvelopeEntry = z
|
||||
backfillCompleted: z.boolean().nullable().optional(),
|
||||
scope: z.string().nullable().optional().describe("Withings only."),
|
||||
hasActivityScope: z.boolean().optional().describe("Withings only."),
|
||||
planUnavailable: z
|
||||
.array(z.enum(WITHINGS_PLAN_FEATURES))
|
||||
.optional()
|
||||
.describe(
|
||||
"Withings only. The readings this connection's Withings API plan withholds (status 277 since the 2026-10-12 plan split), deduplicated and in display order. Empty when the plan refuses nothing. The sync skips these and keeps importing everything else; the list refreshes when the weekly re-probe asks again.",
|
||||
),
|
||||
needsReauth: z.boolean().optional().describe("Google Health only."),
|
||||
available: z.boolean().optional().describe("Oura / Polar / Strava only."),
|
||||
hasOwnCredentials: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Oura / Polar / Strava only."),
|
||||
scopeReconnect: z
|
||||
.array(z.enum(OURA_SCOPE_GATED_COLLECTIONS))
|
||||
.optional()
|
||||
.describe(
|
||||
"Oura only. Collections the stored grant does not cover: they answered 401 while sibling collections succeeded with the same token. Not a failure and not counted toward the reconnect threshold; one reconnect asks for the missing scopes. Empty when the grant covers everything HealthLog reads.",
|
||||
),
|
||||
hasToken: z.boolean().optional().describe("Nightscout only."),
|
||||
allowPrivateHost: z.boolean().optional().describe("Nightscout only."),
|
||||
})
|
||||
|
||||
@@ -670,8 +670,13 @@ describe("mapResilience", () => {
|
||||
});
|
||||
|
||||
describe("OURA_OAUTH_SCOPE", () => {
|
||||
it("requests daily + personal", () => {
|
||||
expect(OURA_OAUTH_SCOPE).toBe("daily personal");
|
||||
it("requests daily, personal and the scopes behind SpO2 and heart health", () => {
|
||||
expect(OURA_OAUTH_SCOPE.split(" ")).toEqual([
|
||||
"daily",
|
||||
"personal",
|
||||
"spo2",
|
||||
"heart_health",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ const {
|
||||
invalidateMock,
|
||||
transactionMock,
|
||||
reconcileMock,
|
||||
scopeDeniedMock,
|
||||
} = vi.hoisted(() => ({
|
||||
getConnMock: vi.fn(),
|
||||
storeTokensMock: vi.fn(),
|
||||
@@ -60,12 +61,14 @@ const {
|
||||
invalidateMock: vi.fn(),
|
||||
transactionMock: vi.fn(),
|
||||
reconcileMock: vi.fn(),
|
||||
scopeDeniedMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../credentials", () => ({
|
||||
getOuraConnection: getConnMock,
|
||||
storeOuraTokens: storeTokensMock,
|
||||
getOuraClientCredentials: getCredsMock,
|
||||
syncOuraScopeDenied: scopeDeniedMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -159,6 +162,7 @@ beforeEach(() => {
|
||||
fetchResilienceMock.mockReset().mockResolvedValue([]);
|
||||
fetchCyclePhasesMock.mockReset().mockResolvedValue([]);
|
||||
refreshMock.mockReset();
|
||||
scopeDeniedMock.mockReset().mockResolvedValue(undefined);
|
||||
transactionMock
|
||||
.mockReset()
|
||||
.mockImplementation(async (run: (tx: unknown) => unknown) => run({}));
|
||||
|
||||
+18
-2
@@ -109,8 +109,24 @@ export function getOuraRedirectUri(): string {
|
||||
);
|
||||
}
|
||||
|
||||
/** `daily` covers sleep / readiness / activity; `personal` is profile info. */
|
||||
export const OURA_OAUTH_SCOPE = "daily personal" as const;
|
||||
/**
|
||||
* The scopes HealthLog asks Oura for.
|
||||
*
|
||||
* - `daily` : readiness, sleep, daily sleep, daily activity
|
||||
* - `personal` : profile info
|
||||
* - `spo2` : `daily_spo2`
|
||||
* - `heart_health` : `vO2_max`, `daily_cardiovascular_age`,
|
||||
* `daily_resilience`
|
||||
*
|
||||
* Oura's current authorization server enforces these per collection: a grant
|
||||
* without `spo2` or `heart_health` answers 401 on those collections while every
|
||||
* other one succeeds. The published OpenAPI security scheme (openapi-1.41)
|
||||
* lists `spo2` and `heart_health` but maps no endpoint to them; the mapping
|
||||
* above is what the live API enforces, as recorded by integrations that probed
|
||||
* it. Grants issued before these were requested keep the old set until the
|
||||
* user reconnects once — see `syncOuraScopeDenied`.
|
||||
*/
|
||||
export const OURA_OAUTH_SCOPE = "daily personal spo2 heart_health" as const;
|
||||
|
||||
export function getAuthorizationUrl(
|
||||
state: string,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* tokens).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { persistRotatedToken } from "@/lib/integrations/oauth-refresh";
|
||||
import { getOuraCredentials, type OuraCredentials } from "./client";
|
||||
@@ -84,6 +85,8 @@ export interface OuraConnection {
|
||||
* concurrent sync that already rotated the token is not clobbered.
|
||||
*/
|
||||
refreshTokenCiphertext: string;
|
||||
/** Collections the grant was last seen not to cover (`ouraScopeDenied`). */
|
||||
scopeDenied?: string[];
|
||||
}
|
||||
|
||||
export async function getOuraConnection(
|
||||
@@ -94,6 +97,7 @@ export async function getOuraConnection(
|
||||
select: {
|
||||
ouraAccessTokenEncrypted: true,
|
||||
ouraRefreshTokenEncrypted: true,
|
||||
ouraScopeDenied: true,
|
||||
},
|
||||
});
|
||||
if (!user?.ouraAccessTokenEncrypted || !user?.ouraRefreshTokenEncrypted) {
|
||||
@@ -103,6 +107,7 @@ export async function getOuraConnection(
|
||||
accessToken: decrypt(user.ouraAccessTokenEncrypted),
|
||||
refreshToken: decrypt(user.ouraRefreshTokenEncrypted),
|
||||
refreshTokenCiphertext: user.ouraRefreshTokenEncrypted,
|
||||
scopeDenied: readOuraScopeDenied(user.ouraScopeDenied),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,3 +153,37 @@ export async function storeOuraTokens(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Collections the current Oura grant does not cover, by collection name. Read
|
||||
* by the settings envelope; an absent or malformed value reads as none.
|
||||
*/
|
||||
export function readOuraScopeDenied(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(
|
||||
(v): v is string => typeof v === "string" && /^[a-z0-9_]{1,40}$/.test(v),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record which collections the grant does not cover after a sync, writing only
|
||||
* when the set changed so an hourly poll does not touch the user row for
|
||||
* nothing. An empty set clears the column.
|
||||
*/
|
||||
export async function syncOuraScopeDenied(
|
||||
userId: string,
|
||||
denied: readonly string[],
|
||||
): Promise<void> {
|
||||
const next = [...new Set(denied)].sort();
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { ouraScopeDenied: true },
|
||||
});
|
||||
const current = readOuraScopeDenied(row?.ouraScopeDenied).sort();
|
||||
if (current.length === next.length && current.every((v, i) => v === next[i]))
|
||||
return;
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { ouraScopeDenied: next.length > 0 ? next : Prisma.DbNull },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Oura collections that sit behind a scope beyond `daily` (`spo2`,
|
||||
* `heart_health`). A grant issued before HealthLog requested those scopes
|
||||
* answers 401 on exactly these, so they are the only names the settings card
|
||||
* can be asked to explain. Each has a label under
|
||||
* `settings.integrations.oura.scope.items`.
|
||||
*/
|
||||
export const OURA_SCOPE_GATED_COLLECTIONS = [
|
||||
"spo2",
|
||||
"vo2max",
|
||||
"cardio_age",
|
||||
"resilience",
|
||||
] as const;
|
||||
|
||||
export type OuraScopeGatedCollection =
|
||||
(typeof OURA_SCOPE_GATED_COLLECTIONS)[number];
|
||||
|
||||
/** Narrow a stored list to the known names, in display order. */
|
||||
export function ouraScopeReconnectList(
|
||||
denied: readonly string[],
|
||||
): OuraScopeGatedCollection[] {
|
||||
return OURA_SCOPE_GATED_COLLECTIONS.filter((c) => denied.includes(c));
|
||||
}
|
||||
+87
-7
@@ -35,7 +35,7 @@
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
markSyncFailureRecorded,
|
||||
recordSyncFailure,
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
getOuraClientCredentials,
|
||||
getOuraConnection,
|
||||
storeOuraTokens,
|
||||
syncOuraScopeDenied,
|
||||
} from "./credentials";
|
||||
import { OuraApiError, classifyOuraError } from "./response-classifier";
|
||||
import { syncUserOuraCyclePhases } from "./cycle-sync";
|
||||
@@ -243,6 +244,42 @@ export interface OuraFetchResult {
|
||||
* run-indexed `seg:<i>` row — before the fresh set upserts.
|
||||
*/
|
||||
sleepSweeps: SleepSegmentSweep[];
|
||||
/**
|
||||
* Collections refused with 401/403 while a sibling succeeded with the same
|
||||
* token: the token is fine, the grant does not cover them. Not failures; the
|
||||
* card asks for one reconnect instead.
|
||||
*/
|
||||
scopeDenied: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A 401 from Oura: the token, or the grant's scope, was refused. A 403 stays a
|
||||
* plain failure: Oura also answers 403 for a lapsed membership, which a
|
||||
* reconnect does not fix.
|
||||
*/
|
||||
function isAuthRefusal(err: unknown): boolean {
|
||||
return err instanceof OuraApiError && err.httpStatus === 401;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the auth refusals out of a run's failures once a sibling collection has
|
||||
* proven the token. Oura's authorization server enforces scopes per collection,
|
||||
* so a grant issued without `spo2` / `heart_health` answers 401 on exactly the
|
||||
* collections behind them and 200 everywhere else. Counting those toward the
|
||||
* reconnect ladder painted a working connection as broken on every sync, and
|
||||
* refreshing the token over them changed nothing.
|
||||
*/
|
||||
function splitScopeDenied(
|
||||
failures: OuraCollectionFailure[],
|
||||
tokenProven: boolean,
|
||||
): { failures: OuraCollectionFailure[]; scopeDenied: string[] } {
|
||||
if (!tokenProven) return { failures, scopeDenied: [] };
|
||||
return {
|
||||
failures: failures.filter((f) => !isAuthRefusal(f.err)),
|
||||
scopeDenied: failures
|
||||
.filter((f) => isAuthRefusal(f.err))
|
||||
.map((f) => f.name),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,6 +304,7 @@ async function fetchAll(
|
||||
refreshToken: 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
|
||||
@@ -386,6 +424,7 @@ async function fetchAll(
|
||||
failed: OuraCollectionSpec[];
|
||||
failures: OuraCollectionFailure[];
|
||||
auth401: boolean;
|
||||
succeeded: number;
|
||||
}> => {
|
||||
const settled = await Promise.allSettled(
|
||||
specs.map((spec) => spec.collect(token)),
|
||||
@@ -394,9 +433,11 @@ async function fetchAll(
|
||||
const failed: OuraCollectionSpec[] = [];
|
||||
const failures: OuraCollectionFailure[] = [];
|
||||
let auth401 = false;
|
||||
let succeeded = 0;
|
||||
settled.forEach((res, i) => {
|
||||
const spec = specs[i]!;
|
||||
if (res.status === "fulfilled") {
|
||||
succeeded++;
|
||||
readings.push(...res.value);
|
||||
} else {
|
||||
const err = res.reason;
|
||||
@@ -406,12 +447,24 @@ async function fetchAll(
|
||||
failures.push({ name: spec.name, err });
|
||||
}
|
||||
});
|
||||
return { readings, failed, failures, auth401 };
|
||||
return { readings, failed, failures, auth401, succeeded };
|
||||
};
|
||||
|
||||
const first = await attempt(accessToken, collections);
|
||||
if (!first.auth401) {
|
||||
return { readings: first.readings, failures: first.failures, sleepSweeps };
|
||||
// A 401 limited to collections already known to sit outside the grant, next
|
||||
// to collections that succeeded, is the grant's scope again: a refresh cannot
|
||||
// widen it, so none is spent. Any other 401 still takes the one refresh, and
|
||||
// whatever 401s after it beside a success is recorded as scope.
|
||||
const knownDenied = new Set(knownScopeDenied);
|
||||
const onlyKnownScope401 = first.failures
|
||||
.filter((f) => isAuthRefusal(f.err))
|
||||
.every((f) => knownDenied.has(f.name));
|
||||
if (!first.auth401 || (first.succeeded > 0 && onlyKnownScope401)) {
|
||||
return {
|
||||
readings: first.readings,
|
||||
...splitScopeDenied(first.failures, first.succeeded > 0),
|
||||
sleepSweeps,
|
||||
};
|
||||
}
|
||||
|
||||
// Reactive refresh: the access token expired (a 401 on the shared token).
|
||||
@@ -419,7 +472,12 @@ async function fetchAll(
|
||||
// failed. A failed refresh throws so the caller parks reauth_required.
|
||||
const creds = await getOuraClientCredentials(userId);
|
||||
if (!creds)
|
||||
return { readings: first.readings, failures: first.failures, sleepSweeps };
|
||||
return {
|
||||
readings: first.readings,
|
||||
failures: first.failures,
|
||||
sleepSweeps,
|
||||
scopeDenied: [],
|
||||
};
|
||||
|
||||
const rotated = await refreshAccessToken(refreshToken, creds);
|
||||
// Compare-and-swap persist: on a lost race against a concurrent sync this
|
||||
@@ -432,12 +490,19 @@ async function fetchAll(
|
||||
refreshTokenCiphertext,
|
||||
);
|
||||
if (!usableToken)
|
||||
return { readings: first.readings, failures: first.failures, sleepSweeps };
|
||||
return {
|
||||
readings: first.readings,
|
||||
failures: first.failures,
|
||||
sleepSweeps,
|
||||
scopeDenied: [],
|
||||
};
|
||||
|
||||
const retry = await attempt(usableToken, first.failed);
|
||||
return {
|
||||
readings: [...first.readings, ...retry.readings],
|
||||
failures: retry.failures,
|
||||
// Proven by either pass: a collection that succeeded before the refresh
|
||||
// proves the grant as much as one that succeeded after it.
|
||||
...splitScopeDenied(retry.failures, first.succeeded + retry.succeeded > 0),
|
||||
sleepSweeps,
|
||||
};
|
||||
}
|
||||
@@ -472,6 +537,7 @@ export async function syncUserOura(
|
||||
conn.refreshToken,
|
||||
conn.refreshTokenCiphertext,
|
||||
lookbackDays,
|
||||
conn.scopeDenied,
|
||||
);
|
||||
} catch (err) {
|
||||
// Only a whole-connection failure (a failed token refresh → reauth) reaches
|
||||
@@ -482,6 +548,20 @@ export async function syncUserOura(
|
||||
throw markSyncFailureRecorded(err);
|
||||
}
|
||||
|
||||
// Remember which collections the grant does not cover, so the settings card
|
||||
// can ask for one reconnect. Best-effort: the note is a convenience, the
|
||||
// sync outcome does not depend on it.
|
||||
if (result.scopeDenied.length > 0) {
|
||||
annotate({
|
||||
meta: { oura_scope_denied: result.scopeDenied },
|
||||
});
|
||||
}
|
||||
try {
|
||||
await syncOuraScopeDenied(userId, result.scopeDenied);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(`oura: scope note write failed: ${err}`);
|
||||
}
|
||||
|
||||
// Clear whatever an earlier scoring left under the re-fetched sleep records
|
||||
// before the fresh set upserts (mirrors Google Health's replace-by-window
|
||||
// order). Best-effort inside the helper — a sweep failure never fails the
|
||||
|
||||
@@ -69,10 +69,6 @@ export const WITHINGS_ADVANCED_SLEEP_FIELDS: readonly string[] = [
|
||||
"rr_average",
|
||||
];
|
||||
|
||||
/** The Withings plans page, linked from the settings card and the docs. */
|
||||
export const WITHINGS_API_PLANS_URL =
|
||||
"https://static.withings.com/content/api/plans/plans.html";
|
||||
|
||||
// ── error recognition ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -327,90 +323,6 @@ export function shouldPersistPlanLeg(opts: {
|
||||
return opts.gated || opts.reprobed;
|
||||
}
|
||||
|
||||
// ── what the settings card names ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The closed set of reading names the Withings card can show for a plan
|
||||
* refusal, in display order. Server-resolved so every client renders the same
|
||||
* list; each has a label under `settings.integrations.withings.plan.items`.
|
||||
*/
|
||||
export const WITHINGS_PLAN_FEATURES = [
|
||||
"spo2",
|
||||
"vascularAge",
|
||||
"pulseWaveVelocity",
|
||||
"vo2Max",
|
||||
"visceralFat",
|
||||
"skinTemperature",
|
||||
"ecg",
|
||||
"sleepScore",
|
||||
"sleepHrv",
|
||||
"sleepHeartRate",
|
||||
"sleepRespiratoryRate",
|
||||
"sleepSpo2",
|
||||
"bodyTemperature",
|
||||
"bodyFat",
|
||||
"fatMass",
|
||||
"fatFreeMass",
|
||||
"muscleMass",
|
||||
"bodyWater",
|
||||
"boneMass",
|
||||
"heartRate",
|
||||
"bloodPressure",
|
||||
"weight",
|
||||
] as const;
|
||||
|
||||
export type WithingsPlanFeature = (typeof WITHINGS_PLAN_FEATURES)[number];
|
||||
|
||||
const MEASTYPE_FEATURE: Record<number, WithingsPlanFeature> = {
|
||||
1: "weight",
|
||||
5: "fatFreeMass",
|
||||
6: "bodyFat",
|
||||
8: "fatMass",
|
||||
9: "bloodPressure",
|
||||
10: "bloodPressure",
|
||||
11: "heartRate",
|
||||
12: "bodyTemperature",
|
||||
35: "spo2",
|
||||
54: "spo2",
|
||||
71: "bodyTemperature",
|
||||
73: "skinTemperature",
|
||||
76: "muscleMass",
|
||||
77: "bodyWater",
|
||||
88: "boneMass",
|
||||
91: "pulseWaveVelocity",
|
||||
123: "vo2Max",
|
||||
155: "vascularAge",
|
||||
170: "visceralFat",
|
||||
};
|
||||
|
||||
const SLEEP_FIELD_FEATURE: Record<string, WithingsPlanFeature> = {
|
||||
hr_average: "sleepHeartRate",
|
||||
rr_average: "sleepRespiratoryRate",
|
||||
sdnn_1: "sleepHrv",
|
||||
spo2_average: "sleepSpo2",
|
||||
sleep_score: "sleepScore",
|
||||
};
|
||||
|
||||
/**
|
||||
* The readings this connection's plan currently withholds, deduplicated and in
|
||||
* display order. Empty when nothing is refused.
|
||||
*/
|
||||
export function withingsPlanUnavailableFeatures(
|
||||
state: WithingsApiPlanState,
|
||||
): WithingsPlanFeature[] {
|
||||
const found = new Set<WithingsPlanFeature>();
|
||||
for (const t of state.measures?.unavailable ?? []) {
|
||||
const f = MEASTYPE_FEATURE[t];
|
||||
if (f) found.add(f);
|
||||
}
|
||||
for (const field of state.sleepSummary?.unavailable ?? []) {
|
||||
const f = SLEEP_FIELD_FEATURE[field];
|
||||
if (f) found.add(f);
|
||||
}
|
||||
if ((state.ecg?.unavailable.length ?? 0) > 0) found.add("ecg");
|
||||
return WITHINGS_PLAN_FEATURES.filter((f) => found.has(f));
|
||||
}
|
||||
|
||||
// ── lifting parks an earlier build left ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* The readings a Withings API-plan refusal can withhold, as the settings card
|
||||
* names them. Pure: no database, no network, so the OpenAPI registry and the
|
||||
* client bundle can import it.
|
||||
*/
|
||||
import type { WithingsApiPlanState } from "./api-plan";
|
||||
|
||||
/** The Withings plans page, linked from the settings card and the docs. */
|
||||
export const WITHINGS_API_PLANS_URL =
|
||||
"https://static.withings.com/content/api/plans/plans.html";
|
||||
|
||||
/**
|
||||
* The closed set of reading names the Withings card can show for a plan
|
||||
* refusal, in display order. Server-resolved so every client renders the same
|
||||
* list; each has a label under `settings.integrations.withings.plan.items`.
|
||||
*/
|
||||
export const WITHINGS_PLAN_FEATURES = [
|
||||
"spo2",
|
||||
"vascularAge",
|
||||
"pulseWaveVelocity",
|
||||
"vo2Max",
|
||||
"visceralFat",
|
||||
"skinTemperature",
|
||||
"ecg",
|
||||
"sleepScore",
|
||||
"sleepHrv",
|
||||
"sleepHeartRate",
|
||||
"sleepRespiratoryRate",
|
||||
"sleepSpo2",
|
||||
"bodyTemperature",
|
||||
"bodyFat",
|
||||
"fatMass",
|
||||
"fatFreeMass",
|
||||
"muscleMass",
|
||||
"bodyWater",
|
||||
"boneMass",
|
||||
"heartRate",
|
||||
"bloodPressure",
|
||||
"weight",
|
||||
] as const;
|
||||
|
||||
export type WithingsPlanFeature = (typeof WITHINGS_PLAN_FEATURES)[number];
|
||||
|
||||
const MEASTYPE_FEATURE: Record<number, WithingsPlanFeature> = {
|
||||
1: "weight",
|
||||
5: "fatFreeMass",
|
||||
6: "bodyFat",
|
||||
8: "fatMass",
|
||||
9: "bloodPressure",
|
||||
10: "bloodPressure",
|
||||
11: "heartRate",
|
||||
12: "bodyTemperature",
|
||||
35: "spo2",
|
||||
54: "spo2",
|
||||
71: "bodyTemperature",
|
||||
73: "skinTemperature",
|
||||
76: "muscleMass",
|
||||
77: "bodyWater",
|
||||
88: "boneMass",
|
||||
91: "pulseWaveVelocity",
|
||||
123: "vo2Max",
|
||||
155: "vascularAge",
|
||||
170: "visceralFat",
|
||||
};
|
||||
|
||||
const SLEEP_FIELD_FEATURE: Record<string, WithingsPlanFeature> = {
|
||||
hr_average: "sleepHeartRate",
|
||||
rr_average: "sleepRespiratoryRate",
|
||||
sdnn_1: "sleepHrv",
|
||||
spo2_average: "sleepSpo2",
|
||||
sleep_score: "sleepScore",
|
||||
};
|
||||
|
||||
/**
|
||||
* The readings this connection's plan currently withholds, deduplicated and in
|
||||
* display order. Empty when nothing is refused.
|
||||
*/
|
||||
export function withingsPlanUnavailableFeatures(
|
||||
state: WithingsApiPlanState,
|
||||
): WithingsPlanFeature[] {
|
||||
const found = new Set<WithingsPlanFeature>();
|
||||
for (const t of state.measures?.unavailable ?? []) {
|
||||
const f = MEASTYPE_FEATURE[t];
|
||||
if (f) found.add(f);
|
||||
}
|
||||
for (const field of state.sleepSummary?.unavailable ?? []) {
|
||||
const f = SLEEP_FIELD_FEATURE[field];
|
||||
if (f) found.add(f);
|
||||
}
|
||||
if ((state.ecg?.unavailable.length ?? 0) > 0) found.add("ecg");
|
||||
return WITHINGS_PLAN_FEATURES.filter((f) => found.has(f));
|
||||
}
|
||||
Reference in new issue
Block a user