mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge branch 'v1423/integrations' into release/v1.42.3
This commit is contained in:
commit
035ae37ee4
49 files changed
+2715
-27
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
|
||||
|
||||
@@ -25,6 +25,56 @@ Activity data (steps, active energy, distance, floors climbed) lands
|
||||
via the `user.activity` OAuth scope shipped in v1.4.25. Sleep stages
|
||||
arrive via the sleep series endpoint.
|
||||
|
||||
## Withings API plans (October 2026)
|
||||
|
||||
On 12 October 2026 Withings split its public API into plans
|
||||
([plans page](https://static.withings.com/content/api/plans/plans.html)).
|
||||
The developer app a self-hoster registers for HealthLog sits on the free
|
||||
**Start for Free** plan, which covers the core biomarkers: activity, sleep,
|
||||
body and heart. The FDA/CE-cleared biomarkers and a few body-composition
|
||||
extras now need the paid **Advanced Biomarkers** plan.
|
||||
|
||||
A request that names anything outside the plan is refused as a whole with
|
||||
status 277 ("One of the data type requested in this API request is not
|
||||
available in your API plan"). Before v1.42.3 HealthLog asked for every type
|
||||
in one request, so from that date no Withings reading imported at all, and
|
||||
the connection showed "Reauthorisation required" although reconnecting could
|
||||
not help.
|
||||
|
||||
**Still synced on the free plan:** weight, fat mass and fat-free mass, muscle
|
||||
mass, bone mass, body water, blood pressure, heart rate, a manual SpO2
|
||||
reading, body temperature, steps, distance and calories, and the sleep
|
||||
stages (duration, deep, light, REM, awake, wake-ups).
|
||||
|
||||
**Needs the paid Advanced Biomarkers plan** (per Withings'
|
||||
[available health data](https://developer.withings.com/developer-guide/v3/data-api/all-available-health-data/)
|
||||
table): pulse wave velocity (meastype 91), vascular age (155), visceral fat
|
||||
(170), VO2 max (123), skin temperature (73), ECG recordings and AFib
|
||||
verdicts, and the sleep score, overnight HRV and overnight breathing rate
|
||||
from the sleep summary.
|
||||
|
||||
**What HealthLog does about it, automatically:**
|
||||
|
||||
- When Withings refuses a request with 277, HealthLog asks again without
|
||||
the types Withings lists as advanced. If that is still refused, it
|
||||
splits the remaining types until it has found the ones the plan
|
||||
refuses, within a fixed number of requests. Everything the plan allows
|
||||
imports as before.
|
||||
- The refused types are remembered per connection, so later syncs skip
|
||||
them without a refused request. Once a week HealthLog asks for the full
|
||||
set again, so a plan upgrade is picked up on its own.
|
||||
- ECG recordings are skipped quietly while the plan refuses them.
|
||||
- A plan refusal is never counted as a sync failure and never asks you to
|
||||
reconnect. Connections that an earlier version parked over it are
|
||||
released on the next hourly sync, and that sync imports everything since
|
||||
the last successful one.
|
||||
- Settings → Integrations → Withings shows a short note naming the
|
||||
readings your plan withholds, with a link to the Withings plans page.
|
||||
|
||||
Nothing needs to be configured. If you move your developer app to the
|
||||
Advanced Biomarkers plan, the extra readings start arriving within a week,
|
||||
or right away after reconnecting Withings once.
|
||||
|
||||
## 1. Register a Withings developer app
|
||||
|
||||
1. Sign in at <https://developer.withings.com/> and **create a new
|
||||
@@ -167,6 +217,11 @@ fixes the mismatch by updating the URL Withings holds.
|
||||
|
||||
### Sync is stuck on "Reauthorisation required"
|
||||
|
||||
If this started on or after 12 October 2026 and your version is older than
|
||||
v1.42.3, the cause is the API plan change, not your grant: see
|
||||
[Withings API plans (October 2026)](#withings-api-plans-october-2026).
|
||||
Updating is the fix; reconnecting is not needed.
|
||||
|
||||
Withings revokes refresh tokens on password change, app un-authorise,
|
||||
or after extended inactivity. The `WithingsConnection` row flips to
|
||||
`error_reauth` status and the scheduled sync no-ops until the user
|
||||
|
||||
@@ -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ść"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- v1.42.3 — remember what an integration's upstream refuses by contract.
|
||||
--
|
||||
-- Withings split its public API into plans on 2026-10-12. A self-hoster's own
|
||||
-- developer app sits on the free plan, and a request that names a meastype
|
||||
-- outside that plan is refused whole with status 277. The sync now drops the
|
||||
-- gated types and keeps the rest; this column remembers which ones per
|
||||
-- connection so later runs skip them without a refused round trip, plus when
|
||||
-- that was last checked so a plan upgrade is noticed within a week.
|
||||
--
|
||||
-- Oura's current authorization server enforces scopes per collection. A grant
|
||||
-- issued before HealthLog asked for `spo2` and `heart_health` answers 401 on
|
||||
-- the collections behind them while every other collection succeeds. This
|
||||
-- column holds those collection names so the Oura card can ask for a single
|
||||
-- reconnect instead of painting the connection as broken.
|
||||
--
|
||||
-- Additive only: two nullable columns, no default, no rewrite, no data change.
|
||||
ALTER TABLE "withings_connections" ADD COLUMN IF NOT EXISTS "api_plan_state" JSONB;
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "oura_scope_denied" JSONB;
|
||||
@@ -264,6 +264,13 @@ model User {
|
||||
// `src/lib/crypto.ts`, matching every other `*Encrypted` column.
|
||||
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")
|
||||
|
||||
// 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
|
||||
@@ -4149,6 +4156,13 @@ model WithingsConnection {
|
||||
/// earliest transient/pending category retry for indexed hourly discovery.
|
||||
webhookSubscriptionState Json? @map("webhook_subscription_state")
|
||||
webhookSubscriptionRetryAt DateTime? @map("webhook_subscription_retry_at")
|
||||
/// v1.42.3 — what this connection's Withings API plan refuses (status 277),
|
||||
/// per request family: `{ measures, sleepSummary, ecg }`, each
|
||||
/// `{ unavailable, checkedAt }`. Lets the sync skip gated meastypes without
|
||||
/// paying a refused round trip each time, re-probe them weekly, and lets the
|
||||
/// Withings card name what the plan withholds. Versioned and validated on
|
||||
/// read (`src/lib/withings/api-plan.ts`); reset on reconnect.
|
||||
apiPlanState Json? @map("api_plan_state")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -52,7 +52,12 @@ const ouraAvailable = vi.fn(async () => false);
|
||||
vi.mock("@/lib/polar/credentials", () => ({
|
||||
getPolarClientCredentials: () => polarAvailable(),
|
||||
}));
|
||||
vi.mock("@/lib/oura/credentials", () => ({
|
||||
vi.mock("@/lib/oura/credentials", async () => ({
|
||||
readOuraScopeDenied: (
|
||||
await vi.importActual<typeof import("@/lib/oura/credentials")>(
|
||||
"@/lib/oura/credentials",
|
||||
)
|
||||
).readOuraScopeDenied,
|
||||
getOuraClientCredentials: () => ouraAvailable(),
|
||||
}));
|
||||
|
||||
@@ -387,3 +392,55 @@ describe("/api/integrations/status — a provider with no ledger row", () => {
|
||||
expect(whoop.syncHealth?.verdict).toBe("disconnected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/integrations/status — what a provider withholds by contract", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("names the readings the Withings plan withholds and the Oura collections the grant lacks", async () => {
|
||||
(
|
||||
prisma.withingsConnection.findUnique as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValueOnce({
|
||||
tokenExpiresAt: new Date(Date.now() + 3_600_000),
|
||||
lastSyncedAt: null,
|
||||
createdAt: new Date(),
|
||||
scope: "user.metrics,user.activity",
|
||||
apiPlanState: {
|
||||
version: 1,
|
||||
measures: {
|
||||
unavailable: [155, 54],
|
||||
checkedAt: new Date().toISOString(),
|
||||
},
|
||||
ecg: { unavailable: ["heart"], checkedAt: new Date().toISOString() },
|
||||
},
|
||||
});
|
||||
userFind.mockResolvedValue({
|
||||
ouraAccessTokenEncrypted: "tok",
|
||||
ouraScopeDenied: ["resilience", "spo2", "not_a_collection"],
|
||||
});
|
||||
|
||||
const entries = (await fetchEntries()) as Array<
|
||||
Entry & { planUnavailable?: string[]; scopeReconnect?: string[] }
|
||||
>;
|
||||
const withings = entries.find((e) => e.integration === "withings")!;
|
||||
const oura = entries.find((e) => e.integration === "oura")!;
|
||||
|
||||
expect(withings.planUnavailable).toEqual(["spo2", "vascularAge", "ecg"]);
|
||||
expect(oura.scopeReconnect).toEqual(["spo2", "resilience"]);
|
||||
});
|
||||
|
||||
it("publishes empty lists when nothing is withheld", async () => {
|
||||
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "tok" });
|
||||
|
||||
const entries = (await fetchEntries()) as Array<
|
||||
Entry & { planUnavailable?: string[]; scopeReconnect?: string[] }
|
||||
>;
|
||||
expect(
|
||||
entries.find((e) => e.integration === "withings")!.planUnavailable,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
entries.find((e) => e.integration === "oura")!.scopeReconnect,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -190,6 +190,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
scope: WITHINGS_OAUTH_SCOPE,
|
||||
webhookSubscriptionState: Prisma.DbNull,
|
||||
webhookSubscriptionRetryAt: subscriptionRetryAt,
|
||||
// A reconnect may come with another developer app (another plan), so
|
||||
// what the old grant's plan refused is asked for again.
|
||||
apiPlanState: Prisma.DbNull,
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
|
||||
@@ -232,3 +232,43 @@ describe("OAuthProviderCard — sync-now action", () => {
|
||||
).not.toContain('data-testid="polar-sync"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuthProviderCard — Oura scope note", () => {
|
||||
const scopeGap = {
|
||||
connected: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
state: "connected" as const,
|
||||
lastSuccessAt: "2026-10-14T00:00:00.000Z",
|
||||
lastError: null,
|
||||
syncHealth: { verdict: "fresh" as const, since: null },
|
||||
scopeReconnect: ["spo2" as const, "vo2max" as const],
|
||||
};
|
||||
|
||||
function renderOura(viewModel: ViewModel) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<OAuthProviderCard
|
||||
provider="oura"
|
||||
statusQueryKey={["oura"]}
|
||||
i18nPrefix="settings.oura"
|
||||
icon={Watch}
|
||||
dataHref="/insights/sleep"
|
||||
viewModel={viewModel}
|
||||
callbackUrl="https://app.example/api/oura/callback"
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
it("asks for one reconnect beside a healthy pill, not an error", () => {
|
||||
const html = renderOura(scopeGap);
|
||||
expect(html).toContain('data-testid="oura-scope-note"');
|
||||
expect(html).toContain("Reconnect Oura once to allow SpO2 and VO2 max.");
|
||||
expect(html).not.toContain('data-testid="oura-error"');
|
||||
});
|
||||
|
||||
it("stays away from the other OAuth providers", () => {
|
||||
expect(render({ viewModel: scopeGap })).not.toContain("scope-note");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* The two "withheld by contract" notes: the Withings card names what the
|
||||
* account's API plan refuses, the Oura card asks for one reconnect when the
|
||||
* grant lacks a scope. Both render nothing when nothing is withheld, and both
|
||||
* render the server-resolved list rather than deriving one.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { WithingsPlanNote } from "../withings-plan-note";
|
||||
import { OuraScopeNote } from "../oura-scope-note";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<WithingsPlanNote>", () => {
|
||||
it("lists the withheld readings and links the Withings plans page", () => {
|
||||
const html = render(
|
||||
<WithingsPlanNote features={["spo2", "vascularAge", "ecg"]} />,
|
||||
);
|
||||
expect(html).toContain('data-testid="withings-plan-note"');
|
||||
expect(html).toContain("SpO2, vascular age, and ECG and AFib");
|
||||
expect(html).toContain("Weight, blood pressure, body composition");
|
||||
expect(html).toContain(
|
||||
"https://static.withings.com/content/api/plans/plans.html",
|
||||
);
|
||||
});
|
||||
|
||||
it("speaks the reader's language", () => {
|
||||
const html = render(<WithingsPlanNote features={["vascularAge"]} />, "de");
|
||||
expect(html).toContain("Gefäßalter");
|
||||
expect(html).toContain("kostenpflichtigen API-Plan");
|
||||
});
|
||||
|
||||
it("renders nothing when the plan withholds nothing", () => {
|
||||
expect(render(<WithingsPlanNote features={[]} />)).toBe("");
|
||||
expect(render(<WithingsPlanNote features={undefined} />)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<OuraScopeNote>", () => {
|
||||
it("asks for one reconnect naming the collections the grant lacks", () => {
|
||||
const html = render(
|
||||
<OuraScopeNote
|
||||
collections={["spo2", "vo2max", "cardio_age", "resilience"]}
|
||||
onReconnect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-testid="oura-scope-note"');
|
||||
expect(html).toContain(
|
||||
"Reconnect Oura once to allow SpO2, VO2 max, cardiovascular age, and resilience.",
|
||||
);
|
||||
expect(html).toContain('data-testid="oura-scope-reconnect"');
|
||||
});
|
||||
|
||||
it("renders nothing when the grant covers everything", () => {
|
||||
expect(
|
||||
render(<OuraScopeNote collections={[]} onReconnect={vi.fn()} />),
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
syncUserMeasurements,
|
||||
} from "@/lib/withings/sync";
|
||||
import { syncUserActivity } from "@/lib/withings/sync-activity";
|
||||
import { releasePlanGatedWithingsParks } from "@/lib/withings/api-plan";
|
||||
import { syncUserSleep } from "@/lib/withings/sync-sleep";
|
||||
import { syncUserEcg } from "@/lib/withings/sync-ecg";
|
||||
import type { WithingsEcgSyncPayload } from "@/lib/jobs/withings-ecg-queue";
|
||||
@@ -74,6 +75,15 @@ export async function handleWithingsFallbackSync(
|
||||
"Withings subscription repair failed; continuing fallback sync",
|
||||
);
|
||||
}
|
||||
// Connections an earlier build parked at `error_reauth` over a Withings
|
||||
// API-plan refusal (277) are lifted first, so this pass and the activity,
|
||||
// sleep and ECG legs read again. Best-effort: a failure here leaves the
|
||||
// rows parked until the next tick, it never blocks the pass.
|
||||
try {
|
||||
await releasePlanGatedWithingsParks();
|
||||
} catch {
|
||||
evt.addWarning("Withings plan-park release failed; continuing");
|
||||
}
|
||||
const connections = await prisma.withingsConnection.findMany({
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
@@ -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({}));
|
||||
@@ -894,3 +898,84 @@ describe("resolveOuraLookbackDays", () => {
|
||||
expect(await resolveOuraLookbackDays("u1")).toBe(21);
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncUserOura — a grant without the spo2 / heart_health scopes", () => {
|
||||
const SCOPED = ["spo2", "vo2max", "cardio_age", "resilience"];
|
||||
|
||||
function denyScopedCollections() {
|
||||
fetchSpo2Mock.mockRejectedValue(err401());
|
||||
fetchVo2MaxMock.mockRejectedValue(err401());
|
||||
fetchCardioAgeMock.mockRejectedValue(err401());
|
||||
fetchResilienceMock.mockRejectedValue(err401());
|
||||
fetchReadinessMock.mockResolvedValue([
|
||||
{ id: "x", day: "2026-06-10", score: 70 },
|
||||
]);
|
||||
}
|
||||
|
||||
it("records the scope gap instead of a failure, and never toward the reconnect count", async () => {
|
||||
getConnMock.mockResolvedValue(CONN);
|
||||
denyScopedCollections();
|
||||
refreshMock.mockResolvedValue({
|
||||
access_token: "newAcc",
|
||||
refresh_token: "newRef",
|
||||
expires_in: 86400,
|
||||
});
|
||||
|
||||
const result = await syncUserOura("u1");
|
||||
|
||||
expect(result).toEqual({ imported: 1, failed: false });
|
||||
expect(recordFailureMock).not.toHaveBeenCalled();
|
||||
expect(recordSuccessMock).toHaveBeenCalledWith("u1", "oura");
|
||||
expect(scopeDeniedMock).toHaveBeenCalledWith("u1", SCOPED);
|
||||
});
|
||||
|
||||
it("spends no token refresh on a scope gap it already knows", async () => {
|
||||
getConnMock.mockResolvedValue({ ...CONN, scopeDenied: SCOPED });
|
||||
denyScopedCollections();
|
||||
|
||||
const result = await syncUserOura("u1");
|
||||
|
||||
expect(refreshMock).not.toHaveBeenCalled();
|
||||
expect(result.failed).toBe(false);
|
||||
expect(recordFailureMock).not.toHaveBeenCalled();
|
||||
expect(scopeDeniedMock).toHaveBeenCalledWith("u1", SCOPED);
|
||||
});
|
||||
|
||||
it("clears the note once the grant covers every collection", async () => {
|
||||
getConnMock.mockResolvedValue({ ...CONN, scopeDenied: SCOPED });
|
||||
|
||||
await syncUserOura("u1");
|
||||
|
||||
expect(scopeDeniedMock).toHaveBeenCalledWith("u1", []);
|
||||
expect(recordSuccessMock).toHaveBeenCalledWith("u1", "oura");
|
||||
});
|
||||
|
||||
it("still treats a 401 on every collection as a dead token", async () => {
|
||||
getConnMock.mockResolvedValue(CONN);
|
||||
for (const m of [
|
||||
fetchReadinessMock,
|
||||
fetchSleepMock,
|
||||
fetchActivityMock,
|
||||
fetchDailySleepMock,
|
||||
fetchSpo2Mock,
|
||||
fetchVo2MaxMock,
|
||||
fetchCardioAgeMock,
|
||||
fetchResilienceMock,
|
||||
]) {
|
||||
m.mockRejectedValue(err401());
|
||||
}
|
||||
refreshMock.mockResolvedValue({
|
||||
access_token: "newAcc",
|
||||
refresh_token: "newRef",
|
||||
expires_in: 86400,
|
||||
});
|
||||
|
||||
const result = await syncUserOura("u1");
|
||||
|
||||
expect(result.failed).toBe(true);
|
||||
expect(scopeDeniedMock).toHaveBeenCalledWith("u1", []);
|
||||
expect(recordFailureMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ integration: "oura", kind: "reauth_required" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
+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
|
||||
|
||||
@@ -27,6 +27,7 @@ vi.mock("@/lib/db", () => {
|
||||
withingsConnection: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
$executeRaw: vi.fn(),
|
||||
$transaction: vi.fn(
|
||||
async (fn: (tx: unknown) => unknown) => await fn({ ecgRecording }),
|
||||
),
|
||||
@@ -466,3 +467,82 @@ describe("syncUserEcg", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncUserEcg — Withings API plan (status 277)", () => {
|
||||
function install277() {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
status: 277,
|
||||
error:
|
||||
"One of the data type requested in this API request is not available in your API plan",
|
||||
}),
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
/** The leg the plan-state write merged in, read off the raw SQL values. */
|
||||
function persistedLeg(): { leg: unknown; value: unknown } | null {
|
||||
const call = vi.mocked(prisma.$executeRaw).mock.calls[0];
|
||||
if (!call) return null;
|
||||
const values = call.slice(1);
|
||||
return { leg: values[0], value: JSON.parse(String(values[1])) };
|
||||
}
|
||||
|
||||
it("skips quietly on a 277: no failure, no throw, the refusal remembered", async () => {
|
||||
install277();
|
||||
const imported = await syncUserEcg("user-1");
|
||||
|
||||
expect(imported).toBe(0);
|
||||
expect(recordSyncFailure).not.toHaveBeenCalled();
|
||||
expect(recordWithingsSyncFailure).not.toHaveBeenCalled();
|
||||
expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", {
|
||||
leg: "ecg",
|
||||
});
|
||||
expect(persistedLeg()).toEqual({
|
||||
leg: "ecg",
|
||||
value: { unavailable: ["heart"], checkedAt: expect.any(String) },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not ask the Heart endpoint again while the refusal is fresh", async () => {
|
||||
const fetchMock = install277();
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
|
||||
id: "connection-1",
|
||||
apiPlanState: {
|
||||
version: 1,
|
||||
ecg: { unavailable: ["heart"], checkedAt: new Date().toISOString() },
|
||||
},
|
||||
} as never);
|
||||
|
||||
const imported = await syncUserEcg("user-1");
|
||||
|
||||
expect(imported).toBe(0);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(recordSyncFailure).not.toHaveBeenCalled();
|
||||
expect(prisma.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-probes after a week and forgets the refusal when the plan now allows it", async () => {
|
||||
const fetchMock = installFetchMock([]);
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
|
||||
id: "connection-1",
|
||||
apiPlanState: {
|
||||
version: 1,
|
||||
ecg: {
|
||||
unavailable: ["heart"],
|
||||
checkedAt: new Date(Date.now() - 8 * 86_400_000).toISOString(),
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
await syncUserEcg("user-1");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
expect(persistedLeg()).toEqual({
|
||||
leg: "ecg",
|
||||
value: { unavailable: [], checkedAt: expect.any(String) },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ vi.mock("@/lib/crypto", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../client", () => ({
|
||||
WITHINGS_MEASTYPES: [1, 9, 10, 11],
|
||||
fetchMeasurements: vi.fn(),
|
||||
refreshAccessToken: vi.fn(),
|
||||
subscribeWebhook: vi.fn(),
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* Withings API plans (2026-10-12): a request naming one meastype outside the
|
||||
* account's plan is refused whole with status 277. These tests drive the real
|
||||
* measure sync and the real client against a mocked `safeFetch` that behaves
|
||||
* like the plan: any refused meastype in `meastypes` answers 277, anything
|
||||
* else answers with one reading per requested type.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: {
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
withingsConnection: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
integrationStatus: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
$executeRaw: vi.fn(),
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((v: string) => v),
|
||||
encrypt: vi.fn((v: string) => v),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/safe-fetch", () => ({
|
||||
safeFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../credentials", () => ({
|
||||
getUserWithingsCredentials: vi.fn(async () => ({
|
||||
clientId: "cid",
|
||||
clientSecret: "secret",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integrations/status", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/lib/integrations/status")
|
||||
>("@/lib/integrations/status");
|
||||
return {
|
||||
...actual,
|
||||
isReauthRequired: vi.fn().mockResolvedValue(false),
|
||||
recordSyncFailure: vi.fn().mockResolvedValue(undefined),
|
||||
recordSyncSuccess: vi.fn().mockResolvedValue(undefined),
|
||||
resumeIntegrationFromPark: vi.fn().mockResolvedValue({ wasParked: false }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/rollups/measurement-rollups", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/lib/rollups/measurement-rollups")
|
||||
>("@/lib/rollups/measurement-rollups");
|
||||
return {
|
||||
...actual,
|
||||
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserDashboardSnapshot: vi.fn(),
|
||||
invalidateUserMeasurements: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/arrivals/measurement-emit", () => ({
|
||||
emitInsertedMeasurementArrivals: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
getEvent: vi.fn(() => ({
|
||||
addExternalCall: vi.fn(),
|
||||
addWarning: vi.fn(),
|
||||
addMeta: vi.fn(),
|
||||
toJSON: () => ({ meta: {} }),
|
||||
})),
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import {
|
||||
recordSyncFailure,
|
||||
recordSyncSuccess,
|
||||
resumeIntegrationFromPark,
|
||||
} from "@/lib/integrations/status";
|
||||
import { classifyWithingsResponse } from "../response-classifier";
|
||||
import { WithingsApiError } from "../response-classifier";
|
||||
import {
|
||||
recordWithingsSyncFailure,
|
||||
syncUserMeasurements,
|
||||
WITHINGS_INCREMENTAL_OVERLAP_MS,
|
||||
} from "../sync";
|
||||
import {
|
||||
releasePlanGatedWithingsParks,
|
||||
requestWithinPlan,
|
||||
WITHINGS_ADVANCED_MEASTYPES,
|
||||
WITHINGS_PLAN_MAX_REQUESTS,
|
||||
} from "../api-plan";
|
||||
import { withingsPlanUnavailableFeatures } from "../plan-features";
|
||||
|
||||
const LAST_GOOD_SYNC = new Date("2026-10-12T21:12:00Z");
|
||||
|
||||
/** The meastypes the free plan refuses in these tests (the documented set). */
|
||||
let refused = new Set<number>(WITHINGS_ADVANCED_MEASTYPES);
|
||||
|
||||
/** Every `meastypes` list the client sent, in order. */
|
||||
const requestedTypes: number[][] = [];
|
||||
const requestedStartdates: string[] = [];
|
||||
|
||||
function installPlanFetch() {
|
||||
vi.mocked(safeFetch).mockImplementation(async (_url, init) => {
|
||||
const params = new URLSearchParams(String(init?.body ?? ""));
|
||||
const types = (params.get("meastypes") ?? "")
|
||||
.split(",")
|
||||
.filter(Boolean)
|
||||
.map(Number);
|
||||
requestedTypes.push(types);
|
||||
requestedStartdates.push(params.get("startdate") ?? "");
|
||||
const body = types.some((t) => refused.has(t))
|
||||
? {
|
||||
status: 277,
|
||||
error:
|
||||
"One of the data type requested in this API request is not available in your API plan",
|
||||
}
|
||||
: {
|
||||
status: 0,
|
||||
body: {
|
||||
measuregrps: [
|
||||
{
|
||||
grpid: 1,
|
||||
date: Math.floor(Date.parse("2026-10-13T07:00:00Z") / 1000),
|
||||
measures: types.map((type) => ({ type, value: 800, unit: -1 })),
|
||||
},
|
||||
],
|
||||
more: 0,
|
||||
},
|
||||
};
|
||||
return { status: 200, json: async () => body } as unknown as Response;
|
||||
});
|
||||
}
|
||||
|
||||
function connection(
|
||||
apiPlanState: unknown = null,
|
||||
lastSyncedAt = LAST_GOOD_SYNC,
|
||||
) {
|
||||
return {
|
||||
id: "conn-1",
|
||||
userId: "user-1",
|
||||
withingsUserId: "wu-1",
|
||||
accessToken: "enc-access",
|
||||
refreshToken: "enc-refresh",
|
||||
tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
lastSyncedAt,
|
||||
apiPlanState,
|
||||
};
|
||||
}
|
||||
|
||||
/** The legs the sync merged into `api_plan_state`, read off the raw SQL. */
|
||||
function persistedLegs(): Array<{ leg: unknown; value: unknown }> {
|
||||
return vi.mocked(prisma.$executeRaw).mock.calls.map((call) => ({
|
||||
leg: call[1],
|
||||
value: JSON.parse(String(call[2])),
|
||||
}));
|
||||
}
|
||||
|
||||
function createdTypes(): string[] {
|
||||
return vi
|
||||
.mocked(prisma.measurement.create)
|
||||
.mock.calls.map((c) => (c[0] as { data: { type: string } }).data.type);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
requestedTypes.length = 0;
|
||||
requestedStartdates.length = 0;
|
||||
refused = new Set<number>(WITHINGS_ADVANCED_MEASTYPES);
|
||||
installPlanFetch();
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue(
|
||||
connection() as never,
|
||||
);
|
||||
vi.mocked(prisma.withingsConnection.update).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.measurement.create).mockImplementation((async (args: {
|
||||
data: { type: string; measuredAt: Date };
|
||||
}) => ({
|
||||
id: `m-${args.data.type}`,
|
||||
type: args.data.type,
|
||||
measuredAt: args.data.measuredAt,
|
||||
})) as never);
|
||||
vi.mocked(prisma.$executeRaw).mockResolvedValue(1 as never);
|
||||
});
|
||||
|
||||
describe("syncUserMeasurements inside the Withings API plan", () => {
|
||||
it("retries without the advanced types on a 277 and imports the core ones", async () => {
|
||||
const result = await syncUserMeasurements("user-1");
|
||||
|
||||
expect(requestedTypes).toHaveLength(2);
|
||||
expect(requestedTypes[0]).toEqual(
|
||||
expect.arrayContaining([...WITHINGS_ADVANCED_MEASTYPES, 1, 9, 10]),
|
||||
);
|
||||
expect(requestedTypes[1]!.some((t) => refused.has(t))).toBe(false);
|
||||
expect(requestedTypes[1]).toEqual(expect.arrayContaining([1, 9, 10, 11]));
|
||||
|
||||
expect(result).toEqual({ imported: expect.any(Number), failed: false });
|
||||
expect(result.imported).toBeGreaterThan(0);
|
||||
expect(createdTypes()).toEqual(
|
||||
expect.arrayContaining([
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
]),
|
||||
);
|
||||
expect(createdTypes()).not.toContain("VASCULAR_AGE");
|
||||
|
||||
// A partial-by-plan run is a success, and the watermark moves on.
|
||||
expect(recordSyncFailure).not.toHaveBeenCalled();
|
||||
expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", {
|
||||
leg: "measures",
|
||||
});
|
||||
expect(prisma.withingsConnection.update).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1" },
|
||||
data: { lastSyncedAt: expect.any(Date) },
|
||||
});
|
||||
|
||||
expect(persistedLegs()).toEqual([
|
||||
{
|
||||
leg: "measures",
|
||||
value: {
|
||||
unavailable: [...WITHINGS_ADVANCED_MEASTYPES].sort((a, b) => a - b),
|
||||
checkedAt: expect.any(String),
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips the remembered types on the next sync without a refused round trip", async () => {
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue(
|
||||
connection({
|
||||
version: 1,
|
||||
measures: {
|
||||
unavailable: [...WITHINGS_ADVANCED_MEASTYPES],
|
||||
checkedAt: new Date().toISOString(),
|
||||
},
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const result = await syncUserMeasurements("user-1");
|
||||
|
||||
expect(requestedTypes).toHaveLength(1);
|
||||
expect(requestedTypes[0]!.some((t) => refused.has(t))).toBe(false);
|
||||
expect(result.failed).toBe(false);
|
||||
expect(prisma.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks for the full set again once the remembered refusal is a week old", async () => {
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue(
|
||||
connection({
|
||||
version: 1,
|
||||
measures: {
|
||||
unavailable: [...WITHINGS_ADVANCED_MEASTYPES],
|
||||
checkedAt: new Date(Date.now() - 8 * 86_400_000).toISOString(),
|
||||
},
|
||||
}) as never,
|
||||
);
|
||||
// The account moved to a plan that allows everything.
|
||||
refused = new Set();
|
||||
|
||||
await syncUserMeasurements("user-1");
|
||||
|
||||
expect(requestedTypes).toHaveLength(1);
|
||||
expect(requestedTypes[0]).toEqual(
|
||||
expect.arrayContaining([...WITHINGS_ADVANCED_MEASTYPES]),
|
||||
);
|
||||
expect(createdTypes()).toContain("VASCULAR_AGE");
|
||||
expect(persistedLegs()).toEqual([
|
||||
{
|
||||
leg: "measures",
|
||||
value: { unavailable: [], checkedAt: expect.any(String) },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("narrows further when the reduced request is still refused", async () => {
|
||||
// An undocumented refusal on top of the documented set: fat ratio (6).
|
||||
refused = new Set([...WITHINGS_ADVANCED_MEASTYPES, 6]);
|
||||
|
||||
const result = await syncUserMeasurements("user-1");
|
||||
|
||||
expect(result.failed).toBe(false);
|
||||
expect(requestedTypes.length).toBeLessThanOrEqual(
|
||||
WITHINGS_PLAN_MAX_REQUESTS,
|
||||
);
|
||||
expect(createdTypes()).toEqual(
|
||||
expect.arrayContaining(["WEIGHT", "BLOOD_PRESSURE_SYS", "PULSE"]),
|
||||
);
|
||||
expect(createdTypes()).not.toContain("BODY_FAT");
|
||||
const [persisted] = persistedLegs();
|
||||
expect(persisted?.value).toEqual({
|
||||
unavailable: [6, ...WITHINGS_ADVANCED_MEASTYPES].sort((a, b) => a - b),
|
||||
checkedAt: expect.any(String),
|
||||
});
|
||||
expect(recordSyncFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads the whole gap since the last good sync", async () => {
|
||||
await syncUserMeasurements("user-1");
|
||||
|
||||
const expected = String(
|
||||
Math.floor(
|
||||
(LAST_GOOD_SYNC.getTime() - WITHINGS_INCREMENTAL_OVERLAP_MS) / 1000,
|
||||
),
|
||||
);
|
||||
expect(requestedStartdates.every((s) => s === expected)).toBe(true);
|
||||
});
|
||||
|
||||
it("holds the watermark and records no failure when the plan refuses everything", async () => {
|
||||
refused = new Set([
|
||||
1, 5, 6, 8, 9, 10, 11, 12, 35, 54, 71, 76, 77, 88, 91, 123, 155, 170, 73,
|
||||
]);
|
||||
|
||||
const result = await syncUserMeasurements("user-1");
|
||||
|
||||
expect(result).toEqual({ imported: 0, failed: false });
|
||||
expect(prisma.withingsConnection.update).not.toHaveBeenCalled();
|
||||
expect(recordSyncFailure).not.toHaveBeenCalled();
|
||||
expect(requestedTypes.length).toBeLessThanOrEqual(
|
||||
WITHINGS_PLAN_MAX_REQUESTS,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a 277 is never an auth failure", () => {
|
||||
it("classifies 277 as persistent, not reauth_required", () => {
|
||||
expect(classifyWithingsResponse(200, { status: 277 }).classification).toBe(
|
||||
"persistent",
|
||||
);
|
||||
// The rest of the OAuth range keeps its meaning.
|
||||
expect(classifyWithingsResponse(200, { status: 250 }).classification).toBe(
|
||||
"reauth_required",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a stray 277 off the ledger entirely", async () => {
|
||||
await recordWithingsSyncFailure(
|
||||
"user-1",
|
||||
new WithingsApiError({
|
||||
verb: "measure",
|
||||
classification: "persistent",
|
||||
withingsStatus: 277,
|
||||
reason: "withings_277",
|
||||
}),
|
||||
"activity",
|
||||
);
|
||||
expect(recordSyncFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lifts connections an earlier build parked over a 277, and only those", async () => {
|
||||
vi.mocked(prisma.integrationStatus.findMany).mockResolvedValue([
|
||||
{
|
||||
userId: "plan-user",
|
||||
lastError:
|
||||
"Withings measure error: 277 - One of the data type requested in this API request is not available in your API plan",
|
||||
},
|
||||
{ userId: "revoked-user", lastError: "Withings refresh error: 101" },
|
||||
] as never);
|
||||
|
||||
const released = await releasePlanGatedWithingsParks();
|
||||
|
||||
expect(released).toBe(1);
|
||||
expect(resumeIntegrationFromPark).toHaveBeenCalledTimes(1);
|
||||
expect(resumeIntegrationFromPark).toHaveBeenCalledWith(
|
||||
"plan-user",
|
||||
"withings",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestWithinPlan", () => {
|
||||
const gatedError = () =>
|
||||
new WithingsApiError({
|
||||
verb: "measure",
|
||||
classification: "persistent",
|
||||
withingsStatus: 277,
|
||||
reason: "withings_277",
|
||||
});
|
||||
|
||||
it("isolates refused keys deterministically and keeps every accepted part", async () => {
|
||||
const refusedKeys = new Set(["c", "f"]);
|
||||
const calls: string[][] = [];
|
||||
const run = () =>
|
||||
requestWithinPlan({
|
||||
keys: ["a", "b", "c", "d", "e", "f", "g"],
|
||||
knownGated: [],
|
||||
fetch: async (keys: string[]) => {
|
||||
calls.push(keys);
|
||||
if (keys.some((k) => refusedKeys.has(k))) throw gatedError();
|
||||
return keys;
|
||||
},
|
||||
});
|
||||
|
||||
const first = await run();
|
||||
const firstCalls = calls.splice(0);
|
||||
const second = await run();
|
||||
|
||||
expect(first.unavailable.sort()).toEqual(["c", "f"]);
|
||||
expect(first.results.sort()).toEqual(["a", "b", "d", "e", "g"]);
|
||||
expect(first.gated).toBe(true);
|
||||
expect(first.acceptedAny).toBe(true);
|
||||
expect(calls).toEqual(firstCalls);
|
||||
expect(second.unavailable.sort()).toEqual(["c", "f"]);
|
||||
});
|
||||
|
||||
it("stops at the request budget and counts what is left as unavailable", async () => {
|
||||
const result = await requestWithinPlan({
|
||||
keys: ["a", "b", "c", "d", "e", "f", "g", "h"],
|
||||
knownGated: [],
|
||||
maxRequests: 3,
|
||||
fetch: async () => {
|
||||
throw gatedError();
|
||||
},
|
||||
});
|
||||
expect(result.requests).toBe(3);
|
||||
expect(result.unavailable.sort()).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"d",
|
||||
"e",
|
||||
"f",
|
||||
"g",
|
||||
"h",
|
||||
]);
|
||||
expect(result.acceptedAny).toBe(false);
|
||||
});
|
||||
|
||||
it("lets any other error through untouched", async () => {
|
||||
const boom = new Error("Withings measure error: 503");
|
||||
await expect(
|
||||
requestWithinPlan({
|
||||
keys: [1, 2],
|
||||
knownGated: [],
|
||||
fetch: async () => {
|
||||
throw boom;
|
||||
},
|
||||
}),
|
||||
).rejects.toBe(boom);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withingsPlanUnavailableFeatures", () => {
|
||||
it("names each withheld reading once, in display order", () => {
|
||||
expect(
|
||||
withingsPlanUnavailableFeatures({
|
||||
version: 1,
|
||||
measures: { unavailable: [155, 35, 54, 91], checkedAt: "x" },
|
||||
sleepSummary: { unavailable: ["sleep_score"], checkedAt: "x" },
|
||||
ecg: { unavailable: ["heart"], checkedAt: "x" },
|
||||
}),
|
||||
).toEqual([
|
||||
"spo2",
|
||||
"vascularAge",
|
||||
"pulseWaveVelocity",
|
||||
"ecg",
|
||||
"sleepScore",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ vi.mock("@/lib/db", () => ({
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
withingsConnection: { findUnique: vi.fn() },
|
||||
$executeRaw: vi.fn(),
|
||||
$transaction: transactionMock,
|
||||
},
|
||||
}));
|
||||
@@ -55,6 +56,7 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
addExternalCall: vi.fn(),
|
||||
addWarning: vi.fn(),
|
||||
})),
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
// v1.4.39.1 — the sleep sync now folds the persistent measurement
|
||||
@@ -733,3 +735,106 @@ describe("syncUserSleep — measurement rollup hook (v1.4.39.1)", () => {
|
||||
expect(imported).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncUserSleep — Withings API plan (status 277)", () => {
|
||||
const ADVANCED = new Set(["sleep_score", "sdnn_1", "rr_average"]);
|
||||
|
||||
/** Segments answer normally; the summary refuses any advanced field. */
|
||||
function installPlanFetch(refused: Set<string>) {
|
||||
const summaryFields: string[][] = [];
|
||||
const nightEnd = Math.floor(Date.UTC(2026, 9, 13, 6) / 1000);
|
||||
const fetchMock = vi.fn(async (_url: string, init: { body: string }) => {
|
||||
const params = new URLSearchParams(init.body);
|
||||
if (params.get("action") !== "getsummary") {
|
||||
return {
|
||||
status: 200,
|
||||
json: async () => ({ status: 0, body: { series: [] } }),
|
||||
};
|
||||
}
|
||||
const fields = (params.get("data_fields") ?? "").split(",");
|
||||
summaryFields.push(fields);
|
||||
if (fields.some((f) => refused.has(f))) {
|
||||
return { status: 200, json: async () => ({ status: 277 }) };
|
||||
}
|
||||
const data = Object.fromEntries(
|
||||
fields.map((f) => [f, f === "spo2_average" ? 95 : 58]),
|
||||
);
|
||||
return {
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
status: 0,
|
||||
body: {
|
||||
series: [
|
||||
{
|
||||
id: 9,
|
||||
startdate: nightEnd - 8 * 3600,
|
||||
enddate: nightEnd,
|
||||
data,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return summaryFields;
|
||||
}
|
||||
|
||||
it("drops the advanced fields, imports the rest and remembers the refusal", async () => {
|
||||
const summaryFields = installPlanFetch(ADVANCED);
|
||||
reconcileMock.mockResolvedValue({ status: "inserted", row: {} });
|
||||
|
||||
const imported = await syncUserSleep("user-1");
|
||||
|
||||
expect(summaryFields).toEqual([
|
||||
["hr_average", "rr_average", "sdnn_1", "spo2_average", "sleep_score"],
|
||||
["hr_average", "spo2_average"],
|
||||
]);
|
||||
expect(imported).toBe(2);
|
||||
expect(recordSyncFailure).not.toHaveBeenCalled();
|
||||
const call = vi.mocked(prisma.$executeRaw).mock.calls[0]!;
|
||||
expect(call[1]).toBe("sleepSummary");
|
||||
expect(JSON.parse(String(call[2]))).toEqual({
|
||||
unavailable: ["rr_average", "sdnn_1", "sleep_score"],
|
||||
checkedAt: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("asks only for the allowed fields while the refusal is fresh", async () => {
|
||||
const summaryFields = installPlanFetch(ADVANCED);
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
|
||||
scope: "user.metrics,user.activity",
|
||||
apiPlanState: {
|
||||
version: 1,
|
||||
sleepSummary: {
|
||||
unavailable: ["sleep_score", "sdnn_1", "rr_average"],
|
||||
checkedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
reconcileMock.mockResolvedValue({ status: "inserted", row: {} });
|
||||
|
||||
await syncUserSleep("user-1");
|
||||
|
||||
expect(summaryFields).toEqual([["hr_average", "spo2_average"]]);
|
||||
expect(prisma.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fail the leg when the stage timeline itself is refused", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
status: 200,
|
||||
json: async () => ({ status: 277 }),
|
||||
})),
|
||||
);
|
||||
|
||||
const imported = await syncUserSleep("user-1");
|
||||
|
||||
expect(imported).toBe(0);
|
||||
expect(recordSyncFailure).not.toHaveBeenCalled();
|
||||
expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings", {
|
||||
leg: "sleep",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* Withings API plans (since 2026-10-12).
|
||||
*
|
||||
* Withings split its public API into plans. A self-hoster's own developer app
|
||||
* sits on the free "Start for Free" plan, which carries the core biomarkers
|
||||
* (activity, sleep, body, heart). The FDA/CE-cleared biomarkers and a handful
|
||||
* of body-composition extras need the paid "Advanced Biomarkers" plan. A
|
||||
* request that names anything outside the plan is refused WHOLE with status
|
||||
* 277 ("One of the data type requested in this API request is not available in
|
||||
* your API plan") — so one gated meastype in a `getmeas` call used to stop
|
||||
* weight and blood pressure from importing too.
|
||||
*
|
||||
* This module keeps the sync inside the plan:
|
||||
*
|
||||
* - `requestWithinPlan` retries a refused request without the types Withings
|
||||
* documents as advanced, then, if it is still refused, splits what is left
|
||||
* in halves until every accepted subset has been read and every refused key
|
||||
* is isolated. Bounded by `WITHINGS_PLAN_MAX_REQUESTS`; anything the budget
|
||||
* could not resolve counts as unavailable until the next probe.
|
||||
* - `WithingsApiPlanState` remembers per request family what the plan
|
||||
* refused, so later runs skip those keys without paying a refused round
|
||||
* trip, and re-probes them weekly in case the account's plan changes.
|
||||
* - `releasePlanGatedWithingsParks` lifts connections an earlier build parked
|
||||
* at `error_reauth` over a 277. Status 277 sits inside the 200..299 OAuth
|
||||
* range the classifier used to read as a revoked grant; it is a contract
|
||||
* limit, and reconnecting does not change it.
|
||||
*
|
||||
* Which keys are advanced comes from Withings' own biomarker table
|
||||
* (https://developer.withings.com/developer-guide/v3/data-api/all-available-health-data/,
|
||||
* the "Advanced Biomarkers" column) and the plans page
|
||||
* (https://static.withings.com/content/api/plans/plans.html).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { resumeIntegrationFromPark } from "@/lib/integrations/status";
|
||||
import { WithingsApiError } from "./response-classifier";
|
||||
|
||||
/** The Withings body status for "not available in your API plan". */
|
||||
export const WITHINGS_PLAN_GATED_STATUS = 277;
|
||||
|
||||
/** Upper bound on requests one plan probe may spend, the first one included. */
|
||||
export const WITHINGS_PLAN_MAX_REQUESTS = 12;
|
||||
|
||||
/** How long a remembered refusal is trusted before the full set is re-probed. */
|
||||
export const WITHINGS_PLAN_REPROBE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Meastypes HealthLog requests that Withings lists under the Advanced
|
||||
* Biomarkers plan: pulse-wave velocity (91), vascular age (155), visceral fat
|
||||
* (170), VO2 max (123) and skin temperature (73). Manual SpO2 (54), body
|
||||
* temperature (71), body composition and blood pressure are listed as free.
|
||||
* Types the table does not list at all (fat ratio 6, legacy temperature 12,
|
||||
* legacy SpO2 35) are left to the split, which finds out instead of guessing.
|
||||
*/
|
||||
export const WITHINGS_ADVANCED_MEASTYPES: readonly number[] = [
|
||||
91, 155, 170, 123, 73,
|
||||
];
|
||||
|
||||
/**
|
||||
* Sleep-summary fields HealthLog requests that the table marks advanced: the
|
||||
* sleep score, the overnight HRV and the respiration rate. The nightly average
|
||||
* heart rate is listed both ways (free for trackers, advanced for the Sleep
|
||||
* Analyzer) and the nightly SpO2 average is not listed, so both go to the split.
|
||||
*/
|
||||
export const WITHINGS_ADVANCED_SLEEP_FIELDS: readonly string[] = [
|
||||
"sleep_score",
|
||||
"sdnn_1",
|
||||
"rr_average",
|
||||
];
|
||||
|
||||
// ── error recognition ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* True when a caught error is Withings' plan refusal. Reads the typed error
|
||||
* first and falls back to the message shape for errors that lost their
|
||||
* prototype on a job round trip.
|
||||
*/
|
||||
export function isPlanGatedError(err: unknown): boolean {
|
||||
if (err instanceof WithingsApiError) {
|
||||
return err.withingsStatus === WITHINGS_PLAN_GATED_STATUS;
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return PLAN_GATED_MESSAGE.test(msg);
|
||||
}
|
||||
|
||||
const PLAN_GATED_MESSAGE = /Withings\s+\w+\s+error:\s*277\b/;
|
||||
|
||||
// ── probing ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface WithinPlanResult<K, R> {
|
||||
/** Everything the accepted requests returned. */
|
||||
results: R[];
|
||||
/** The requested keys the plan refused (or the budget could not clear). */
|
||||
unavailable: K[];
|
||||
/** True when at least one request was refused with 277. */
|
||||
gated: boolean;
|
||||
/** True when at least one request was accepted. */
|
||||
acceptedAny: boolean;
|
||||
/** How many requests the call spent. */
|
||||
requests: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fetch` over `keys`, staying inside the account's API plan.
|
||||
*
|
||||
* The first request asks for every key. On a 277 the documented advanced keys
|
||||
* are dropped and the rest asked for again; on a further 277 the remainder is
|
||||
* split in halves, depth first and in input order, so the same refusal always
|
||||
* spends the same requests. A single key that is still refused is unavailable.
|
||||
* When the budget runs out, the subsets not yet tried count as unavailable for
|
||||
* now; the weekly re-probe asks for them again.
|
||||
*
|
||||
* Any error other than a 277 propagates unchanged: this only decides what the
|
||||
* plan allows, never whether the connection works.
|
||||
*/
|
||||
export async function requestWithinPlan<K, R>(opts: {
|
||||
keys: readonly K[];
|
||||
knownGated: readonly K[];
|
||||
fetch: (keys: K[]) => Promise<R[]>;
|
||||
maxRequests?: number;
|
||||
}): Promise<WithinPlanResult<K, R>> {
|
||||
const budget = opts.maxRequests ?? WITHINGS_PLAN_MAX_REQUESTS;
|
||||
const keys = [...opts.keys];
|
||||
const results: R[] = [];
|
||||
const unavailable: K[] = [];
|
||||
let requests = 0;
|
||||
let gated = false;
|
||||
let acceptedAny = false;
|
||||
|
||||
// One request. `true` = accepted (results kept), `false` = refused by plan.
|
||||
const attempt = async (subset: K[]): Promise<boolean> => {
|
||||
requests++;
|
||||
try {
|
||||
results.push(...(await opts.fetch(subset)));
|
||||
acceptedAny = true;
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (!isPlanGatedError(err)) throw err;
|
||||
gated = true;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if (keys.length === 0) {
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
}
|
||||
if (await attempt(keys)) {
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
}
|
||||
|
||||
const knownGated = new Set(opts.knownGated);
|
||||
const dropped = keys.filter((k) => knownGated.has(k));
|
||||
let remaining = keys.filter((k) => !knownGated.has(k));
|
||||
if (dropped.length > 0) {
|
||||
unavailable.push(...dropped);
|
||||
if (remaining.length === 0) {
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
}
|
||||
if (requests >= budget) {
|
||||
unavailable.push(...remaining);
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
}
|
||||
if (await attempt(remaining)) {
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
}
|
||||
}
|
||||
|
||||
// `remaining` is known to be refused as a whole. Split it until each accepted
|
||||
// part has been read and each refused key stands alone.
|
||||
if (remaining.length === 1) {
|
||||
unavailable.push(...remaining);
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
}
|
||||
const stack: K[][] = halves(remaining).reverse();
|
||||
remaining = [];
|
||||
while (stack.length > 0) {
|
||||
const subset = stack.pop()!;
|
||||
if (requests >= budget) {
|
||||
unavailable.push(...subset);
|
||||
continue;
|
||||
}
|
||||
if (await attempt(subset)) continue;
|
||||
if (subset.length === 1) {
|
||||
unavailable.push(subset[0]!);
|
||||
continue;
|
||||
}
|
||||
stack.push(...halves(subset).reverse());
|
||||
}
|
||||
|
||||
return { results, unavailable, gated, acceptedAny, requests };
|
||||
}
|
||||
|
||||
function halves<K>(list: K[]): K[][] {
|
||||
const mid = Math.ceil(list.length / 2);
|
||||
return [list.slice(0, mid), list.slice(mid)];
|
||||
}
|
||||
|
||||
// ── remembered state ───────────────────────────────────────────────────────
|
||||
|
||||
/** The request families the plan can refuse independently. */
|
||||
export type WithingsPlanLeg = "measures" | "sleepSummary" | "ecg";
|
||||
|
||||
export interface WithingsPlanLegState<K> {
|
||||
/** What the plan refused at the last probe. Empty = nothing refused. */
|
||||
unavailable: K[];
|
||||
/** When the full set was last asked for. */
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface WithingsApiPlanState {
|
||||
version: 1;
|
||||
measures?: WithingsPlanLegState<number>;
|
||||
sleepSummary?: WithingsPlanLegState<string>;
|
||||
/** `unavailable` holds `"heart"` when the Heart endpoints are refused. */
|
||||
ecg?: WithingsPlanLegState<string>;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseLeg<K>(
|
||||
value: unknown,
|
||||
isKey: (v: unknown) => v is K,
|
||||
): WithingsPlanLegState<K> | undefined {
|
||||
if (!isObject(value)) return undefined;
|
||||
const { unavailable, checkedAt } = value;
|
||||
if (!Array.isArray(unavailable) || !unavailable.every(isKey))
|
||||
return undefined;
|
||||
if (typeof checkedAt !== "string" || !Number.isFinite(Date.parse(checkedAt)))
|
||||
return undefined;
|
||||
return { unavailable: [...new Set(unavailable)], checkedAt };
|
||||
}
|
||||
|
||||
const isMeastype = (v: unknown): v is number =>
|
||||
typeof v === "number" && Number.isSafeInteger(v) && v > 0;
|
||||
const isFieldName = (v: unknown): v is string =>
|
||||
typeof v === "string" && /^[a-z0-9_]{1,64}$/.test(v);
|
||||
|
||||
/**
|
||||
* Persisted JSON is an untrusted boundary. A leg that does not validate is
|
||||
* dropped, which only means the next run asks for the full set again.
|
||||
*/
|
||||
export function parseWithingsApiPlanState(
|
||||
value: unknown,
|
||||
): WithingsApiPlanState {
|
||||
const state: WithingsApiPlanState = { version: 1 };
|
||||
if (!isObject(value) || value.version !== 1) return state;
|
||||
const measures = parseLeg(value.measures, isMeastype);
|
||||
const sleepSummary = parseLeg(value.sleepSummary, isFieldName);
|
||||
const ecg = parseLeg(value.ecg, isFieldName);
|
||||
if (measures) state.measures = measures;
|
||||
if (sleepSummary) state.sleepSummary = sleepSummary;
|
||||
if (ecg) state.ecg = ecg;
|
||||
return state;
|
||||
}
|
||||
|
||||
/** True when a remembered refusal is old enough to ask for the full set. */
|
||||
export function isPlanReprobeDue(
|
||||
leg: WithingsPlanLegState<unknown> | undefined,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
if (!leg || leg.unavailable.length === 0) return false;
|
||||
return now.getTime() - Date.parse(leg.checkedAt) >= WITHINGS_PLAN_REPROBE_MS;
|
||||
}
|
||||
|
||||
/** The keys a run should leave out: the remembered refusal, unless it is due. */
|
||||
export function planSkipFor<K>(
|
||||
leg: WithingsPlanLegState<K> | undefined,
|
||||
now: Date = new Date(),
|
||||
): K[] {
|
||||
if (!leg || isPlanReprobeDue(leg, now)) return [];
|
||||
return leg.unavailable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one leg of the plan state. A single statement merges the leg into the
|
||||
* stored object, so the measures, sleep and ECG legs (separate crons, separate
|
||||
* minutes) cannot overwrite each other's memory with a stale read.
|
||||
*/
|
||||
export async function persistWithingsPlanLeg(
|
||||
userId: string,
|
||||
leg: WithingsPlanLeg,
|
||||
state: WithingsPlanLegState<number> | WithingsPlanLegState<string>,
|
||||
): Promise<void> {
|
||||
const value = JSON.stringify({
|
||||
unavailable: [...new Set<unknown>(state.unavailable)],
|
||||
checkedAt: state.checkedAt,
|
||||
});
|
||||
await prisma.$executeRaw`
|
||||
UPDATE "withings_connections"
|
||||
SET "api_plan_state" =
|
||||
COALESCE(
|
||||
CASE WHEN jsonb_typeof("api_plan_state") = 'object'
|
||||
AND "api_plan_state"->'version' = '1'::jsonb
|
||||
THEN "api_plan_state" END,
|
||||
'{}'::jsonb
|
||||
)
|
||||
|| jsonb_build_object('version', 1)
|
||||
|| jsonb_build_object(${leg}::text, ${value}::jsonb),
|
||||
"updated_at" = ${new Date()}
|
||||
WHERE "user_id" = ${userId}`;
|
||||
annotate({
|
||||
meta: {
|
||||
withings_plan: {
|
||||
leg,
|
||||
unavailable: state.unavailable.map(String),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a run's outcome is worth writing down. A run that met a 277
|
||||
* always is; so is a re-probe (the full set was asked for), whatever it found;
|
||||
* a run that skipped remembered keys and was accepted changes nothing.
|
||||
*/
|
||||
export function shouldPersistPlanLeg(opts: {
|
||||
gated: boolean;
|
||||
reprobed: boolean;
|
||||
}): boolean {
|
||||
return opts.gated || opts.reprobed;
|
||||
}
|
||||
|
||||
// ── lifting parks an earlier build left ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lift every Withings connection parked over a plan refusal.
|
||||
*
|
||||
* Before 2026-10-14 the classifier read 277 as a revoked grant (it falls in the
|
||||
* 200..299 OAuth range), so the first refused `getmeas` parked the connection
|
||||
* at `error_reauth` and every leg stopped. Reconnecting cannot fix a plan
|
||||
* limit, so these rows are resumed instead: the next run reads inside the plan
|
||||
* and, because a refused run never advanced `lastSyncedAt`, imports everything
|
||||
* since the last good sync. The decision reads the stored error message, which
|
||||
* carries the Withings status the park was recorded with.
|
||||
*
|
||||
* Returns how many rows were lifted.
|
||||
*/
|
||||
export async function releasePlanGatedWithingsParks(): Promise<number> {
|
||||
const rows = await prisma.integrationStatus.findMany({
|
||||
where: {
|
||||
integration: "withings",
|
||||
state: "error_reauth",
|
||||
},
|
||||
select: { userId: true, lastError: true },
|
||||
});
|
||||
let released = 0;
|
||||
for (const row of rows) {
|
||||
if (!row.lastError || !parkedOverPlan(row.lastError)) continue;
|
||||
await resumeIntegrationFromPark(row.userId, "withings");
|
||||
released++;
|
||||
}
|
||||
if (released > 0) {
|
||||
annotate({ meta: { withings_plan_parks_released: released } });
|
||||
}
|
||||
return released;
|
||||
}
|
||||
|
||||
function parkedOverPlan(lastErrorCiphertext: string): boolean {
|
||||
try {
|
||||
return PLAN_GATED_MESSAGE.test(decrypt(lastErrorCiphertext));
|
||||
} catch {
|
||||
getEvent()?.addWarning("withings plan release: undecryptable lastError");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -244,17 +244,26 @@ export interface WithingsMeasureGroup {
|
||||
measures: Array<{ type: number; value: number; unit: number }>;
|
||||
}
|
||||
|
||||
/** Every meastype HealthLog maps, in ascending order. */
|
||||
export const WITHINGS_MEASTYPES: readonly number[] =
|
||||
Object.keys(MEASURE_TYPE_MAP).map(Number);
|
||||
|
||||
/**
|
||||
* Fetch measurements from Withings API.
|
||||
*
|
||||
* `measTypes` narrows the request to a subset of `MEASURE_TYPE_MAP` (the sync
|
||||
* leaves out what the account's API plan refuses); omitted, every mapped type
|
||||
* is asked for.
|
||||
*/
|
||||
export async function fetchMeasurements(
|
||||
accessToken: string,
|
||||
startDate?: Date,
|
||||
endDate?: Date,
|
||||
opts: { measTypes?: readonly number[] } = {},
|
||||
): Promise<WithingsMeasure[]> {
|
||||
const baseParams: Record<string, string> = {
|
||||
action: "getmeas",
|
||||
meastypes: Object.keys(MEASURE_TYPE_MAP).join(","),
|
||||
meastypes: (opts.measTypes ?? WITHINGS_MEASTYPES).join(","),
|
||||
};
|
||||
if (startDate) {
|
||||
baseParams.startdate = String(Math.floor(startDate.getTime() / 1000));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -100,12 +100,20 @@ const TRANSIENT_CODES = new Set<number>([503, 601, 2554, 2555, 2556]);
|
||||
* - 294 = "already subscribed" (subscribeWebhook treats it as success;
|
||||
* any OTHER endpoint receiving 294 is a contract bug)
|
||||
*
|
||||
* - 277 = "One of the data type requested in this API request is not
|
||||
* available in your API plan" (Withings API plans, 2026-10-12).
|
||||
* A contract limit on the developer app, not a revoked grant, so it
|
||||
* must never park the connection at `error_reauth` the way the
|
||||
* 200..299 range below would. The sync paths handle it before it
|
||||
* reaches the ledger (`src/lib/withings/api-plan.ts`); this entry is
|
||||
* the floor for any path that does not.
|
||||
*
|
||||
* Note: 294 is intentionally listed here. `subscribeWebhook` overrides
|
||||
* the classification for its own call site (already-subscribed is
|
||||
* idempotent there); every other client call that sees 294 surfaces it
|
||||
* as a persistent failure so the bug doesn't hide.
|
||||
*/
|
||||
const PERSISTENT_CODES = new Set<number>([293, 294]);
|
||||
const PERSISTENT_CODES = new Set<number>([277, 293, 294]);
|
||||
|
||||
/**
|
||||
* Classify a single Withings API response.
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
getValidToken,
|
||||
recordWithingsSyncFailure,
|
||||
} from "./sync";
|
||||
import { isPlanGatedError } from "./api-plan";
|
||||
import {
|
||||
isReauthRequired,
|
||||
parkIntegrationAtReauth,
|
||||
@@ -298,6 +299,19 @@ export async function syncUserActivity(
|
||||
// (Withings emits 403 only through the HTTP layer for scope-gated
|
||||
// resources), so the override has to live at the call site rather
|
||||
// than in the shared classifier.
|
||||
//
|
||||
// Steps, distance and calories are free-plan biomarkers, so a plan refusal
|
||||
// (277) is not expected. If Withings ever moves one, the leg reads nothing
|
||||
// rather than failing, so a plan limit cannot park every Withings leg.
|
||||
if (isPlanGatedError(err)) {
|
||||
getEvent()?.addWarning(
|
||||
"withings activity: refused by the API plan (277), skipped",
|
||||
);
|
||||
await recordSyncSuccess(userId, "withings", {
|
||||
leg: WITHINGS_LEG_ACTIVITY,
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
const withingsStatus =
|
||||
err instanceof WithingsApiError ? err.withingsStatus : undefined;
|
||||
if (withingsStatus === 403) {
|
||||
|
||||
@@ -69,6 +69,12 @@ import {
|
||||
getValidToken,
|
||||
recordWithingsSyncFailure,
|
||||
} from "./sync";
|
||||
import {
|
||||
isPlanGatedError,
|
||||
isPlanReprobeDue,
|
||||
parseWithingsApiPlanState,
|
||||
persistWithingsPlanLeg,
|
||||
} from "./api-plan";
|
||||
import {
|
||||
isReauthRequired,
|
||||
recordSyncFailure,
|
||||
@@ -370,6 +376,22 @@ export async function syncUserEcg(
|
||||
? Math.max(defaultEndUnix, options.enddate!)
|
||||
: defaultEndUnix;
|
||||
|
||||
// Withings API plans (2026-10-12): ECG signals and the AFib verdicts are
|
||||
// Advanced Biomarkers. On the free plan the Heart endpoints answer 277; that
|
||||
// is remembered, and the leg stays quiet until the weekly re-probe asks again.
|
||||
const planRow = await prisma.withingsConnection.findUnique({
|
||||
where: { userId },
|
||||
select: { apiPlanState: true },
|
||||
});
|
||||
const ecgPlan = parseWithingsApiPlanState(planRow?.apiPlanState).ecg;
|
||||
const ecgRefused = (ecgPlan?.unavailable.length ?? 0) > 0;
|
||||
const ecgReprobe = isPlanReprobeDue(ecgPlan, now);
|
||||
if (ecgRefused && !ecgReprobe) {
|
||||
annotate({ meta: { withings_ecg_skipped: "api_plan" } });
|
||||
await recordSyncSuccess(userId, "withings", { leg: WITHINGS_LEG_ECG });
|
||||
return 0;
|
||||
}
|
||||
|
||||
let entries: WithingsHeartEntry[];
|
||||
try {
|
||||
entries = await fetchWithingsHeartList(
|
||||
@@ -378,6 +400,12 @@ export async function syncUserEcg(
|
||||
endUnix,
|
||||
);
|
||||
} catch (err) {
|
||||
if (isPlanGatedError(err)) {
|
||||
await rememberEcgPlan(userId, ["heart"], now);
|
||||
annotate({ meta: { withings_ecg_skipped: "api_plan" } });
|
||||
await recordSyncSuccess(userId, "withings", { leg: WITHINGS_LEG_ECG });
|
||||
return 0;
|
||||
}
|
||||
// The Heart List endpoint rides the user.metrics scope, so a 403 here is a
|
||||
// genuine token-revoked / reauth case rather than the activity-scope skip
|
||||
// the sleep/activity syncs guard against.
|
||||
@@ -398,6 +426,9 @@ export async function syncUserEcg(
|
||||
throw err;
|
||||
}
|
||||
|
||||
// A re-probe the plan now accepts: forget the refusal.
|
||||
if (ecgRefused) await rememberEcgPlan(userId, [], now);
|
||||
|
||||
let imported = 0;
|
||||
let waveformsCaptured = 0;
|
||||
const touched: Array<{ type: MeasurementType; measuredAt: Date }> = [];
|
||||
@@ -520,3 +551,18 @@ export async function syncUserEcg(
|
||||
await recordSyncSuccess(userId, "withings", { leg: WITHINGS_LEG_ECG });
|
||||
return imported;
|
||||
}
|
||||
|
||||
async function rememberEcgPlan(
|
||||
userId: string,
|
||||
unavailable: string[],
|
||||
now: Date,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await persistWithingsPlanLeg(userId, "ecg", {
|
||||
unavailable,
|
||||
checkedAt: now.toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(`withings ecg: plan state write failed: ${err}`);
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@
|
||||
import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
import {
|
||||
@@ -60,6 +60,16 @@ import {
|
||||
} from "@/lib/measurements/reconcile-external-measurement";
|
||||
|
||||
import { hasActivityScope } from "./client";
|
||||
import {
|
||||
isPlanGatedError,
|
||||
isPlanReprobeDue,
|
||||
parseWithingsApiPlanState,
|
||||
persistWithingsPlanLeg,
|
||||
planSkipFor,
|
||||
requestWithinPlan,
|
||||
shouldPersistPlanLeg,
|
||||
WITHINGS_ADVANCED_SLEEP_FIELDS,
|
||||
} from "./api-plan";
|
||||
import {
|
||||
WithingsApiError,
|
||||
classifyWithingsResponse,
|
||||
@@ -94,8 +104,13 @@ const WITHINGS_SLEEP_URL = "https://wbsapi.withings.net/v2/sleep";
|
||||
* requested; snoring / breathing-disturbance intensity have no enum and are
|
||||
* deliberately left out (no home to store them).
|
||||
*/
|
||||
const SLEEP_SUMMARY_DATA_FIELDS =
|
||||
"hr_average,rr_average,sdnn_1,spo2_average,sleep_score";
|
||||
export const SLEEP_SUMMARY_DATA_FIELDS: readonly string[] = [
|
||||
"hr_average",
|
||||
"rr_average",
|
||||
"sdnn_1",
|
||||
"spo2_average",
|
||||
"sleep_score",
|
||||
];
|
||||
|
||||
/**
|
||||
* One nightly summary row from `POST /v2/sleep?action=getsummary`. The vitals
|
||||
@@ -276,12 +291,13 @@ export async function fetchWithingsSleepSummary(
|
||||
accessToken: string,
|
||||
startymd: string,
|
||||
endymd: string,
|
||||
dataFields: readonly string[] = SLEEP_SUMMARY_DATA_FIELDS,
|
||||
): Promise<WithingsSleepSummary[]> {
|
||||
const params = new URLSearchParams({
|
||||
action: "getsummary",
|
||||
startdateymd: startymd,
|
||||
enddateymd: endymd,
|
||||
data_fields: SLEEP_SUMMARY_DATA_FIELDS,
|
||||
data_fields: dataFields.join(","),
|
||||
});
|
||||
|
||||
const pageStart = performance.now();
|
||||
@@ -384,6 +400,15 @@ export async function syncUserSleep(
|
||||
endUnix,
|
||||
);
|
||||
} catch (err) {
|
||||
// The stage timeline is a free-plan biomarker, so a plan refusal here is
|
||||
// not expected. If Withings ever moves it, the leg reads nothing rather
|
||||
// than failing: a 277 must not climb the strike ladder and park the
|
||||
// measures, activity and ECG legs along with it.
|
||||
if (isPlanGatedError(err)) {
|
||||
annotate({ meta: { withings_sleep_segments_skipped: "api_plan" } });
|
||||
await recordSyncSuccess(userId, "withings", { leg: WITHINGS_LEG_SLEEP });
|
||||
return 0;
|
||||
}
|
||||
// v1.4.43 W7-B3 — typed-classification path (see sync-activity.ts
|
||||
// for the full rationale). The thrown `WithingsApiError` carries
|
||||
// the classification verdict; `recordWithingsSyncFailure` routes
|
||||
@@ -513,13 +538,40 @@ export async function syncUserSleep(
|
||||
// v1.18.10 P0 — nightly sleep vitals (avg HR / respiratory rate / SDNN HRV
|
||||
// / avg SpO2 / sleep score) from the per-night summary. Fetch failure stays
|
||||
// isolated from the stage writes; database failures are hard sync failures.
|
||||
//
|
||||
// Withings API plans (2026-10-12): the sleep score, the overnight HRV and the
|
||||
// respiration rate are Advanced Biomarkers, and one of them in `data_fields`
|
||||
// has the whole summary refused with 277. Ask only for what the plan allows,
|
||||
// remember what it refused, and re-probe weekly.
|
||||
let summaries: WithingsSleepSummary[] = [];
|
||||
try {
|
||||
summaries = await fetchWithingsSleepSummary(
|
||||
tokenInfo.accessToken,
|
||||
ymdUtc(start),
|
||||
ymdUtc(now),
|
||||
);
|
||||
const planRow = await prisma.withingsConnection.findUnique({
|
||||
where: { userId },
|
||||
select: { apiPlanState: true },
|
||||
});
|
||||
const sleepPlan = parseWithingsApiPlanState(
|
||||
planRow?.apiPlanState,
|
||||
).sleepSummary;
|
||||
const reprobed = isPlanReprobeDue(sleepPlan, now);
|
||||
const skip = planSkipFor(sleepPlan, now);
|
||||
const plan = await requestWithinPlan({
|
||||
keys: SLEEP_SUMMARY_DATA_FIELDS.filter((f) => !skip.includes(f)),
|
||||
knownGated: WITHINGS_ADVANCED_SLEEP_FIELDS,
|
||||
fetch: (fields) =>
|
||||
fetchWithingsSleepSummary(
|
||||
tokenInfo.accessToken,
|
||||
ymdUtc(start),
|
||||
ymdUtc(now),
|
||||
fields,
|
||||
),
|
||||
});
|
||||
summaries = plan.results;
|
||||
if (shouldPersistPlanLeg({ gated: plan.gated, reprobed })) {
|
||||
await persistWithingsPlanLeg(userId, "sleepSummary", {
|
||||
unavailable: [...skip, ...plan.unavailable],
|
||||
checkedAt: now.toISOString(),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`withings sleep: summary fetch failed for ${userId}: ${err}`,
|
||||
|
||||
@@ -10,7 +10,21 @@ import {
|
||||
fetchMeasurements,
|
||||
refreshAccessToken,
|
||||
subscribeWebhook,
|
||||
WITHINGS_MEASTYPES,
|
||||
type WithingsMeasure,
|
||||
} from "./client";
|
||||
import {
|
||||
isPlanGatedError,
|
||||
isPlanReprobeDue,
|
||||
parseWithingsApiPlanState,
|
||||
persistWithingsPlanLeg,
|
||||
planSkipFor,
|
||||
requestWithinPlan,
|
||||
shouldPersistPlanLeg,
|
||||
WITHINGS_ADVANCED_MEASTYPES,
|
||||
WITHINGS_PLAN_GATED_STATUS,
|
||||
type WithinPlanResult,
|
||||
} from "./api-plan";
|
||||
import { getUserWithingsCredentials } from "./credentials";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
@@ -233,13 +247,66 @@ export async function syncUserMeasurements(
|
||||
)
|
||||
: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days back
|
||||
|
||||
let fetched: Awaited<ReturnType<typeof fetchMeasurements>>;
|
||||
// Withings API plans (2026-10-12): a request naming one meastype outside the
|
||||
// account's plan is refused whole with 277. Leave out what the plan refused
|
||||
// last time (unless the weekly re-probe is due), and let `requestWithinPlan`
|
||||
// narrow the request if the plan refuses something new. A refused run never
|
||||
// reaches the watermark stamp below, so the run after this fix reads the
|
||||
// whole gap since the last good sync.
|
||||
const now = new Date();
|
||||
const planState = parseWithingsApiPlanState(connection.apiPlanState);
|
||||
const reprobed = isPlanReprobeDue(planState.measures, now);
|
||||
const skip = planSkipFor(planState.measures, now);
|
||||
const requested = WITHINGS_MEASTYPES.filter((t) => !skip.includes(t));
|
||||
|
||||
let plan: WithinPlanResult<number, WithingsMeasure>;
|
||||
try {
|
||||
fetched = await fetchMeasurements(tokenInfo.accessToken, startDate);
|
||||
plan = await requestWithinPlan({
|
||||
keys: requested,
|
||||
knownGated: WITHINGS_ADVANCED_MEASTYPES,
|
||||
fetch: (measTypes) =>
|
||||
fetchMeasurements(tokenInfo.accessToken, startDate, undefined, {
|
||||
measTypes,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
await recordWithingsSyncFailure(userId, err, WITHINGS_LEG_MEASURES);
|
||||
throw err;
|
||||
}
|
||||
if (shouldPersistPlanLeg({ gated: plan.gated, reprobed })) {
|
||||
try {
|
||||
await persistWithingsPlanLeg(userId, "measures", {
|
||||
unavailable: [...skip, ...plan.unavailable].sort((a, b) => a - b),
|
||||
checkedAt: now.toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
// Losing the memory only costs a refused round trip next run.
|
||||
getEvent()?.addWarning(`withings: plan state write failed: ${err}`);
|
||||
}
|
||||
}
|
||||
if (plan.gated) {
|
||||
annotate({
|
||||
action: {
|
||||
name: "withings.sync.plan_gated",
|
||||
details: {
|
||||
unavailable: plan.unavailable,
|
||||
requests: plan.requests,
|
||||
acceptedAny: plan.acceptedAny,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (plan.gated && !plan.acceptedAny) {
|
||||
// The plan refused every type asked for. Nothing was read, so the
|
||||
// watermark stays where it is (a later plan change re-reads the gap), and
|
||||
// nothing failed: the connection is healthy, the plan is the limit. The
|
||||
// settings card names what the plan withholds.
|
||||
await recordSyncSuccess(userId, "withings", {
|
||||
leg: WITHINGS_LEG_MEASURES,
|
||||
});
|
||||
return { imported: 0, failed: false };
|
||||
}
|
||||
const fetched = plan.results;
|
||||
|
||||
// The app's own plausibility band applies to a provider reading exactly as
|
||||
// it applies to a typed one. A remote that reports a pulse in the millions
|
||||
@@ -470,6 +537,15 @@ export async function recordWithingsSyncFailure(
|
||||
err: unknown,
|
||||
leg: WithingsSyncLeg,
|
||||
): Promise<void> {
|
||||
// A plan refusal (277) is a limit on the developer app, not a failure of the
|
||||
// connection. It never enters the ledger, so no path that forgets to handle
|
||||
// it can climb the strike ladder or park every leg over it.
|
||||
if (isPlanGatedError(err)) {
|
||||
annotate({
|
||||
meta: { withings_plan_gated_unhandled: { leg } },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await recordSyncFailure({
|
||||
userId,
|
||||
@@ -531,6 +607,8 @@ export function isWithingsRefreshReauthFailure(message: string): boolean {
|
||||
const n = Number.parseInt(status, 10);
|
||||
if (!Number.isFinite(n)) return false;
|
||||
if (n === 100 || n === 101 || n === 102) return true;
|
||||
// 277 is the API-plan refusal: a limit on the developer app, not a grant.
|
||||
if (n === WITHINGS_PLAN_GATED_STATUS) return false;
|
||||
if (n >= 200 && n <= 299) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* The Oura scope note against the real schema: the sync writes the
|
||||
* collections a grant does not cover, writes nothing when the set is
|
||||
* unchanged, clears it when the grant covers everything, and the connection
|
||||
* read hands it back to the next sync.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const USER = "user-oura-scope";
|
||||
|
||||
beforeEach(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: USER,
|
||||
username: "oura-scope",
|
||||
email: "oura-scope@example.test",
|
||||
ouraAccessTokenEncrypted: encrypt("acc"),
|
||||
ouraRefreshTokenEncrypted: encrypt("ref"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
async function stored() {
|
||||
const row = await getPrismaClient().user.findUnique({
|
||||
where: { id: USER },
|
||||
select: { ouraScopeDenied: true, updatedAt: true },
|
||||
});
|
||||
return row!;
|
||||
}
|
||||
|
||||
describe("syncOuraScopeDenied", () => {
|
||||
it("stores, keeps, hands back and clears the scope gap", async () => {
|
||||
const { syncOuraScopeDenied, getOuraConnection } =
|
||||
await import("@/lib/oura/credentials");
|
||||
|
||||
await syncOuraScopeDenied(USER, ["vo2max", "spo2"]);
|
||||
const first = await stored();
|
||||
expect(first.ouraScopeDenied).toEqual(["spo2", "vo2max"]);
|
||||
expect((await getOuraConnection(USER))?.scopeDenied).toEqual([
|
||||
"spo2",
|
||||
"vo2max",
|
||||
]);
|
||||
|
||||
// Same set in another order: no write.
|
||||
await syncOuraScopeDenied(USER, ["spo2", "vo2max"]);
|
||||
expect((await stored()).updatedAt).toEqual(first.updatedAt);
|
||||
|
||||
await syncOuraScopeDenied(USER, []);
|
||||
expect((await stored()).ouraScopeDenied).toBeNull();
|
||||
expect((await getOuraConnection(USER))?.scopeDenied).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Withings API plans (2026-10-12) against the real schema: the plan memory is
|
||||
* merged per leg by one SQL statement, a measure sync refused with 277 still
|
||||
* imports what the plan allows, and a connection an earlier build parked over
|
||||
* a 277 is lifted.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const USER = "user-withings-api-plan";
|
||||
const LAST_GOOD_SYNC = new Date(Date.now() - 2 * 86_400_000);
|
||||
const ADVANCED = new Set([91, 155, 170, 123, 73]);
|
||||
|
||||
beforeEach(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: USER,
|
||||
username: "withings-api-plan",
|
||||
email: "withings-api-plan@example.test",
|
||||
},
|
||||
});
|
||||
await prisma.withingsConnection.create({
|
||||
data: {
|
||||
userId: USER,
|
||||
withingsUserId: "wu-plan",
|
||||
accessToken: encrypt("access-token"),
|
||||
refreshToken: encrypt("refresh-token"),
|
||||
tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
scope: "user.metrics,user.activity",
|
||||
lastSyncedAt: LAST_GOOD_SYNC,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/** A plan that refuses the documented advanced meastypes with 277. */
|
||||
function stubFreePlan() {
|
||||
const calls: number[][] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_url: string, init: { body: string }) => {
|
||||
const types = (new URLSearchParams(init.body).get("meastypes") ?? "")
|
||||
.split(",")
|
||||
.map(Number);
|
||||
calls.push(types);
|
||||
if (types.some((t) => ADVANCED.has(t))) {
|
||||
return { status: 200, json: async () => ({ status: 277 }) };
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
status: 0,
|
||||
body: {
|
||||
measuregrps: [
|
||||
{
|
||||
grpid: 1,
|
||||
date: Math.floor((Date.now() - 86_400_000) / 1000),
|
||||
measures: [
|
||||
{ type: 1, value: 8120, unit: -2 },
|
||||
{ type: 10, value: 128, unit: 0 },
|
||||
{ type: 9, value: 82, unit: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
more: 0,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
return calls;
|
||||
}
|
||||
|
||||
describe("Withings plan memory", () => {
|
||||
it("merges each leg without dropping the other", async () => {
|
||||
const { persistWithingsPlanLeg, parseWithingsApiPlanState } =
|
||||
await import("@/lib/withings/api-plan");
|
||||
await persistWithingsPlanLeg(USER, "measures", {
|
||||
unavailable: [155, 91],
|
||||
checkedAt: "2026-10-14T10:00:00.000Z",
|
||||
});
|
||||
await persistWithingsPlanLeg(USER, "ecg", {
|
||||
unavailable: ["heart"],
|
||||
checkedAt: "2026-10-14T10:41:00.000Z",
|
||||
});
|
||||
|
||||
const row = await getPrismaClient().withingsConnection.findUnique({
|
||||
where: { userId: USER },
|
||||
select: { apiPlanState: true },
|
||||
});
|
||||
expect(parseWithingsApiPlanState(row?.apiPlanState)).toEqual({
|
||||
version: 1,
|
||||
measures: {
|
||||
unavailable: [155, 91],
|
||||
checkedAt: "2026-10-14T10:00:00.000Z",
|
||||
},
|
||||
ecg: { unavailable: ["heart"], checkedAt: "2026-10-14T10:41:00.000Z" },
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces a stored value that is not a v1 object", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.withingsConnection.update({
|
||||
where: { userId: USER },
|
||||
data: { apiPlanState: { version: 2, measures: "junk" } },
|
||||
});
|
||||
const { persistWithingsPlanLeg } = await import("@/lib/withings/api-plan");
|
||||
await persistWithingsPlanLeg(USER, "sleepSummary", {
|
||||
unavailable: ["sleep_score"],
|
||||
checkedAt: "2026-10-14T10:15:00.000Z",
|
||||
});
|
||||
|
||||
const row = await prisma.withingsConnection.findUnique({
|
||||
where: { userId: USER },
|
||||
select: { apiPlanState: true },
|
||||
});
|
||||
expect(row?.apiPlanState).toEqual({
|
||||
version: 1,
|
||||
sleepSummary: {
|
||||
unavailable: ["sleep_score"],
|
||||
checkedAt: "2026-10-14T10:15:00.000Z",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncUserMeasurements on the free plan", () => {
|
||||
it("imports the core types, remembers the refusal and skips it next time", async () => {
|
||||
const calls = stubFreePlan();
|
||||
const { syncUserMeasurements } = await import("@/lib/withings/sync");
|
||||
|
||||
const first = await syncUserMeasurements(USER);
|
||||
expect(first.failed).toBe(false);
|
||||
expect(first.imported).toBe(3);
|
||||
expect(calls).toHaveLength(2);
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const conn = await prisma.withingsConnection.findUnique({
|
||||
where: { userId: USER },
|
||||
select: { apiPlanState: true, lastSyncedAt: true },
|
||||
});
|
||||
expect(conn?.lastSyncedAt?.getTime()).toBeGreaterThan(
|
||||
LAST_GOOD_SYNC.getTime(),
|
||||
);
|
||||
expect(
|
||||
(conn?.apiPlanState as { measures: { unavailable: number[] } }).measures
|
||||
.unavailable,
|
||||
).toEqual([73, 91, 123, 155, 170]);
|
||||
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: { userId: USER, source: "WITHINGS" },
|
||||
select: { type: true },
|
||||
});
|
||||
expect(rows.map((r) => r.type).sort()).toEqual([
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"WEIGHT",
|
||||
]);
|
||||
const status = await prisma.integrationStatus.findUnique({
|
||||
where: { userId_integration: { userId: USER, integration: "withings" } },
|
||||
select: { state: true },
|
||||
});
|
||||
expect(status?.state).toBe("connected");
|
||||
|
||||
calls.length = 0;
|
||||
await syncUserMeasurements(USER);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.some((t) => ADVANCED.has(t))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("releasePlanGatedWithingsParks", () => {
|
||||
it("lifts a park recorded over a 277 and leaves a revoked grant parked", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: "user-revoked",
|
||||
username: "revoked",
|
||||
email: "revoked@example.test",
|
||||
},
|
||||
});
|
||||
await prisma.integrationStatus.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: USER,
|
||||
integration: "withings",
|
||||
state: "error_reauth",
|
||||
lastError: encrypt(
|
||||
"Withings measure error: 277 - One of the data type requested in this API request is not available in your API plan",
|
||||
),
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 0,
|
||||
reauth_required: 30,
|
||||
persistent: 0,
|
||||
},
|
||||
failingLegs: ["measures", "ecg"],
|
||||
},
|
||||
{
|
||||
userId: "user-revoked",
|
||||
integration: "withings",
|
||||
state: "error_reauth",
|
||||
lastError: encrypt("Withings refresh error: 101 - invalid token"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { releasePlanGatedWithingsParks } =
|
||||
await import("@/lib/withings/api-plan");
|
||||
expect(await releasePlanGatedWithingsParks()).toBe(1);
|
||||
|
||||
const rows = await prisma.integrationStatus.findMany({
|
||||
orderBy: { userId: "asc" },
|
||||
select: { userId: true, state: true, failingLegs: true },
|
||||
});
|
||||
expect(rows).toEqual([
|
||||
{ userId: "user-revoked", state: "error_reauth", failingLegs: null },
|
||||
{ userId: USER, state: "connected", failingLegs: [] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user