feat(integrations): add Polar and Oura over OAuth

Connect Polar and Oura through full OAuth, mirroring the existing wearable
house-pattern: a signed, expiring, provider-bound state doubles as an httpOnly
cookie, tokens are encrypted on the user, and an hourly job pulls sleep, heart
rate, respiratory and activity into canonical measurements under each source,
idempotently. Polar Nightly Recharge and Oura readiness feed the recovery
series, which now ranks WHOOP, then Oura, then Polar over the computed proxy.
Settings cards, localized copy and the credential env contract are wired for
both; the providers are optional and configured all-or-none.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 20:13:03 +02:00
commit 6fba9568ab
44 files changed
+4077 -13

No files matched your search

+23
View File
@@ -50,6 +50,29 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# WHOOP_WEBHOOK_SECRET="<openssl rand -hex 32>"
# WHOOP_REDIRECT_URI=""
# -----------------------------------------------------------------------------
# Polar AccessLink -- optional integration (full OAuth, shared app from env)
# -----------------------------------------------------------------------------
# Register an app at https://admin.polaraccesslink.com and set its redirect URI
# to `${NEXT_PUBLIC_APP_URL}/api/polar/callback`. Both vars together enable the
# Polar connect button; set neither to leave the integration disabled. Polar
# access tokens do not expire and carry no refresh token. POLAR_REDIRECT_URI
# overrides the derived callback when the public URL differs.
# POLAR_CLIENT_ID=""
# POLAR_CLIENT_SECRET=""
# POLAR_REDIRECT_URI=""
# -----------------------------------------------------------------------------
# Oura Cloud v2 -- optional integration (full OAuth, shared app from env)
# -----------------------------------------------------------------------------
# Register an app at https://cloud.ouraring.com/oauth/applications and set its
# redirect URI to `${NEXT_PUBLIC_APP_URL}/api/oura/callback`. Both vars together
# enable the Oura connect button. Oura uses refresh tokens; the sync refreshes
# reactively on a 401. OURA_REDIRECT_URI overrides the derived callback.
# OURA_CLIENT_ID=""
# OURA_CLIENT_SECRET=""
# OURA_REDIRECT_URI=""
# -----------------------------------------------------------------------------
# APNs -- iOS push (optional, all-or-none)
+12
View File
@@ -77,6 +77,18 @@ services:
# container (the `environment:` block is a whitelist).
WHOOP_WEBHOOK_SECRET: "${WHOOP_WEBHOOK_SECRET:-}"
WHOOP_REDIRECT_URI: "${WHOOP_REDIRECT_URI:-}"
# v1.17.0 (F4) — Polar AccessLink + Oura Cloud OAuth (shared app from
# env). Both pairs together enable each connect button; an unset pair
# leaves that integration disabled. The optional *_REDIRECT_URI overrides
# the derived `${NEXT_PUBLIC_APP_URL}/api/{polar,oura}/callback`. Listed
# here so operator values in .env reach the container (the `environment:`
# block is a whitelist).
POLAR_CLIENT_ID: "${POLAR_CLIENT_ID:-}"
POLAR_CLIENT_SECRET: "${POLAR_CLIENT_SECRET:-}"
POLAR_REDIRECT_URI: "${POLAR_REDIRECT_URI:-}"
OURA_CLIENT_ID: "${OURA_CLIENT_ID:-}"
OURA_CLIENT_SECRET: "${OURA_CLIENT_SECRET:-}"
OURA_REDIRECT_URI: "${OURA_REDIRECT_URI:-}"
# Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates
# POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the
# deployment-dashboard URL (e.g. the Coolify UI) linked in
+41 -1
View File
@@ -4585,7 +4585,47 @@
"nightscoutConnectFailed": "Verbindung zu Nightscout fehlgeschlagen.",
"nightscoutDisconnect": "Trennen",
"nightscoutDisconnectTitle": "Nightscout trennen?",
"nightscoutDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Glukosewerte bleiben in deinem Verlauf."
"nightscoutDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Glukosewerte bleiben in deinem Verlauf.",
"polar": "Polar",
"polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen und Tagesaktivität zu synchronisieren.",
"polarHelp": "Melde dich bei Polar an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.",
"polarUnavailable": "Polar ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Polar-OAuth-Zugangsdaten zu hinterlegen.",
"polarConnect": "Polar verbinden",
"polarDisconnect": "Trennen",
"polarDisconnectTitle": "Polar trennen?",
"polarDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Werte bleiben in deinem Verlauf.",
"polarOauthConnected": "Polar verbunden — die erste Synchronisierung startet im Hintergrund.",
"polarOauthFailed": "Polar-Verbindung fehlgeschlagen",
"polarOauthError": {
"csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.",
"state": "Die Antwort von Polar passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.",
"cross_user": "Die Antwort gehört zu einer Anfrage eines anderen Kontos. Bitte starte die Verbindung aus deinem eigenen Konto erneut.",
"nocode": "Polar hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Polar-Dialog.",
"nocreds": "Polar ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Polar-OAuth-Zugangsdaten zu hinterlegen.",
"token": "Der Token-Austausch mit Polar ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
"rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.",
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
},
"oura": "Oura",
"ouraDescription": "Verbinde dein Oura-Konto, um Readiness, Schlafphasen, HRV und Tagesaktivität zu synchronisieren.",
"ouraHelp": "Melde dich bei Oura an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.",
"ouraUnavailable": "Oura ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Oura-OAuth-Zugangsdaten zu hinterlegen.",
"ouraConnect": "Oura verbinden",
"ouraDisconnect": "Trennen",
"ouraDisconnectTitle": "Oura trennen?",
"ouraDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Werte bleiben in deinem Verlauf.",
"ouraOauthConnected": "Oura verbunden — die erste Synchronisierung startet im Hintergrund.",
"ouraOauthFailed": "Oura-Verbindung fehlgeschlagen",
"ouraOauthError": {
"csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.",
"state": "Die Antwort von Oura passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.",
"cross_user": "Die Antwort gehört zu einer Anfrage eines anderen Kontos. Bitte starte die Verbindung aus deinem eigenen Konto erneut.",
"nocode": "Oura hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Oura-Dialog.",
"nocreds": "Oura ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Oura-OAuth-Zugangsdaten zu hinterlegen.",
"token": "Der Token-Austausch mit Oura ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
"rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.",
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
}
},
"admin": {
"title": "Administration",
+41 -1
View File
@@ -4585,7 +4585,47 @@
"nightscoutConnectFailed": "Could not connect to Nightscout.",
"nightscoutDisconnect": "Disconnect",
"nightscoutDisconnectTitle": "Disconnect Nightscout?",
"nightscoutDisconnectDescription": "Future syncs stop. Glucose readings already imported stay in your history."
"nightscoutDisconnectDescription": "Future syncs stop. Glucose readings already imported stay in your history.",
"polar": "Polar",
"polarDescription": "Connect your Polar account to sync Nightly Recharge recovery, sleep stages, and daily activity.",
"polarHelp": "Sign in with Polar to grant access. Data is pulled in the background once an hour.",
"polarUnavailable": "Polar is not configured on this server. Ask your administrator to add the Polar OAuth credentials.",
"polarConnect": "Connect Polar",
"polarDisconnect": "Disconnect",
"polarDisconnectTitle": "Disconnect Polar?",
"polarDisconnectDescription": "Future syncs stop. Readings already imported stay in your history.",
"polarOauthConnected": "Polar connected — the first sync starts in the background.",
"polarOauthFailed": "Polar connection failed",
"polarOauthError": {
"csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.",
"state": "The response from Polar did not match any open connection request. Please start the connection again.",
"cross_user": "The response belongs to a request from a different account. Please restart the connection from your own account.",
"nocode": "Polar did not return an authorization code. Try again and allow access in the Polar dialog.",
"nocreds": "Polar is not configured on this server. Ask your administrator to add the Polar OAuth credentials.",
"token": "The token exchange with Polar failed. Please start the connection again.",
"rate_limited": "Too many connection attempts. Wait a moment and try again.",
"generic": "Unknown error while connecting. Please start the connection again."
},
"oura": "Oura",
"ouraDescription": "Connect your Oura account to sync readiness, sleep stages, HRV, and daily activity.",
"ouraHelp": "Sign in with Oura to grant access. Data is pulled in the background once an hour.",
"ouraUnavailable": "Oura is not configured on this server. Ask your administrator to add the Oura OAuth credentials.",
"ouraConnect": "Connect Oura",
"ouraDisconnect": "Disconnect",
"ouraDisconnectTitle": "Disconnect Oura?",
"ouraDisconnectDescription": "Future syncs stop. Readings already imported stay in your history.",
"ouraOauthConnected": "Oura connected — the first sync starts in the background.",
"ouraOauthFailed": "Oura connection failed",
"ouraOauthError": {
"csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.",
"state": "The response from Oura did not match any open connection request. Please start the connection again.",
"cross_user": "The response belongs to a request from a different account. Please restart the connection from your own account.",
"nocode": "Oura did not return an authorization code. Try again and allow access in the Oura dialog.",
"nocreds": "Oura is not configured on this server. Ask your administrator to add the Oura OAuth credentials.",
"token": "The token exchange with Oura failed. Please start the connection again.",
"rate_limited": "Too many connection attempts. Wait a moment and try again.",
"generic": "Unknown error while connecting. Please start the connection again."
}
},
"admin": {
"title": "Administration",
+41 -1
View File
@@ -4585,7 +4585,47 @@
"nightscoutConnectFailed": "No se pudo conectar con Nightscout.",
"nightscoutDisconnect": "Desconectar",
"nightscoutDisconnectTitle": "¿Desconectar Nightscout?",
"nightscoutDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas de glucosa ya importadas permanecen en tu historial."
"nightscoutDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas de glucosa ya importadas permanecen en tu historial.",
"polar": "Polar",
"polarDescription": "Conecta tu cuenta de Polar para sincronizar la recuperación Nightly Recharge, las fases de sueño y la actividad diaria.",
"polarHelp": "Inicia sesión en Polar para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.",
"polarUnavailable": "Polar no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Polar.",
"polarConnect": "Conectar Polar",
"polarDisconnect": "Desconectar",
"polarDisconnectTitle": "¿Desconectar Polar?",
"polarDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas ya importadas permanecen en tu historial.",
"polarOauthConnected": "Polar conectado: la primera sincronización empieza en segundo plano.",
"polarOauthFailed": "Error al conectar con Polar",
"polarOauthError": {
"csrf1": "La verificación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.",
"state": "La respuesta de Polar no coincidió con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.",
"cross_user": "La respuesta pertenece a una solicitud de otra cuenta. Reinicia la conexión desde tu propia cuenta.",
"nocode": "Polar no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Polar.",
"nocreds": "Polar no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Polar.",
"token": "El intercambio de token con Polar falló. Inicia la conexión de nuevo.",
"rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.",
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
},
"oura": "Oura",
"ouraDescription": "Conecta tu cuenta de Oura para sincronizar readiness, fases de sueño, VFC y actividad diaria.",
"ouraHelp": "Inicia sesión en Oura para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.",
"ouraUnavailable": "Oura no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Oura.",
"ouraConnect": "Conectar Oura",
"ouraDisconnect": "Desconectar",
"ouraDisconnectTitle": "¿Desconectar Oura?",
"ouraDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas ya importadas permanecen en tu historial.",
"ouraOauthConnected": "Oura conectado: la primera sincronización empieza en segundo plano.",
"ouraOauthFailed": "Error al conectar con Oura",
"ouraOauthError": {
"csrf1": "La verificación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.",
"state": "La respuesta de Oura no coincidió con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.",
"cross_user": "La respuesta pertenece a una solicitud de otra cuenta. Reinicia la conexión desde tu propia cuenta.",
"nocode": "Oura no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Oura.",
"nocreds": "Oura no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Oura.",
"token": "El intercambio de token con Oura falló. Inicia la conexión de nuevo.",
"rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.",
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
}
},
"admin": {
"title": "Administration",
+41 -1
View File
@@ -4585,7 +4585,47 @@
"nightscoutConnectFailed": "Impossible de se connecter à Nightscout.",
"nightscoutDisconnect": "Déconnecter",
"nightscoutDisconnectTitle": "Déconnecter Nightscout ?",
"nightscoutDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures de glycémie déjà importées restent dans votre historique."
"nightscoutDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures de glycémie déjà importées restent dans votre historique.",
"polar": "Polar",
"polarDescription": "Connectez votre compte Polar pour synchroniser la récupération Nightly Recharge, les phases de sommeil et l'activité quotidienne.",
"polarHelp": "Connectez-vous à Polar pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.",
"polarUnavailable": "Polar n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Polar.",
"polarConnect": "Connecter Polar",
"polarDisconnect": "Déconnecter",
"polarDisconnectTitle": "Déconnecter Polar ?",
"polarDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures déjà importées restent dans votre historique.",
"polarOauthConnected": "Polar connecté — la première synchronisation démarre en arrière-plan.",
"polarOauthFailed": "Échec de la connexion à Polar",
"polarOauthError": {
"csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était absent. Veuillez relancer la connexion dans le même navigateur.",
"state": "La réponse de Polar ne correspondait à aucune demande de connexion en cours. Veuillez relancer la connexion.",
"cross_user": "La réponse appartient à une demande d'un autre compte. Veuillez relancer la connexion depuis votre propre compte.",
"nocode": "Polar n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Polar.",
"nocreds": "Polar n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Polar.",
"token": "L'échange de jeton avec Polar a échoué. Veuillez relancer la connexion.",
"rate_limited": "Trop de tentatives de connexion. Patientez un instant et réessayez.",
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
},
"oura": "Oura",
"ouraDescription": "Connectez votre compte Oura pour synchroniser le readiness, les phases de sommeil, la VFC et l'activité quotidienne.",
"ouraHelp": "Connectez-vous à Oura pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.",
"ouraUnavailable": "Oura n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Oura.",
"ouraConnect": "Connecter Oura",
"ouraDisconnect": "Déconnecter",
"ouraDisconnectTitle": "Déconnecter Oura ?",
"ouraDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures déjà importées restent dans votre historique.",
"ouraOauthConnected": "Oura connecté — la première synchronisation démarre en arrière-plan.",
"ouraOauthFailed": "Échec de la connexion à Oura",
"ouraOauthError": {
"csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était absent. Veuillez relancer la connexion dans le même navigateur.",
"state": "La réponse d'Oura ne correspondait à aucune demande de connexion en cours. Veuillez relancer la connexion.",
"cross_user": "La réponse appartient à une demande d'un autre compte. Veuillez relancer la connexion depuis votre propre compte.",
"nocode": "Oura n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Oura.",
"nocreds": "Oura n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Oura.",
"token": "L'échange de jeton avec Oura a échoué. Veuillez relancer la connexion.",
"rate_limited": "Trop de tentatives de connexion. Patientez un instant et réessayez.",
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
}
},
"admin": {
"title": "Administration",
+41 -1
View File
@@ -4585,7 +4585,47 @@
"nightscoutConnectFailed": "Impossibile connettersi a Nightscout.",
"nightscoutDisconnect": "Disconnetti",
"nightscoutDisconnectTitle": "Disconnettere Nightscout?",
"nightscoutDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture della glicemia già importate restano nella tua cronologia."
"nightscoutDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture della glicemia già importate restano nella tua cronologia.",
"polar": "Polar",
"polarDescription": "Collega il tuo account Polar per sincronizzare il recupero Nightly Recharge, le fasi del sonno e l'attività giornaliera.",
"polarHelp": "Accedi a Polar per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.",
"polarUnavailable": "Polar non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Polar.",
"polarConnect": "Collega Polar",
"polarDisconnect": "Disconnetti",
"polarDisconnectTitle": "Disconnettere Polar?",
"polarDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture già importate restano nella tua cronologia.",
"polarOauthConnected": "Polar collegato — la prima sincronizzazione parte in background.",
"polarOauthFailed": "Connessione a Polar non riuscita",
"polarOauthError": {
"csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.",
"state": "La risposta di Polar non corrispondeva a nessuna richiesta di connessione aperta. Riavvia la connessione.",
"cross_user": "La risposta appartiene a una richiesta di un altro account. Riavvia la connessione dal tuo account.",
"nocode": "Polar non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di Polar.",
"nocreds": "Polar non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Polar.",
"token": "Lo scambio del token con Polar è fallito. Riavvia la connessione.",
"rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.",
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
},
"oura": "Oura",
"ouraDescription": "Collega il tuo account Oura per sincronizzare readiness, fasi del sonno, HRV e attività giornaliera.",
"ouraHelp": "Accedi a Oura per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.",
"ouraUnavailable": "Oura non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Oura.",
"ouraConnect": "Collega Oura",
"ouraDisconnect": "Disconnetti",
"ouraDisconnectTitle": "Disconnettere Oura?",
"ouraDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture già importate restano nella tua cronologia.",
"ouraOauthConnected": "Oura collegato — la prima sincronizzazione parte in background.",
"ouraOauthFailed": "Connessione a Oura non riuscita",
"ouraOauthError": {
"csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.",
"state": "La risposta di Oura non corrispondeva a nessuna richiesta di connessione aperta. Riavvia la connessione.",
"cross_user": "La risposta appartiene a una richiesta di un altro account. Riavvia la connessione dal tuo account.",
"nocode": "Oura non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di Oura.",
"nocreds": "Oura non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Oura.",
"token": "Lo scambio del token con Oura è fallito. Riavvia la connessione.",
"rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.",
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
}
},
"admin": {
"title": "Administration",
+41 -1
View File
@@ -4585,7 +4585,47 @@
"nightscoutConnectFailed": "Nie można połączyć z Nightscout.",
"nightscoutDisconnect": "Rozłącz",
"nightscoutDisconnectTitle": "Rozłączyć Nightscout?",
"nightscoutDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty glukozy pozostaną w historii."
"nightscoutDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty glukozy pozostaną w historii.",
"polar": "Polar",
"polarDescription": "Połącz konto Polar, aby synchronizować regenerację Nightly Recharge, fazy snu i aktywność dzienną.",
"polarHelp": "Zaloguj się w Polar, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.",
"polarUnavailable": "Polar nie jest skonfigurowany na tym serwerze. Poproś administratora o dodanie danych OAuth Polar.",
"polarConnect": "Połącz Polar",
"polarDisconnect": "Rozłącz",
"polarDisconnectTitle": "Rozłączyć Polar?",
"polarDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty pozostaną w historii.",
"polarOauthConnected": "Polar połączony — pierwsza synchronizacja startuje w tle.",
"polarOauthFailed": "Połączenie z Polar nie powiodło się",
"polarOauthError": {
"csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.",
"state": "Odpowiedź z Polar nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.",
"cross_user": "Odpowiedź należy do żądania z innego konta. Rozpocznij połączenie ponownie z własnego konta.",
"nocode": "Polar nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Polar.",
"nocreds": "Polar nie jest skonfigurowany na tym serwerze. Poproś administratora o dodanie danych OAuth Polar.",
"token": "Wymiana tokena z Polar nie powiodła się. Rozpocznij połączenie ponownie.",
"rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.",
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
},
"oura": "Oura",
"ouraDescription": "Połącz konto Oura, aby synchronizować readiness, fazy snu, HRV i aktywność dzienną.",
"ouraHelp": "Zaloguj się w Oura, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.",
"ouraUnavailable": "Oura nie jest skonfigurowana na tym serwerze. Poproś administratora o dodanie danych OAuth Oura.",
"ouraConnect": "Połącz Oura",
"ouraDisconnect": "Rozłącz",
"ouraDisconnectTitle": "Rozłączyć Oura?",
"ouraDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty pozostaną w historii.",
"ouraOauthConnected": "Oura połączona — pierwsza synchronizacja startuje w tle.",
"ouraOauthFailed": "Połączenie z Oura nie powiodło się",
"ouraOauthError": {
"csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.",
"state": "Odpowiedź z Oura nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.",
"cross_user": "Odpowiedź należy do żądania z innego konta. Rozpocznij połączenie ponownie z własnego konta.",
"nocode": "Oura nie zwróciła kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Oura.",
"nocreds": "Oura nie jest skonfigurowana na tym serwerze. Poproś administratora o dodanie danych OAuth Oura.",
"token": "Wymiana tokena z Oura nie powiodła się. Rozpocznij połączenie ponownie.",
"rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.",
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
}
},
"admin": {
"title": "Administracja",
+32
View File
@@ -54,6 +54,38 @@
}
]
},
{
"name": "Polar OAuth",
"description": "Required to enable the Polar AccessLink integration. App boots without these but the Polar connect button stays disabled. Register an app at https://admin.polaraccesslink.com and set the redirect URI to ${NEXT_PUBLIC_APP_URL}/api/polar/callback.",
"required": false,
"allOrNone": true,
"variables": [
{
"name": "POLAR_CLIENT_ID",
"purpose": "Polar AccessLink OAuth client id."
},
{
"name": "POLAR_CLIENT_SECRET",
"purpose": "Polar AccessLink OAuth client secret."
}
]
},
{
"name": "Oura OAuth",
"description": "Required to enable the Oura Cloud v2 integration. App boots without these but the Oura connect button stays disabled. Register an app at https://cloud.ouraring.com/oauth/applications and set the redirect URI to ${NEXT_PUBLIC_APP_URL}/api/oura/callback.",
"required": false,
"allOrNone": true,
"variables": [
{
"name": "OURA_CLIENT_ID",
"purpose": "Oura Cloud OAuth client id."
},
{
"name": "OURA_CLIENT_SECRET",
"purpose": "Oura Cloud OAuth client secret."
}
]
},
{
"name": "APNs (iOS push)",
"description": "All four core APNS_* vars together. Missing any disables APNs silently — the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic <all-or-none> required row whenever 1-3 of the 4 are set.",
+96
View File
@@ -0,0 +1,96 @@
import { prisma } from "@/lib/db";
import { apiHandler } from "@/lib/api-handler";
import { getSession } from "@/lib/auth/session";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import { exchangeCode, getOuraCredentials } from "@/lib/oura/client";
import {
oauthStateCookieName,
stateMatchesCookie,
verifySignedState,
} from "@/lib/oauth/signed-state";
import { markReconnected } from "@/lib/integrations/status";
import { NextRequest, NextResponse } from "next/server";
/**
* v1.17.0 (F4) — OAuth callback from Oura.
*
* Identity + CSRF mirror the Polar callback: the `state` param is a stateless
* HMAC-signed token, enforced as a byte-exact double-submit against the
* httpOnly cookie AND verified for signature / expiry / `oura` provider
* binding. The `userId` is read from inside the verified token, cross-checked
* against the web session when present.
*
* On success: exchange the code, persist the encrypted access + refresh token
* on `User`, clear any prior reauth state, and redirect back to Settings.
*/
const ERR = (reason: string) =>
NextResponse.redirect(
new URL(
`/settings/integrations?oura=error&reason=${reason}`,
process.env.NEXT_PUBLIC_APP_URL!,
),
);
export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "oura.callback" } });
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const state = searchParams.get("state");
const cookieState = request.cookies.get(oauthStateCookieName("oura"))?.value;
if (!stateMatchesCookie(state, cookieState)) {
annotate({ meta: { reason: "csrf1" } });
return ERR("csrf1");
}
const verified = verifySignedState("oura", state);
if (!verified) {
annotate({ meta: { reason: "state" } });
return ERR("state");
}
const userId = verified.userId;
const evt = getEvent();
if (evt) evt.setAuth({ user_id: userId, auth_method: "session" });
const sessionData = await getSession();
if (sessionData && sessionData.user.id !== userId) {
annotate({ meta: { reason: "cross_user" } });
return ERR("cross_user");
}
if (!code) return ERR("nocode");
try {
const creds = getOuraCredentials();
if (!creds) return ERR("nocreds");
const tokens = await exchangeCode(code, creds);
await prisma.user.update({
where: { id: userId },
data: {
ouraAccessTokenEncrypted: encrypt(tokens.access_token),
ouraRefreshTokenEncrypted: encrypt(tokens.refresh_token),
},
});
await auditLog("oura.connect", { userId });
await markReconnected(userId, "oura");
const response = NextResponse.redirect(
new URL(
"/settings/integrations?oura=connected",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
response.cookies.delete(oauthStateCookieName("oura"));
return response;
} catch (err) {
getEvent()?.setError(err);
return ERR("token");
}
});
+60
View File
@@ -0,0 +1,60 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAuthorizationUrl, getOuraCredentials } from "@/lib/oura/client";
import {
OAUTH_STATE_TTL_MS,
mintSignedState,
oauthStateCookieName,
} from "@/lib/oauth/signed-state";
import { NextResponse } from "next/server";
/**
* v1.17.0 (F4) — redirect the user to the Oura consent screen.
*
* Full OAuth from env (`OURA_CLIENT_ID` / `OURA_CLIENT_SECRET`). CSRF via the
* stateless signed state (`src/lib/oauth/signed-state.ts`): the same token is
* the `state` URL param AND an httpOnly cookie. Rate-limited per user.
*/
const CONNECT_RATE_LIMIT = 10;
const CONNECT_WINDOW_MS = 60_000;
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "oura.connect" } });
const rl = await checkRateLimit(
`oura:connect:${user.id}`,
CONNECT_RATE_LIMIT,
CONNECT_WINDOW_MS,
);
if (!rl.allowed) {
annotate({ action: { name: "oura.connect.rate_limited" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?oura=error&reason=rate_limited",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
const creds = getOuraCredentials();
if (!creds) {
return apiError("Oura integration is not configured on this server.", 400);
}
const state = mintSignedState("oura", user.id);
const url = getAuthorizationUrl(state, creds);
const response = NextResponse.redirect(url);
response.cookies.set(oauthStateCookieName("oura"), state, {
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: Math.floor(OAUTH_STATE_TTL_MS / 1000),
path: "/",
});
return response;
});
+39
View File
@@ -0,0 +1,39 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { markDisconnected } from "@/lib/integrations/status";
/**
* v1.17.0 (F4) — disconnect Oura for the current user.
*
* Clears the stored access + refresh token on `User` and parks the integration
* status at `disconnected`. Imported `source = OURA` measurements are left
* intact; a reconnect resumes sync.
*/
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "oura.disconnect" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { ouraAccessTokenEncrypted: true },
});
if (!dbUser?.ouraAccessTokenEncrypted) {
return apiError("No Oura connection", 404);
}
await prisma.user.update({
where: { id: user.id },
data: {
ouraAccessTokenEncrypted: null,
ouraRefreshTokenEncrypted: null,
},
});
await auditLog("oura.disconnect", { userId: user.id });
await markDisconnected(user.id, "oura");
return apiSuccess({ disconnected: true });
});
@@ -0,0 +1,75 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
}));
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn() } },
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ data, error: null }),
}));
vi.mock("@/lib/oura/client", () => ({
getOuraCredentials: vi.fn(() => ({ clientId: "c", clientSecret: "s" })),
}));
vi.mock("@/lib/integrations/status", () => ({
getIntegrationStatus: vi.fn(async () => ({
integration: "oura",
state: "error_reauth",
lastSuccessAt: null,
lastAttemptAt: "2026-06-10T00:00:00.000Z",
lastError: "http_401",
consecutiveFailuresByKind: null,
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getOuraCredentials } from "@/lib/oura/client";
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const credsMock = getOuraCredentials as ReturnType<typeof vi.fn>;
type Body = {
connected: boolean;
available: boolean;
state?: string;
lastError?: string | null;
};
const call = () =>
(GET as unknown as () => Promise<{ data: Body }>)().then((r) => r.data);
describe("GET /api/oura/status", () => {
beforeEach(() => {
vi.clearAllMocks();
credsMock.mockReturnValue({ clientId: "c", clientSecret: "s" });
});
it("reports not-connected but available", async () => {
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: null });
const res = await call();
expect(res.connected).toBe(false);
expect(res.available).toBe(true);
});
it("surfaces the ledger reauth state when connected", async () => {
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "enc" });
const res = await call();
expect(res.connected).toBe(true);
expect(res.state).toBe("error_reauth");
expect(res.lastError).toBe("http_401");
});
it("never returns the token", async () => {
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "enc-secret" });
const res = (await call()) as Record<string, unknown>;
expect(JSON.stringify(res)).not.toContain("enc-secret");
});
});
+42
View File
@@ -0,0 +1,42 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { getOuraCredentials } from "@/lib/oura/client";
import { getIntegrationStatus } from "@/lib/integrations/status";
/**
* v1.17.0 (F4) — Oura connection status for the current user.
*
* `available` reports whether the server has the shared OAuth app configured.
* `connected` is whether a token is stored. The ledger snapshot comes off the
* shared `oura` IntegrationKey. The access / refresh tokens are NEVER returned.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "oura.status" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { ouraAccessTokenEncrypted: true },
});
const available = !!getOuraCredentials();
const connected = !!dbUser?.ouraAccessTokenEncrypted;
if (!connected) {
return apiSuccess({ connected: false, configured: false, available });
}
const status = await getIntegrationStatus(user.id, "oura");
return apiSuccess({
connected: true,
configured: true,
available,
state: status.state,
lastSuccessAt: status.lastSuccessAt,
lastAttemptAt: status.lastAttemptAt,
lastError: status.lastError,
});
});
+118
View File
@@ -0,0 +1,118 @@
import { prisma } from "@/lib/db";
import { apiHandler } from "@/lib/api-handler";
import { getSession } from "@/lib/auth/session";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import {
exchangeCode,
getPolarCredentials,
registerUser,
} from "@/lib/polar/client";
import {
oauthStateCookieName,
stateMatchesCookie,
verifySignedState,
} from "@/lib/oauth/signed-state";
import { markReconnected } from "@/lib/integrations/status";
import { NextRequest, NextResponse } from "next/server";
/**
* v1.17.0 (F4) — OAuth callback from Polar AccessLink.
*
* Identity + CSRF: the `state` URL param is a stateless HMAC-signed token. The
* callback enforces (1) the param equals the httpOnly cookie byte-for-byte
* (double-submit), and (2) the signature + expiry + `polar` provider binding
* are valid. The `userId` is read from INSIDE the verified token — tamper-
* evident — so identity never comes from the ambient session. When a web
* session cookie is also present we cross-check it matches, so a logged-in user
* can't complete another user's in-flight handshake.
*
* On success: exchange the code (Basic-auth), register the user with the Polar
* app, persist the encrypted access token + member id on `User`, clear any
* prior reauth state, and redirect back to Settings.
*/
const ERR = (reason: string) =>
NextResponse.redirect(
new URL(
`/settings/integrations?polar=error&reason=${reason}`,
process.env.NEXT_PUBLIC_APP_URL!,
),
);
export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "polar.callback" } });
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const state = searchParams.get("state");
const cookieState = request.cookies.get(oauthStateCookieName("polar"))?.value;
// CSRF double-submit: the URL state must equal the cookie (constant-time).
if (!stateMatchesCookie(state, cookieState)) {
annotate({ meta: { reason: "csrf1" } });
return ERR("csrf1");
}
// Verify signature + expiry + provider binding; userId rides inside.
const verified = verifySignedState("polar", state);
if (!verified) {
annotate({ meta: { reason: "state" } });
return ERR("state");
}
const userId = verified.userId;
const evt = getEvent();
if (evt) evt.setAuth({ user_id: userId, auth_method: "session" });
// Optional session cross-check when a web session is present.
const sessionData = await getSession();
if (sessionData && sessionData.user.id !== userId) {
annotate({ meta: { reason: "cross_user" } });
return ERR("cross_user");
}
if (!code) return ERR("nocode");
try {
const creds = getPolarCredentials();
if (!creds) return ERR("nocreds");
const tokens = await exchangeCode(code, creds);
// Guard against an off-spec token body without `x_user_id`: persisting the
// string "undefined" as the member id would break every later data read
// (`/v3/users/undefined/...`) with no clear cause.
if (typeof tokens.x_user_id !== "number" || !Number.isFinite(tokens.x_user_id)) {
return ERR("token");
}
const polarUserId = String(tokens.x_user_id);
// Register the user with the Polar app (idempotent; 409 = already linked).
await registerUser(tokens.access_token, polarUserId);
await prisma.user.update({
where: { id: userId },
data: {
polarAccessTokenEncrypted: encrypt(tokens.access_token),
// Polar issues no refresh token; the column stays null.
polarRefreshTokenEncrypted: null,
polarUserIdEncrypted: encrypt(polarUserId),
},
});
await auditLog("polar.connect", { userId });
await markReconnected(userId, "polar");
const response = NextResponse.redirect(
new URL(
"/settings/integrations?polar=connected",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
response.cookies.delete(oauthStateCookieName("polar"));
return response;
} catch (err) {
getEvent()?.setError(err);
return ERR("token");
}
});
+67
View File
@@ -0,0 +1,67 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAuthorizationUrl, getPolarCredentials } from "@/lib/polar/client";
import {
OAUTH_STATE_TTL_MS,
mintSignedState,
oauthStateCookieName,
} from "@/lib/oauth/signed-state";
import { NextResponse } from "next/server";
/**
* v1.17.0 (F4) — redirect the user to the Polar AccessLink consent screen.
*
* Full OAuth from env: the shared client id/secret come from `POLAR_CLIENT_ID`
* / `POLAR_CLIENT_SECRET` (not per-user BYO). The CSRF defence is a stateless
* signed state (`src/lib/oauth/signed-state.ts`): the same token is the `state`
* URL param AND an httpOnly cookie, and the callback enforces both signature
* validity and a byte-exact cookie match.
*
* Rate-limited per user so a session can't spam the consent redirect.
*/
const CONNECT_RATE_LIMIT = 10;
const CONNECT_WINDOW_MS = 60_000;
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "polar.connect" } });
const rl = await checkRateLimit(
`polar:connect:${user.id}`,
CONNECT_RATE_LIMIT,
CONNECT_WINDOW_MS,
);
if (!rl.allowed) {
annotate({ action: { name: "polar.connect.rate_limited" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?polar=error&reason=rate_limited",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
const creds = getPolarCredentials();
if (!creds) {
return apiError(
"Polar integration is not configured on this server.",
400,
);
}
const state = mintSignedState("polar", user.id);
const url = getAuthorizationUrl(state, creds);
const response = NextResponse.redirect(url);
response.cookies.set(oauthStateCookieName("polar"), state, {
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: Math.floor(OAUTH_STATE_TTL_MS / 1000),
path: "/",
});
return response;
});
+40
View File
@@ -0,0 +1,40 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { markDisconnected } from "@/lib/integrations/status";
/**
* v1.17.0 (F4) — disconnect Polar for the current user.
*
* Clears the stored OAuth token + member id on `User` and parks the integration
* status at `disconnected`. Imported `source = POLAR` measurements are left
* intact (they are the user's historical readings); a reconnect resumes sync.
*/
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "polar.disconnect" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { polarAccessTokenEncrypted: true },
});
if (!dbUser?.polarAccessTokenEncrypted) {
return apiError("No Polar connection", 404);
}
await prisma.user.update({
where: { id: user.id },
data: {
polarAccessTokenEncrypted: null,
polarRefreshTokenEncrypted: null,
polarUserIdEncrypted: null,
},
});
await auditLog("polar.disconnect", { userId: user.id });
await markDisconnected(user.id, "polar");
return apiSuccess({ disconnected: true });
});
@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
}));
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn() } },
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ data, error: null }),
}));
vi.mock("@/lib/polar/client", () => ({
getPolarCredentials: vi.fn(() => ({ clientId: "c", clientSecret: "s" })),
}));
vi.mock("@/lib/integrations/status", () => ({
getIntegrationStatus: vi.fn(async () => ({
integration: "polar",
state: "connected",
lastSuccessAt: "2026-06-10T00:00:00.000Z",
lastAttemptAt: "2026-06-10T00:00:00.000Z",
lastError: null,
consecutiveFailuresByKind: null,
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getPolarCredentials } from "@/lib/polar/client";
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const credsMock = getPolarCredentials as ReturnType<typeof vi.fn>;
type Body = {
connected: boolean;
configured: boolean;
available: boolean;
state?: string;
};
const call = () =>
(GET as unknown as () => Promise<{ data: Body }>)().then((r) => r.data);
describe("GET /api/polar/status", () => {
beforeEach(() => {
vi.clearAllMocks();
credsMock.mockReturnValue({ clientId: "c", clientSecret: "s" });
});
it("reports not-connected but available when env is set and no token stored", async () => {
userFind.mockResolvedValue({ polarAccessTokenEncrypted: null });
const res = await call();
expect(res.connected).toBe(false);
expect(res.available).toBe(true);
});
it("reports available=false when the server has no Polar app configured", async () => {
credsMock.mockReturnValue(null);
userFind.mockResolvedValue(null);
const res = await call();
expect(res.available).toBe(false);
expect(res.connected).toBe(false);
});
it("reports connected + ledger state when a token is stored", async () => {
userFind.mockResolvedValue({ polarAccessTokenEncrypted: "enc" });
const res = await call();
expect(res.connected).toBe(true);
expect(res.configured).toBe(true);
expect(res.state).toBe("connected");
});
it("never returns the token", async () => {
userFind.mockResolvedValue({ polarAccessTokenEncrypted: "enc" });
const res = (await call()) as Record<string, unknown>;
expect(JSON.stringify(res)).not.toContain("enc");
});
});
+44
View File
@@ -0,0 +1,44 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { getPolarCredentials } from "@/lib/polar/client";
import { getIntegrationStatus } from "@/lib/integrations/status";
/**
* v1.17.0 (F4) — Polar connection status for the current user.
*
* `available` reports whether the server has the shared OAuth app configured
* (env client id/secret) so the card can grey out the connect button when the
* operator hasn't set it up. `connected` is whether a token is stored. The
* ledger snapshot (state / lastSuccessAt / lastError) comes off the shared
* `polar` IntegrationKey. The access token + member id are NEVER returned.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "polar.status" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { polarAccessTokenEncrypted: true },
});
const available = !!getPolarCredentials();
const connected = !!dbUser?.polarAccessTokenEncrypted;
if (!connected) {
return apiSuccess({ connected: false, configured: false, available });
}
const status = await getIntegrationStatus(user.id, "polar");
return apiSuccess({
connected: true,
configured: true,
available,
state: status.state,
lastSuccessAt: status.lastSuccessAt,
lastAttemptAt: status.lastAttemptAt,
lastError: status.lastError,
});
});
@@ -131,9 +131,10 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
});
const html = render();
// Exactly one pill per card → 4 pills total (Withings, WHOOP,
// Fitbit / Google Health, Nightscout). The moodLog integration was removed.
expect(count(html, 'data-testid="integration-status-pill"')).toBe(4);
// Exactly one pill per card → 6 pills total (Withings, WHOOP,
// Fitbit / Google Health, Polar, Oura, Nightscout). The moodLog integration
// was removed; Polar + Oura (F4) were added in v1.17.0.
expect(count(html, 'data-testid="integration-status-pill"')).toBe(6);
// The redundant banner from v1.4.15 is gone.
expect(html).not.toContain('data-testid="integration-status-banner"');
// Card-body "letzter Sync" repetition is gone — no
@@ -8,6 +8,8 @@ import { toast } from "sonner";
import { FitbitCard } from "@/components/settings/integrations/fitbit-card";
import { NightscoutCard } from "@/components/settings/integrations/nightscout-card";
import { OuraCard } from "@/components/settings/integrations/oura-card";
import { PolarCard } from "@/components/settings/integrations/polar-card";
import {
pickStatus,
useIntegrationStatuses,
@@ -90,6 +92,58 @@ export function IntegrationsSection() {
}
}, [withingsOauthOutcome, router, queryClient, t]);
// v1.17.0 (F4) — generic OAuth-callback toast for the env-based providers.
// The Polar / Oura callbacks redirect back with `?<provider>=connected` or
// `?<provider>=error&reason=<tag>`; surface the outcome as a toast and scrub
// the one-shot params so a reload doesn't replay it.
const [oauthOutcome] = useState<
{ provider: "polar" | "oura"; kind: "connected" } | { provider: "polar" | "oura"; kind: "error"; reason: string } | null
>(() => {
if (typeof window === "undefined") return null;
const params = new URLSearchParams(window.location.search);
for (const provider of ["polar", "oura"] as const) {
const v = params.get(provider);
if (v === "connected") return { provider, kind: "connected" };
if (v === "error") {
return { provider, kind: "error", reason: params.get("reason") ?? "unknown" };
}
}
return null;
});
useEffect(() => {
if (!oauthOutcome) return;
const { provider } = oauthOutcome;
const url = new URL(window.location.href);
url.searchParams.delete(provider);
url.searchParams.delete("reason");
router.replace(`${url.pathname}${url.search}`, { scroll: false });
const key = provider === "polar" ? queryKeys.polar() : queryKeys.oura();
if (oauthOutcome.kind === "connected") {
toast.success(t(`settings.${provider}OauthConnected`));
queryClient.invalidateQueries({ queryKey: key });
} else {
// Known reason tags resolve to a specific message; anything else falls
// back to the generic copy (matching the Withings handler).
const knownReasons = new Set([
"csrf1",
"state",
"cross_user",
"nocode",
"nocreds",
"token",
"rate_limited",
]);
const reasonKey = knownReasons.has(oauthOutcome.reason)
? `settings.${provider}OauthError.${oauthOutcome.reason}`
: `settings.${provider}OauthError.generic`;
toast.error(t(`settings.${provider}OauthFailed`), {
description: t(reasonKey),
duration: 10_000,
});
}
}, [oauthOutcome, router, queryClient, t]);
const withingsViewModel = pickStatus(integrationStatus, "withings");
const whoopViewModel = pickStatus(integrationStatus, "whoop");
const fitbitViewModel = pickStatus(integrationStatus, "fitbit");
@@ -125,6 +179,8 @@ export function IntegrationsSection() {
<WithingsCard viewModel={withingsViewModel} />
<WhoopCard viewModel={whoopViewModel} />
<FitbitCard viewModel={fitbitViewModel} />
<PolarCard enabled={isAuthenticated} />
<OuraCard enabled={isAuthenticated} />
<NightscoutCard enabled={isAuthenticated} />
</section>
);
@@ -0,0 +1,213 @@
"use client";
// v1.17.0 (F4) — shared OAuth integration card for env-based providers (Polar,
// Oura). Unlike WHOOP / Fitbit (per-user BYO client id/secret), these use a
// single shared OAuth app the operator configures via env, so the card has NO
// credential inputs — just a connect button that redirects to the provider, a
// status pill off the self-contained status read, and a disconnect action.
// Mirrors the Nightscout card's self-contained status pattern.
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Activity, Link2, Unlink } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
IntegrationStatusPill,
type IntegrationPillState,
} from "@/components/settings/integration-status-pill";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
export interface OAuthProviderStatus {
connected: boolean;
configured: boolean;
available: boolean;
state?:
| "connected"
| "error_transient"
| "error_reauth"
| "disconnected"
| "parked";
lastSuccessAt?: string | null;
lastAttemptAt?: string | null;
lastError?: string | null;
}
function pillStateFor(status: OAuthProviderStatus | undefined): IntegrationPillState {
if (!status?.connected) return "disconnected";
switch (status.state) {
case "parked":
return "parked";
case "error_reauth":
return "error";
case "error_transient":
return "warning";
default:
return "connected";
}
}
export interface OAuthProviderCardProps {
/** Lower-case provider key, used for routes + query keys + testids. */
provider: "polar" | "oura";
/** The query-key array shared by the status read + the invalidations. */
statusQueryKey: readonly unknown[];
/** i18n key prefix (e.g. `settings.polar`). */
i18nPrefix: string;
enabled?: boolean;
}
export function OAuthProviderCard({
provider,
statusQueryKey,
i18nPrefix,
enabled = true,
}: OAuthProviderCardProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [msg, setMsg] = useState<string | null>(null);
const { data: status } = useQuery({
queryKey: statusQueryKey,
queryFn: async () => apiGet<OAuthProviderStatus>(`/api/${provider}/status`),
enabled,
refetchOnWindowFocus: true,
});
const disconnect = useMutation({
mutationFn: async () => {
await apiPost(`/api/${provider}/disconnect`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: statusQueryKey });
},
});
const pillState = pillStateFor(status);
const errorMessage =
(pillState === "error" || pillState === "parked") && status?.lastError
? status.lastError
: null;
const serverUnavailable = status && !status.available;
function handleConnect() {
setMsg(null);
void invalidateKeys(queryClient, measurementDependentKeys);
window.location.href = `/api/${provider}/connect`;
}
return (
<div
data-testid={`${provider}-card`}
className="bg-card border-border rounded-xl border p-4 sm:p-6"
>
<SettingsCardHeader
icon={Activity}
title={t(`${i18nPrefix}`)}
description={<p>{t(`${i18nPrefix}Description`)}</p>}
status={
<IntegrationStatusPill
state={pillState}
lastSyncAt={status?.lastSuccessAt ?? null}
/>
}
/>
<hr className="border-border/60 mt-4" />
<div className="mt-4 space-y-4 pl-7">
{errorMessage && (
<p
role="alert"
data-testid={`${provider}-error`}
className="text-destructive text-sm break-words"
>
{errorMessage}
</p>
)}
<p className="text-muted-foreground text-xs">
{t(`${i18nPrefix}Help`)}
</p>
{serverUnavailable && (
<p
className="text-muted-foreground/80 text-xs"
data-testid={`${provider}-unavailable`}
>
{t(`${i18nPrefix}Unavailable`)}
</p>
)}
{status?.connected ? (
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive min-h-11"
data-testid={`${provider}-disconnect`}
>
<Unlink className="h-3.5 w-3.5" />
{t(`${i18nPrefix}Disconnect`)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t(`${i18nPrefix}DisconnectTitle`)}
</AlertDialogTitle>
<AlertDialogDescription>
{t(`${i18nPrefix}DisconnectDescription`)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => disconnect.mutate()}
>
{t(`${i18nPrefix}Disconnect`)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
) : (
<Button
type="button"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={serverUnavailable}
onClick={handleConnect}
data-testid={`${provider}-connect`}
>
<Link2 className="h-3.5 w-3.5" />
{t(`${i18nPrefix}Connect`)}
</Button>
)}
{msg && (
<p role="alert" className="text-destructive text-sm">
{msg}
</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,18 @@
"use client";
// v1.17.0 (F4) — Oura Cloud integration card. Thin wrapper over the shared
// env-based OAuth card.
import { OAuthProviderCard } from "@/components/settings/integrations/oauth-provider-card";
import { queryKeys } from "@/lib/query-keys";
export function OuraCard({ enabled = true }: { enabled?: boolean }) {
return (
<OAuthProviderCard
provider="oura"
statusQueryKey={queryKeys.oura()}
i18nPrefix="settings.oura"
enabled={enabled}
/>
);
}
@@ -0,0 +1,18 @@
"use client";
// v1.17.0 (F4) — Polar AccessLink integration card. Thin wrapper over the
// shared env-based OAuth card.
import { OAuthProviderCard } from "@/components/settings/integrations/oauth-provider-card";
import { queryKeys } from "@/lib/query-keys";
export function PolarCard({ enabled = true }: { enabled?: boolean }) {
return (
<OAuthProviderCard
provider="polar"
statusQueryKey={queryKeys.polar()}
i18nPrefix="settings.polar"
enabled={enabled}
/>
);
}
@@ -81,6 +81,38 @@ describe("resolveCanonicalRecovery", () => {
expect(resolved[0].value).toBe(82);
});
it("ranks native OURA + POLAR readiness above the COMPUTED proxy", () => {
// Same night (wake Jun 02): an Oura readiness and a Polar Nightly Recharge
// row both stamped on the wake day, plus the COMPUTED proxy filed under the
// day-that-ended (Jun 01). Oura must win over Polar, and both must win over
// COMPUTED — the v1.17.0 (F4) ladder WHOOP > OURA > POLAR > COMPUTED.
const oura = [
row("2026-06-02T00:00:00Z", 84, "OURA"),
row("2026-06-01T12:00:00Z", 50, "COMPUTED"),
];
const resolvedOura = resolveCanonicalRecovery(oura);
expect(resolvedOura).toHaveLength(1);
expect(resolvedOura[0].source).toBe("OURA");
expect(resolvedOura[0].value).toBe(84);
const ouraVsPolar = [
row("2026-06-02T00:00:00Z", 84, "OURA"),
row("2026-06-02T00:00:00Z", 60, "POLAR"),
row("2026-06-01T12:00:00Z", 50, "COMPUTED"),
];
const resolvedBoth = resolveCanonicalRecovery(ouraVsPolar);
expect(resolvedBoth).toHaveLength(1);
expect(resolvedBoth[0].source).toBe("OURA");
const polarOnly = [
row("2026-06-02T00:00:00Z", 60, "POLAR"),
row("2026-06-01T12:00:00Z", 50, "COMPUTED"),
];
const resolvedPolar = resolveCanonicalRecovery(polarOnly);
expect(resolvedPolar).toHaveLength(1);
expect(resolvedPolar[0].source).toBe("POLAR");
});
it("returns an empty list for no rows", () => {
expect(resolveCanonicalRecovery([])).toEqual([]);
});
+10 -4
View File
@@ -52,13 +52,19 @@ export interface RecoveryRow {
}
/**
* The per-source preference for `RECOVERY_SCORE`: a native WHOOP row outranks
* the COMPUTED proxy for the same day. Lower number = higher authority. A
* source not listed (there is none today) falls back below both.
* The per-source preference for `RECOVERY_SCORE`: a native device row outranks
* the COMPUTED proxy for the same day. Lower number = higher authority. The
* ladder mirrors `DEFAULT_SOURCE_PRIORITY.recovery`
* (`WHOOP > OURA > POLAR > COMPUTED`): WHOOP's all-night strap leads, then the
* Oura ring readiness and the Polar Nightly Recharge band, then the computed
* proxy as the fallback for users without a wearable. A source not listed falls
* below all of them.
*/
const RECOVERY_SOURCE_RANK: Partial<Record<MeasurementSource, number>> = {
WHOOP: 0,
COMPUTED: 1,
OURA: 1,
POLAR: 2,
COMPUTED: 3,
};
function rankOf(source: MeasurementSource): number {
+32
View File
@@ -263,6 +263,8 @@ import {
NightscoutSyncPayload,
handleNightscoutSync,
} from "./reminder/nightscout-sync";
import { PolarSyncPayload, handlePolarSync } from "./reminder/polar-sync";
import { OuraSyncPayload, handleOuraSync } from "./reminder/oura-sync";
import {
handleMedicationInventoryExpire,
handleIntakeAutoSkip,
@@ -401,6 +403,16 @@ const FITBIT_OAUTH_STATE_CLEANUP_CRON = "24 3 * * *";
const NIGHTSCOUT_SYNC_QUEUE = "nightscout-sync";
const NIGHTSCOUT_SYNC_CRON = "11 * * * *"; // every hour at :11
// v1.17.0 (F4) — Polar + Oura OAuth poll sync. Poll-only (one hourly tick per
// provider re-walks every connected user). :13 / :15 stagger off the other
// hourly sync ticks (WHOOP :05, Fitbit :08, Nightscout :11) so the polls don't
// pile up on one boss poll. The queues MUST be registered in `allQueues` below
// or pg-boss never provisions them and the schedule silently no-ops (the
// v1.4.37 dead-queue class).
const POLAR_SYNC_QUEUE = "polar-sync";
const POLAR_SYNC_CRON = "13 * * * *"; // every hour at :13
const OURA_SYNC_QUEUE = "oura-sync";
const OURA_SYNC_CRON = "15 * * * *"; // every hour at :15
// v1.15.19 — daily duplicate dose-slot dedup discovery tick. The boot-time
// pass only ran on worker restart, so a cross-source duplicate slot created
// between deploys (a pending REMINDER row plus a standalone API/WEB row on
@@ -622,6 +634,10 @@ export async function startReminderWorker() {
// MUST be registered here or pg-boss never provisions it and the schedule
// below silently no-ops (the v1.4.37 dead-queue class).
NIGHTSCOUT_SYNC_QUEUE,
// v1.17.0 (F4) — Polar + Oura OAuth poll sync. Registered here or pg-boss
// never provisions them and the hourly schedule below silently no-ops.
POLAR_SYNC_QUEUE,
OURA_SYNC_QUEUE,
OFFHOST_BACKUP_QUEUE,
RESTORE_DRILL_QUEUE,
HOST_METRIC_QUEUE,
@@ -845,6 +861,9 @@ export async function startReminderWorker() {
// v1.17.0 — hourly Nightscout CGM poll (:11, staggered off the other sync
// ticks).
[NIGHTSCOUT_SYNC_QUEUE, NIGHTSCOUT_SYNC_CRON],
// v1.17.0 (F4) — hourly Polar (:13) + Oura (:15) OAuth polls.
[POLAR_SYNC_QUEUE, POLAR_SYNC_CRON],
[OURA_SYNC_QUEUE, OURA_SYNC_CRON],
[OFFHOST_BACKUP_QUEUE, OFFHOST_BACKUP_CRON],
[RESTORE_DRILL_QUEUE, RESTORE_DRILL_CRON],
[HOST_METRIC_QUEUE, HOST_METRIC_CRON],
@@ -1045,6 +1064,19 @@ export async function startReminderWorker() {
{ localConcurrency: 1 },
handleNightscoutSync,
);
// v1.17.0 (F4) — Polar + Oura OAuth poll-cohort sync. The hourly cron tick
// (no `userId`) re-walks every connected user; one user's revoked grant is
// warned, not fatal.
await boss.work<PolarSyncPayload>(
POLAR_SYNC_QUEUE,
{ localConcurrency: 1 },
handlePolarSync,
);
await boss.work<OuraSyncPayload>(
OURA_SYNC_QUEUE,
{ localConcurrency: 1 },
handleOuraSync,
);
await boss.work<GeneralStatusPayload>(
GENERAL_STATUS_QUEUE,
{ localConcurrency: 1 },
+62
View File
@@ -0,0 +1,62 @@
/**
* v1.17.0 (F4) — Oura poll-cohort sync handler.
*
* Poll-only: one hourly cron tick (no `userId`) iterates every user with a
* stored Oura token and re-syncs each via `syncUserOura`. One user's revoked
* grant / outage is warned and the pass continues. The queue name, cron
* schedule, and boss.work registration live in reminder-worker.ts.
*/
import { type Job } from "pg-boss";
import { recordError } from "@/lib/jobs/worker-status";
import { withBackgroundEvent } from "@/lib/logging/background";
import { syncUserOura } from "@/lib/oura/sync";
import { getWorkerPrisma } from "./shared";
export interface OuraSyncPayload {
userId?: string;
}
export async function handleOuraSync(jobs: Job<OuraSyncPayload>[]) {
await withBackgroundEvent("job.oura_sync", async (evt) => {
const prisma = getWorkerPrisma();
try {
const targets: Array<{ userId: string }> = [];
for (const job of jobs) {
if (job.data?.userId) targets.push({ userId: job.data.userId });
}
if (targets.length === 0) {
const users = await prisma.user.findMany({
where: { ouraAccessTokenEncrypted: { not: null } },
select: { id: true },
});
targets.push(...users.map((u) => ({ userId: u.id })));
}
if (targets.length === 0) return;
let usersSynced = 0;
let measurementsImported = 0;
for (const { userId } of targets) {
try {
measurementsImported += await syncUserOura(userId);
usersSynced++;
} catch (err) {
evt.addWarning(`job.oura_sync failed for user ${userId}: ${err}`);
}
}
evt.setBackground({
task_name: "job.oura_sync",
result: {
users_synced: usersSynced,
total: targets.length,
measurements_imported: measurementsImported,
},
});
} catch (err) {
evt.setError(err);
recordError();
throw err;
}
});
}
+66
View File
@@ -0,0 +1,66 @@
/**
* v1.17.0 (F4) — Polar poll-cohort sync handler.
*
* Poll-only: Polar AccessLink has a webhook, but HealthLog runs the simpler
* poll model (matching Nightscout). One hourly cron tick carries no `userId`
* and the handler iterates every user with a stored Polar token, re-syncing
* each via `syncUserPolar`. One user's revoked grant / outage is warned and the
* pass continues.
*
* The queue name, cron schedule, and boss.work registration live in
* reminder-worker.ts.
*/
import { type Job } from "pg-boss";
import { recordError } from "@/lib/jobs/worker-status";
import { withBackgroundEvent } from "@/lib/logging/background";
import { syncUserPolar } from "@/lib/polar/sync";
import { getWorkerPrisma } from "./shared";
export interface PolarSyncPayload {
userId?: string;
}
export async function handlePolarSync(jobs: Job<PolarSyncPayload>[]) {
await withBackgroundEvent("job.polar_sync", async (evt) => {
const prisma = getWorkerPrisma();
try {
const targets: Array<{ userId: string }> = [];
for (const job of jobs) {
if (job.data?.userId) targets.push({ userId: job.data.userId });
}
if (targets.length === 0) {
const users = await prisma.user.findMany({
where: { polarAccessTokenEncrypted: { not: null } },
select: { id: true },
});
targets.push(...users.map((u) => ({ userId: u.id })));
}
if (targets.length === 0) return;
let usersSynced = 0;
let measurementsImported = 0;
for (const { userId } of targets) {
try {
measurementsImported += await syncUserPolar(userId);
usersSynced++;
} catch (err) {
evt.addWarning(`job.polar_sync failed for user ${userId}: ${err}`);
}
}
evt.setBackground({
task_name: "job.polar_sync",
result: {
users_synced: usersSynced,
total: targets.length,
measurements_imported: measurementsImported,
},
});
} catch (err) {
evt.setError(err);
recordError();
throw err;
}
});
}
@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
OAUTH_STATE_TTL_MS,
mintSignedState,
oauthStateCookieName,
stateMatchesCookie,
verifySignedState,
} from "../signed-state";
const KEY = "x".repeat(48);
describe("OAuth signed state (Polar / Oura)", () => {
let prevKey: string | undefined;
beforeEach(() => {
prevKey = process.env.API_TOKEN_HMAC_KEY;
process.env.API_TOKEN_HMAC_KEY = KEY;
});
afterEach(() => {
if (prevKey === undefined) delete process.env.API_TOKEN_HMAC_KEY;
else process.env.API_TOKEN_HMAC_KEY = prevKey;
});
it("pins the cookie name per provider", () => {
expect(oauthStateCookieName("polar")).toBe("polar_oauth_state");
expect(oauthStateCookieName("oura")).toBe("oura_oauth_state");
});
it("round-trips a minted token back to the userId + provider", () => {
const now = 1_000_000;
const token = mintSignedState("polar", "user-abc", now);
const verified = verifySignedState("polar", token, now + 1000);
expect(verified).toEqual({
provider: "polar",
userId: "user-abc",
expiry: now + OAUTH_STATE_TTL_MS,
});
});
it("mints unique tokens for the same user (nonce entropy)", () => {
const a = mintSignedState("oura", "u1");
const b = mintSignedState("oura", "u1");
expect(a).not.toBe(b);
});
it("rejects a token whose provider does not match", () => {
const token = mintSignedState("polar", "u1");
expect(verifySignedState("oura", token)).toBeNull();
});
it("rejects a tampered userId (signature mismatch)", () => {
const now = 5_000;
const token = mintSignedState("polar", "victim", now);
const parts = token.split(".");
parts[1] = "attacker";
const forged = parts.join(".");
expect(verifySignedState("polar", forged, now + 1)).toBeNull();
});
it("rejects a tampered signature tag", () => {
const token = mintSignedState("oura", "u1");
const forged = token.slice(0, -3) + "AAA";
expect(verifySignedState("oura", forged)).toBeNull();
});
it("rejects an expired token even with a valid signature", () => {
const now = 10_000;
const token = mintSignedState("polar", "u1", now);
expect(verifySignedState("polar", token, now + OAUTH_STATE_TTL_MS + 1)).toBeNull();
});
it("rejects malformed shapes and empty input", () => {
expect(verifySignedState("polar", null)).toBeNull();
expect(verifySignedState("polar", "")).toBeNull();
expect(verifySignedState("polar", "a.b.c")).toBeNull();
expect(verifySignedState("polar", "polar.u1.notanumber.nonce.tag")).toBeNull();
});
it("a token minted under a different key fails verification", () => {
const token = mintSignedState("polar", "u1");
process.env.API_TOKEN_HMAC_KEY = "y".repeat(48);
expect(verifySignedState("polar", token)).toBeNull();
});
it("double-submit compare is byte-exact", () => {
const token = mintSignedState("oura", "u1");
expect(stateMatchesCookie(token, token)).toBe(true);
expect(stateMatchesCookie(token, token + "x")).toBe(false);
expect(stateMatchesCookie(token, null)).toBe(false);
expect(stateMatchesCookie(null, token)).toBe(false);
});
});
+146
View File
@@ -0,0 +1,146 @@
/**
* v1.17.0 (F4) — stateless, HMAC-signed OAuth `state` for the Polar + Oura
* authorization-code flows.
*
* Unlike WHOOP / Withings (which carry the in-flight `(nonce → userId)` mapping
* in a short-lived DB ledger row), the F4 providers store their tokens directly
* on `User` and the merged schema ships NO `*OAuthState` table. The CSRF
* defence therefore lives in the signed state itself: the `state` param is
* `<provider>.<userId>.<expiryMs>.<nonce>` plus an HMAC-SHA256 tag over that
* payload, keyed by `API_TOKEN_HMAC_KEY`. The same opaque token is also set as
* an httpOnly cookie at connect time, so the callback enforces BOTH:
*
* 1. signature + expiry + provider binding are valid (the token wasn't forged
* or replayed past its TTL), AND
* 2. the `state` URL param equals the cookie value byte-for-byte (double-
* submit — an attacker who can plant a `state` query param cannot also set
* our httpOnly cookie).
*
* The `userId` rides INSIDE the signed payload (tamper-evident), so the
* callback resolves identity from the verified token rather than the ambient
* session — the same property the WHOOP ledger row gives, without a table.
*
* Security notes:
* - The payload is signed, not encrypted. A `userId` (an opaque cuid) is not
* a secret, and it never leaves the user's own browser round-trip. The
* signature is what matters: a third party cannot mint a state for another
* user without the server HMAC key.
* - `verifySignedState` is fully constant-time on the tag compare and rejects
* a provider / expiry / shape mismatch BEFORE the (already constant-time)
* compare, so a malformed probe can't leak timing about the key.
*/
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
/** Providers that share this signed-state machinery. */
export type OAuthStateProvider = "polar" | "oura";
/** 10-minute TTL — long enough to approve a consent screen, short enough that
* an abandoned handshake's token is useless within the hour. Mirrors the
* WHOOP / Withings ledger-row TTL. */
export const OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
/** httpOnly cookie name carrying the in-flight signed state, per provider. */
export function oauthStateCookieName(provider: OAuthStateProvider): string {
return `${provider}_oauth_state`;
}
function getKey(): string {
const key = process.env.API_TOKEN_HMAC_KEY;
if (!key || key.length < 32) {
throw new Error(
"API_TOKEN_HMAC_KEY must be set (>=32 chars) to sign OAuth state",
);
}
return key;
}
/** Field separator — `.` is base64url-safe-adjacent and absent from every
* field (provider is a fixed literal, userId is a cuid, the rest are digits /
* base64url), so the split is unambiguous. */
const SEP = ".";
function sign(payload: string): string {
return createHmac("sha256", getKey()).update(payload).digest("base64url");
}
/**
* Mint a signed state token for a user starting `provider`'s OAuth flow. The
* returned string is used BOTH as the OAuth `state` URL param and as the
* httpOnly cookie value.
*/
export function mintSignedState(
provider: OAuthStateProvider,
userId: string,
now: number = Date.now(),
): string {
const expiry = now + OAUTH_STATE_TTL_MS;
const nonce = randomBytes(16).toString("base64url");
const payload = [provider, userId, String(expiry), nonce].join(SEP);
const tag = sign(payload);
return `${payload}${SEP}${tag}`;
}
export interface VerifiedState {
provider: OAuthStateProvider;
userId: string;
expiry: number;
}
/**
* Verify a signed state token. Returns the decoded `{ provider, userId, expiry }`
* only when the signature is valid, the provider matches, and the TTL has not
* elapsed. Returns null on any failure — a forged tag, a wrong provider, an
* expired token, or a malformed shape. Never throws on bad input (only on a
* missing server key, which is an operator misconfiguration).
*/
export function verifySignedState(
provider: OAuthStateProvider,
token: string | null | undefined,
now: number = Date.now(),
): VerifiedState | null {
if (!token || token.length > 512) return null;
const parts = token.split(SEP);
if (parts.length !== 5) return null;
const [gotProvider, userId, expiryStr, nonce, tag] = parts as [
string,
string,
string,
string,
string,
];
if (gotProvider !== provider) return null;
if (!userId || !nonce || !tag) return null;
const expiry = Number.parseInt(expiryStr, 10);
if (!Number.isFinite(expiry) || String(expiry) !== expiryStr) return null;
const payload = [gotProvider, userId, expiryStr, nonce].join(SEP);
const expected = sign(payload);
// Constant-time compare; length guard first so timingSafeEqual never throws
// on mismatched buffer sizes.
if (
tag.length !== expected.length ||
!timingSafeEqual(Buffer.from(tag), Buffer.from(expected))
) {
return null;
}
// Signature is valid; now enforce the TTL.
if (expiry <= now) return null;
return { provider, userId, expiry };
}
/**
* Constant-time double-submit check: the `state` URL param must equal the
* cookie value byte-for-byte. Both are the same minted token, so an attacker
* who can inject a `state` query param cannot also set our httpOnly cookie.
*/
export function stateMatchesCookie(
urlState: string | null | undefined,
cookieState: string | null | undefined,
): boolean {
if (!urlState || !cookieState) return false;
if (urlState.length !== cookieState.length) return false;
return timingSafeEqual(Buffer.from(urlState), Buffer.from(cookieState));
}
+177
View File
@@ -0,0 +1,177 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
OURA_OAUTH_SCOPE,
exchangeCode,
fetchReadiness,
getAuthorizationUrl,
getOuraCredentials,
mapDailyActivity,
mapReadiness,
mapSleep,
refreshAccessToken,
type OuraDailyActivity,
type OuraReadiness,
type OuraSleep,
} from "../client";
import { OuraApiError } from "../response-classifier";
const CREDS = { clientId: "cid", clientSecret: "csecret" };
function installFetchMock(pages: Array<{ status: number; body?: unknown }>) {
let i = 0;
const fetchMock = vi.fn(async (...args: [string, RequestInit?]) => {
void args;
const page = pages[Math.min(i, pages.length - 1)]!;
i += 1;
return { status: page.status, json: async () => page.body ?? null };
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
beforeEach(() => {
process.env.OURA_CLIENT_ID = "env-cid";
process.env.OURA_CLIENT_SECRET = "env-secret";
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
delete process.env.OURA_REDIRECT_URI;
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("getOuraCredentials", () => {
it("reads env", () => {
expect(getOuraCredentials()).toEqual({
clientId: "env-cid",
clientSecret: "env-secret",
});
});
it("null when unconfigured", () => {
delete process.env.OURA_CLIENT_SECRET;
expect(getOuraCredentials()).toBeNull();
});
});
describe("getAuthorizationUrl", () => {
it("builds the Oura authorize URL", () => {
const url = getAuthorizationUrl("signed", CREDS);
expect(url).toContain("cloud.ouraring.com/oauth/authorize");
expect(url).toContain("scope=daily+personal");
expect(url).toContain("state=signed");
});
});
describe("exchangeCode / refreshAccessToken", () => {
it("exchanges a code for an access + refresh token", async () => {
installFetchMock([
{
status: 200,
body: { access_token: "a", refresh_token: "r", expires_in: 86400 },
},
]);
const t = await exchangeCode("code", CREDS);
expect(t.access_token).toBe("a");
expect(t.refresh_token).toBe("r");
});
it("rotates tokens on refresh", async () => {
const fetchMock = installFetchMock([
{
status: 200,
body: { access_token: "a2", refresh_token: "r2", expires_in: 86400 },
},
]);
const t = await refreshAccessToken("r1", CREDS);
expect(t.refresh_token).toBe("r2");
const [, init] = fetchMock.mock.calls[0]!;
expect(init!.body).toContain("grant_type=refresh_token");
});
it("throws OuraApiError on a 400 invalid_grant", async () => {
installFetchMock([{ status: 400, body: { error: "invalid_grant" } }]);
await expect(refreshAccessToken("dead", CREDS)).rejects.toBeInstanceOf(
OuraApiError,
);
});
});
describe("fetchReadiness pagination", () => {
it("walks next_token across pages", async () => {
installFetchMock([
{
status: 200,
body: { data: [{ id: "1", day: "2026-06-09", score: 70 }], next_token: "abc" },
},
{
status: 200,
body: { data: [{ id: "2", day: "2026-06-10", score: 80 }], next_token: null },
},
]);
const r = await fetchReadiness("tok", {
startDate: "2026-06-01",
endDate: "2026-06-10",
});
expect(r).toHaveLength(2);
});
});
describe("mapReadiness", () => {
it("maps score -> RECOVERY_SCORE", () => {
const r: OuraReadiness = { id: "1", day: "2026-06-10", score: 84 };
const mapped = mapReadiness(r);
expect(mapped).toHaveLength(1);
expect(mapped[0]).toMatchObject({ type: "RECOVERY_SCORE", value: 84, fieldTag: "recovery" });
});
it("skips a record with no score", () => {
expect(mapReadiness({ id: "1", day: "2026-06-10", score: null })).toEqual([]);
});
});
describe("mapSleep", () => {
it("maps stages s->min, efficiency, hrv, rhr, breath", () => {
const s: OuraSleep = {
id: "1",
day: "2026-06-10",
light_sleep_duration: 3600,
deep_sleep_duration: 1800,
rem_sleep_duration: 5400,
awake_time: 600,
efficiency: 91,
average_hrv: 48,
lowest_heart_rate: 49,
average_breath: 14.5,
};
const mapped = mapSleep(s);
expect(mapped.find((m) => m.sleepStage === "CORE")?.value).toBe(60);
expect(mapped.find((m) => m.sleepStage === "AWAKE")?.value).toBe(10);
expect(mapped.find((m) => m.type === "SLEEP_EFFICIENCY")?.value).toBe(91);
expect(mapped.find((m) => m.type === "HRV_RMSSD")?.value).toBe(48);
expect(mapped.find((m) => m.type === "RESTING_HEART_RATE")?.value).toBe(49);
expect(mapped.find((m) => m.type === "RESPIRATORY_RATE")?.value).toBe(14.5);
});
});
describe("mapDailyActivity", () => {
it("maps steps and active energy", () => {
const a: OuraDailyActivity = {
id: "1",
day: "2026-06-10",
steps: 9001,
active_calories: 412,
};
const mapped = mapDailyActivity(a);
expect(mapped.find((m) => m.type === "ACTIVITY_STEPS")?.value).toBe(9001);
expect(mapped.find((m) => m.type === "ACTIVE_ENERGY_BURNED")?.value).toBe(
412,
);
});
});
describe("OURA_OAUTH_SCOPE", () => {
it("requests daily + personal", () => {
expect(OURA_OAUTH_SCOPE).toBe("daily personal");
});
});
+172
View File
@@ -0,0 +1,172 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
getConnMock,
storeTokensMock,
getCredsMock,
fetchReadinessMock,
fetchSleepMock,
fetchActivityMock,
refreshMock,
upsertMock,
recordSuccessMock,
recordFailureMock,
recomputeMock,
invalidateMock,
} = vi.hoisted(() => ({
getConnMock: vi.fn(),
storeTokensMock: vi.fn(),
getCredsMock: vi.fn(),
fetchReadinessMock: vi.fn(),
fetchSleepMock: vi.fn(),
fetchActivityMock: vi.fn(),
refreshMock: vi.fn(),
upsertMock: vi.fn(),
recordSuccessMock: vi.fn(),
recordFailureMock: vi.fn(),
recomputeMock: vi.fn(),
invalidateMock: vi.fn(),
}));
vi.mock("../credentials", () => ({
getOuraConnection: getConnMock,
storeOuraTokens: storeTokensMock,
}));
vi.mock("@/lib/db", () => ({
prisma: { measurement: { upsert: upsertMock } },
}));
vi.mock("@/lib/integrations/status", () => ({
recordSyncSuccess: recordSuccessMock,
recordSyncFailure: recordFailureMock,
}));
vi.mock("@/lib/rollups/measurement-rollups", () => ({
collapseToTypeDayKeys: (rows: Array<{ type: string; measuredAt: Date }>) =>
rows.map((r) => ({ type: r.type, measuredAt: r.measuredAt })),
recomputeBucketsForMeasurement: recomputeMock,
}));
vi.mock("@/lib/insights/comprehensive-generate", () => ({
invalidateStatusInsightsForTypes: invalidateMock,
}));
vi.mock("../client", async (importOriginal) => {
const actual = await importOriginal<typeof import("../client")>();
return {
...actual,
getOuraCredentials: getCredsMock,
fetchReadiness: fetchReadinessMock,
fetchSleep: fetchSleepMock,
fetchDailyActivity: fetchActivityMock,
refreshAccessToken: refreshMock,
};
});
import { syncUserOura } from "../sync";
import { OuraApiError } from "../response-classifier";
const CONN = { accessToken: "acc", refreshToken: "ref" };
beforeEach(() => {
getConnMock.mockReset();
storeTokensMock.mockReset().mockResolvedValue(undefined);
getCredsMock.mockReset().mockReturnValue({ clientId: "c", clientSecret: "s" });
fetchReadinessMock.mockReset().mockResolvedValue([]);
fetchSleepMock.mockReset().mockResolvedValue([]);
fetchActivityMock.mockReset().mockResolvedValue([]);
refreshMock.mockReset();
upsertMock.mockReset().mockResolvedValue({});
recordSuccessMock.mockReset().mockResolvedValue(undefined);
recordFailureMock.mockReset().mockResolvedValue(undefined);
recomputeMock.mockReset().mockResolvedValue(undefined);
invalidateMock.mockReset().mockResolvedValue(undefined);
});
afterEach(() => vi.clearAllMocks());
function err401() {
return new OuraApiError({
verb: "fetchReadiness",
classification: "reauth_required",
httpStatus: 401,
reason: "http_401",
});
}
describe("syncUserOura", () => {
it("no-ops cleanly when unconnected", async () => {
getConnMock.mockResolvedValue(null);
expect(await syncUserOura("u1")).toBe(0);
expect(recordSuccessMock).not.toHaveBeenCalled();
});
it("writes a readiness RECOVERY_SCORE with source OURA", async () => {
getConnMock.mockResolvedValue(CONN);
fetchReadinessMock.mockResolvedValue([
{ id: "x", day: "2026-06-10", score: 84 },
]);
const imported = await syncUserOura("u1");
expect(imported).toBe(1);
const arg = upsertMock.mock.calls[0]![0];
expect(arg.where.userId_type_source_externalId).toMatchObject({
type: "RECOVERY_SCORE",
source: "OURA",
externalId: "readiness:2026-06-10:recovery",
});
expect(arg.create.value).toBe(84);
expect(recordSuccessMock).toHaveBeenCalledWith("u1", "oura");
});
it("refreshes the token on a 401 and retries once", async () => {
getConnMock.mockResolvedValue(CONN);
// First call (with old token) 401s; after refresh the retry succeeds.
fetchReadinessMock
.mockRejectedValueOnce(err401())
.mockResolvedValueOnce([{ id: "x", day: "2026-06-10", score: 70 }]);
refreshMock.mockResolvedValue({
access_token: "newAcc",
refresh_token: "newRef",
expires_in: 86400,
});
const imported = await syncUserOura("u1");
expect(refreshMock).toHaveBeenCalledWith("ref", expect.anything());
expect(storeTokensMock).toHaveBeenCalledWith("u1", "newAcc", "newRef");
expect(imported).toBe(1);
expect(recordSuccessMock).toHaveBeenCalledWith("u1", "oura");
});
it("records reauth_required when the refresh itself fails", async () => {
getConnMock.mockResolvedValue(CONN);
fetchReadinessMock.mockRejectedValue(err401());
refreshMock.mockRejectedValue(
new OuraApiError({
verb: "refreshAccessToken",
classification: "reauth_required",
httpStatus: 400,
reason: "http_400",
upstreamError: "invalid_grant",
}),
);
await expect(syncUserOura("u1")).rejects.toBeInstanceOf(OuraApiError);
expect(recordFailureMock).toHaveBeenCalledWith(
expect.objectContaining({ integration: "oura", kind: "reauth_required" }),
);
expect(recordSuccessMock).not.toHaveBeenCalled();
});
it("does NOT refresh on a 403 (not an expiry case)", async () => {
getConnMock.mockResolvedValue(CONN);
fetchReadinessMock.mockRejectedValue(
new OuraApiError({
verb: "fetchReadiness",
classification: "reauth_required",
httpStatus: 403,
reason: "http_403",
}),
);
await expect(syncUserOura("u1")).rejects.toBeInstanceOf(OuraApiError);
expect(refreshMock).not.toHaveBeenCalled();
});
});
+432
View File
@@ -0,0 +1,432 @@
/**
* v1.17.0 (F4) — Oura Cloud API v2 client for OAuth + data fetching.
* Docs: https://cloud.ouraring.com/v2/docs (re-verify at build).
*
* Mirrors the WHOOP / Polar client structure: hand-rolled fetch over
* `safeFetch` (no SDK), an OAuth handshake (`getAuthorizationUrl` /
* `exchangeCode` / `refreshAccessToken`), typed v2 collection fetchers with
* `next_token` pagination, and a single source-of-truth field→Measurement
* mapping.
*
* Oura specifics: standard authorization-code OAuth WITH refresh tokens (the
* sync layer refreshes on 401, persisting BOTH rotated tokens). The token
* endpoint takes client_id/secret in the body. Collections are read by
* `start_date` / `end_date` (YYYY-MM-DD) and page via `next_token`.
*/
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import { OuraApiError, classifyOuraResponse } from "./response-classifier";
const OURA_API_BASE = "https://api.ouraring.com";
const OURA_OAUTH_AUTH_URL = "https://cloud.ouraring.com/oauth/authorize";
const OURA_OAUTH_TOKEN_URL = "https://api.ouraring.com/oauth/token";
export interface OuraCredentials {
clientId: string;
clientSecret: string;
}
/** Env-configured shared OAuth credentials (F4 = full OAuth from env). */
export function getOuraCredentials(): OuraCredentials | null {
const clientId = process.env.OURA_CLIENT_ID;
const clientSecret = process.env.OURA_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
return { clientId, clientSecret };
}
export function getOuraRedirectUri(): string {
return (
process.env.OURA_REDIRECT_URI ||
`${process.env.NEXT_PUBLIC_APP_URL}/api/oura/callback`
);
}
/** `daily` covers sleep / readiness / activity; `personal` is profile info. */
export const OURA_OAUTH_SCOPE = "daily personal" as const;
export function getAuthorizationUrl(
state: string,
creds: OuraCredentials,
): string {
const params = new URLSearchParams({
response_type: "code",
client_id: creds.clientId,
redirect_uri: getOuraRedirectUri(),
scope: OURA_OAUTH_SCOPE,
state,
});
return `${OURA_OAUTH_AUTH_URL}?${params}`;
}
export interface OuraTokenResponse {
access_token: string;
refresh_token: string;
expires_in: number;
token_type?: string;
scope?: string;
}
async function postToken(
params: URLSearchParams,
verb: string,
): Promise<OuraTokenResponse> {
const start = performance.now();
const res = await safeFetch(OURA_OAUTH_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: params.toString(),
});
const json = await res.json().catch(() => null);
const verdict = classifyOuraResponse(res.status);
getEvent()?.addExternalCall({
service: "oura",
method: verb,
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw new OuraApiError({
verb,
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
upstreamError:
typeof json?.error === "string" ? json.error : undefined,
});
}
return json as OuraTokenResponse;
}
export async function exchangeCode(
code: string,
creds: OuraCredentials,
): Promise<OuraTokenResponse> {
return postToken(
new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: getOuraRedirectUri(),
client_id: creds.clientId,
client_secret: creds.clientSecret,
}),
"exchangeCode",
);
}
export async function refreshAccessToken(
refreshToken: string,
creds: OuraCredentials,
): Promise<OuraTokenResponse> {
return postToken(
new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: creds.clientId,
client_secret: creds.clientSecret,
}),
"refreshAccessToken",
);
}
// ─── Collection reads (v2) ─────────────────────────────────────
/** v2 paginated envelope: `{ data, next_token }`. */
export interface OuraCollection<T> {
data: T[];
next_token?: string | null;
}
export interface OuraSleep {
id: string;
day: string;
bedtime_end?: string;
total_sleep_duration?: number | null;
time_in_bed?: number | null;
efficiency?: number | null;
rem_sleep_duration?: number | null;
deep_sleep_duration?: number | null;
light_sleep_duration?: number | null;
awake_time?: number | null;
average_heart_rate?: number | null;
lowest_heart_rate?: number | null;
average_hrv?: number | null;
average_breath?: number | null;
}
export interface OuraReadiness {
id: string;
day: string;
timestamp?: string;
score?: number | null;
}
export interface OuraDailyActivity {
id: string;
day: string;
timestamp?: string;
steps?: number | null;
active_calories?: number | null;
total_calories?: number | null;
}
interface DateRangeQuery {
startDate: string; // YYYY-MM-DD
endDate: string; // YYYY-MM-DD
maxPages?: number;
}
async function fetchCollection<T>(
path: string,
accessToken: string,
verb: string,
query: DateRangeQuery,
): Promise<T[]> {
const records: T[] = [];
let nextToken: string | null | undefined;
let pageCount = 0;
const maxPages = query.maxPages ?? 100;
do {
const params = new URLSearchParams({
start_date: query.startDate,
end_date: query.endDate,
});
if (nextToken) params.set("next_token", nextToken);
const pageStart = performance.now();
const res = await safeFetch(`${OURA_API_BASE}${path}?${params}`, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
});
const json = (await res.json().catch(() => null)) as
| OuraCollection<T>
| null;
const verdict = classifyOuraResponse(res.status);
getEvent()?.addExternalCall({
service: "oura",
method: `${verb}(page=${pageCount})`,
duration_ms: Math.round(performance.now() - pageStart),
status: res.status,
error:
verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw new OuraApiError({
verb,
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
});
}
for (const r of json?.data ?? []) records.push(r);
nextToken = json?.next_token ?? null;
pageCount += 1;
} while (nextToken && pageCount < maxPages);
return records;
}
export function fetchSleep(
accessToken: string,
query: DateRangeQuery,
): Promise<OuraSleep[]> {
return fetchCollection<OuraSleep>(
"/v2/usercollection/sleep",
accessToken,
"fetchSleep",
query,
);
}
export function fetchReadiness(
accessToken: string,
query: DateRangeQuery,
): Promise<OuraReadiness[]> {
return fetchCollection<OuraReadiness>(
"/v2/usercollection/daily_readiness",
accessToken,
"fetchReadiness",
query,
);
}
export function fetchDailyActivity(
accessToken: string,
query: DateRangeQuery,
): Promise<OuraDailyActivity[]> {
return fetchCollection<OuraDailyActivity>(
"/v2/usercollection/daily_activity",
accessToken,
"fetchDailyActivity",
query,
);
}
// ─── Field → Measurement mapping ───────────────────────────────
const SEC_TO_MIN = 1 / 60;
function round2(n: number): number {
return parseFloat(n.toFixed(2));
}
export interface MappedMeasurement {
type: string;
value: number;
unit: string;
measuredAt: Date;
fieldTag: string;
sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE";
}
function dayAnchor(day: string, bedtimeEnd?: string): Date | null {
// Prefer the precise wake instant when present; else anchor the day at UTC
// midnight. The recovery resolver reads the local day key off this stamp.
const candidate = bedtimeEnd ?? `${day}T00:00:00.000Z`;
const d = new Date(candidate);
return Number.isNaN(d.getTime()) ? null : d;
}
/** Map one Oura readiness record: `score` → `RECOVERY_SCORE` (source OURA,
* feeding the recovery ladder below WHOOP). */
export function mapReadiness(r: OuraReadiness): MappedMeasurement[] {
if (typeof r.score !== "number") return [];
const measuredAt = dayAnchor(r.day, r.timestamp);
if (!measuredAt) return [];
return [
{
type: "RECOVERY_SCORE",
value: round2(r.score),
unit: "score",
measuredAt,
fieldTag: "recovery",
},
];
}
/** Map one Oura sleep record: per-stage durations (s→min), efficiency, HRV,
* RHR (lowest_heart_rate), respiratory rate. */
export function mapSleep(s: OuraSleep): MappedMeasurement[] {
const measuredAt = dayAnchor(s.day, s.bedtime_end);
if (!measuredAt) return [];
const out: MappedMeasurement[] = [];
const stages: Array<[number | null | undefined, MappedMeasurement["sleepStage"], string]> = [
[s.light_sleep_duration, "CORE", "sleep_core"],
[s.deep_sleep_duration, "DEEP", "sleep_deep"],
[s.rem_sleep_duration, "REM", "sleep_rem"],
[s.awake_time, "AWAKE", "sleep_awake"],
];
for (const [sec, stage, fieldTag] of stages) {
if (typeof sec === "number" && sec >= 0) {
out.push({
type: "SLEEP_DURATION",
value: round2(sec * SEC_TO_MIN),
unit: "minutes",
measuredAt,
fieldTag,
sleepStage: stage,
});
}
}
if (typeof s.efficiency === "number") {
out.push({
type: "SLEEP_EFFICIENCY",
value: round2(s.efficiency),
unit: "%",
measuredAt,
fieldTag: "sleep_eff",
});
}
if (typeof s.average_hrv === "number" && s.average_hrv > 0) {
out.push({
type: "HRV_RMSSD",
value: round2(s.average_hrv),
unit: "ms",
measuredAt,
fieldTag: "hrv_rmssd",
});
}
if (typeof s.lowest_heart_rate === "number" && s.lowest_heart_rate > 0) {
out.push({
type: "RESTING_HEART_RATE",
value: Math.round(s.lowest_heart_rate),
unit: "bpm",
measuredAt,
fieldTag: "rhr",
});
}
if (typeof s.average_breath === "number" && s.average_breath > 0) {
out.push({
type: "RESPIRATORY_RATE",
value: round2(s.average_breath),
unit: "breaths/min",
measuredAt,
fieldTag: "resp_rate",
});
}
return out;
}
/** Map one Oura daily-activity record: steps + active energy. */
export function mapDailyActivity(a: OuraDailyActivity): MappedMeasurement[] {
const measuredAt = dayAnchor(a.day, a.timestamp);
if (!measuredAt) return [];
const out: MappedMeasurement[] = [];
if (typeof a.steps === "number" && a.steps >= 0) {
out.push({
type: "ACTIVITY_STEPS",
value: Math.round(a.steps),
unit: "steps",
measuredAt,
fieldTag: "steps",
});
}
if (typeof a.active_calories === "number" && a.active_calories >= 0) {
out.push({
type: "ACTIVE_ENERGY_BURNED",
value: round2(a.active_calories),
unit: "kcal",
measuredAt,
fieldTag: "active_energy",
});
}
return out;
}
export const OURA_FIELD_MAP: Record<
string,
{ type: string; unit: string; note?: string }
> = {
"daily_readiness.score": {
type: "RECOVERY_SCORE",
unit: "score",
note: "0-100 readiness; feeds recovery ladder below WHOOP",
},
"sleep.{light,deep,rem}_sleep_duration": {
type: "SLEEP_DURATION",
unit: "minutes",
note: "s->min, one row per stage",
},
"sleep.awake_time": {
type: "SLEEP_DURATION",
unit: "minutes",
note: "AWAKE stage",
},
"sleep.efficiency": { type: "SLEEP_EFFICIENCY", unit: "%" },
"sleep.average_hrv": { type: "HRV_RMSSD", unit: "ms" },
"sleep.lowest_heart_rate": { type: "RESTING_HEART_RATE", unit: "bpm" },
"sleep.average_breath": { type: "RESPIRATORY_RATE", unit: "breaths/min" },
"daily_activity.steps": { type: "ACTIVITY_STEPS", unit: "steps" },
"daily_activity.active_calories": {
type: "ACTIVE_ENERGY_BURNED",
unit: "kcal",
},
};
+50
View File
@@ -0,0 +1,50 @@
/**
* v1.17.0 (F4) — per-user Oura token accessors.
*
* Oura uses a single shared OAuth app (env client id/secret via
* `getOuraCredentials`). Per-user state is the granted access + refresh token,
* stored encrypted on `User`. The merged schema carries NO token-expiry column,
* so the sync layer refreshes REACTIVELY on a 401 rather than proactively on an
* expiry timestamp (and persists both rotated tokens).
*/
import { prisma } from "@/lib/db";
import { decrypt, encrypt } from "@/lib/crypto";
export interface OuraConnection {
accessToken: string;
refreshToken: string;
}
export async function getOuraConnection(
userId: string,
): Promise<OuraConnection | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
ouraAccessTokenEncrypted: true,
ouraRefreshTokenEncrypted: true,
},
});
if (!user?.ouraAccessTokenEncrypted || !user?.ouraRefreshTokenEncrypted) {
return null;
}
return {
accessToken: decrypt(user.ouraAccessTokenEncrypted),
refreshToken: decrypt(user.ouraRefreshTokenEncrypted),
};
}
/** Persist a rotated token pair after a successful refresh. */
export async function storeOuraTokens(
userId: string,
accessToken: string,
refreshToken: string,
): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: {
ouraAccessTokenEncrypted: encrypt(accessToken),
ouraRefreshTokenEncrypted: encrypt(refreshToken),
},
});
}
+113
View File
@@ -0,0 +1,113 @@
/**
* v1.17.0 (F4) — off-response classifier for Oura Cloud API v2 replies.
* Mirrors the WHOOP / Polar classifiers: HTTP-status-driven.
*
* - `success` → HTTP 2xx
* - `transient` → 429 (rate-limited), 5xx, 3xx, unknown.
* - `reauth_required` → 401 / 403 — token rejected / grant revoked.
* - `persistent` → any other 4xx — malformed request / contract drift.
*/
export type OuraClassification =
| "success"
| "transient"
| "reauth_required"
| "persistent";
export interface ClassifiedOuraResponse {
classification: OuraClassification;
httpStatus: number | undefined;
reason: string;
}
export function classifyOuraResponse(
httpStatus: number,
): ClassifiedOuraResponse {
if (httpStatus >= 200 && httpStatus < 300) {
return { classification: "success", httpStatus, reason: "ok" };
}
if (httpStatus === 429) {
return { classification: "transient", httpStatus, reason: "http_429" };
}
if (httpStatus === 401 || httpStatus === 403) {
return {
classification: "reauth_required",
httpStatus,
reason: `http_${httpStatus}`,
};
}
if (httpStatus >= 400 && httpStatus < 500) {
return {
classification: "persistent",
httpStatus,
reason: `http_${httpStatus}`,
};
}
if (httpStatus >= 500 && httpStatus < 600) {
return {
classification: "transient",
httpStatus,
reason: `http_${httpStatus}`,
};
}
return {
classification: "transient",
httpStatus,
reason: `http_${httpStatus}_unknown`,
};
}
export class OuraApiError extends Error {
readonly classification: OuraClassification;
readonly httpStatus: number | undefined;
readonly reason: string;
readonly verb: string;
readonly upstreamError: string | undefined;
constructor(opts: {
verb: string;
classification: OuraClassification;
httpStatus: number | undefined;
reason: string;
upstreamError?: string;
}) {
const statusLabel =
typeof opts.httpStatus === "number" ? opts.httpStatus : "?";
const errSegment = opts.upstreamError ? ` - ${opts.upstreamError}` : "";
const raw = `Oura ${opts.verb} error: ${statusLabel}${errSegment}`;
super(raw.slice(0, 1024));
this.name = "OuraApiError";
this.verb = opts.verb;
this.classification = opts.classification;
this.httpStatus = opts.httpStatus;
this.reason = opts.reason;
this.upstreamError = opts.upstreamError;
}
}
/**
* True when a caught error is an OAuth `invalid_grant` on the token endpoint —
* the canonical revoked-refresh-token signal. Lifts a 400 carrying
* `invalid_grant` from `persistent` to `reauth_required` so the connection
* surfaces the reconnect prompt.
*/
export function isInvalidGrant(err: unknown): boolean {
if (err instanceof OuraApiError) {
if (err.httpStatus !== 400) return false;
if (err.upstreamError === "invalid_grant") return true;
return /\binvalid_grant\b/.test(err.message);
}
const msg = err instanceof Error ? err.message : String(err);
return /Oura\s+\w+\s+error:\s*400\b[\s\S]*\binvalid_grant\b/.test(msg);
}
export function classifyOuraError(err: unknown): OuraClassification {
if (isInvalidGrant(err)) return "reauth_required";
if (err instanceof OuraApiError) return err.classification;
const msg = err instanceof Error ? err.message : String(err);
const m = /Oura\s+\w+\s+error:\s*(\d+)/.exec(msg);
if (!m) return "transient";
const status = Number.parseInt(m[1]!, 10);
if (!Number.isFinite(status)) return "transient";
return classifyOuraResponse(status).classification;
}
+235
View File
@@ -0,0 +1,235 @@
/**
* v1.17.0 (F4) — Oura Cloud v2 sync.
*
* Pulls daily readiness (→ RECOVERY_SCORE), sleep (per-stage durations,
* efficiency, HRV, RHR, respiratory rate), and daily activity (steps + active
* energy) for one connected user, mapping each into `Measurement` rows tagged
* `source = OURA`.
*
* Token model: Oura uses refresh tokens. The merged schema has no expiry
* column, so the sync refreshes REACTIVELY — the first read that 401s triggers
* one refresh (persisting BOTH rotated tokens) and a single retry. A failed
* refresh (`invalid_grant`) records `reauth_required` on the `oura` ledger.
*
* Idempotency: `externalId = <resource>:<day>:<fieldTag>`, upsert keyed on
* `(userId, type, source = OURA, externalId)`. Oura finalises a day's scores
* after the night, so the `update` branch overwrites in place (re-score).
*
* The measurement-write tail mirrors the shared WHOOP / Nightscout sync tail.
*/
import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { getEvent } from "@/lib/logging/context";
import {
recordSyncFailure,
recordSyncSuccess,
type FailureKind,
} from "@/lib/integrations/status";
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
fetchDailyActivity,
fetchReadiness,
fetchSleep,
getOuraCredentials,
mapDailyActivity,
mapReadiness,
mapSleep,
refreshAccessToken,
type MappedMeasurement,
} from "./client";
import { getOuraConnection, storeOuraTokens } from "./credentials";
import { OuraApiError, classifyOuraError } from "./response-classifier";
/** Default lookback window (days) for an incremental sync. Oura finalises a
* night's scores hours after wake; 7 days re-fetches a handful of records (the
* upserts are idempotent) and closes any catch-up gap. */
export const OURA_SYNC_LOOKBACK_DAYS = 7;
export function classifyOuraFailure(err: unknown): FailureKind {
const c = classifyOuraError(err);
if (c === "reauth_required") return "reauth_required";
if (c === "persistent") return "persistent";
return "transient";
}
function ymd(d: Date): string {
return d.toISOString().slice(0, 10);
}
export interface OuraMeasurementUpsert {
type: string;
value: number;
unit: string;
measuredAt: Date;
externalId: string;
sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE" | null;
}
function toUpsert(
mapped: MappedMeasurement[],
resourcePrefix: string,
): OuraMeasurementUpsert[] {
return mapped.map((m) => ({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: `${resourcePrefix}:${ymd(m.measuredAt)}:${m.fieldTag}`,
sleepStage: m.sleepStage ?? null,
}));
}
/**
* Fetch all three Oura collections for a user with a single reactive
* refresh-on-401 retry. Returns the raw mapped readings (not yet upserted).
* Throws a classified `OuraApiError` on a hard failure so the caller records
* the ledger entry.
*/
async function fetchAll(
userId: string,
accessToken: string,
refreshToken: string,
lookbackDays: number,
): Promise<OuraMeasurementUpsert[]> {
const now = new Date();
const start = new Date(now.getTime() - lookbackDays * 24 * 60 * 60 * 1000);
const query = { startDate: ymd(start), endDate: ymd(now) };
const run = async (token: string): Promise<OuraMeasurementUpsert[]> => {
const [readiness, sleeps, activities] = await Promise.all([
fetchReadiness(token, query),
fetchSleep(token, query),
fetchDailyActivity(token, query),
]);
const out: OuraMeasurementUpsert[] = [];
for (const r of readiness) out.push(...toUpsert(mapReadiness(r), "readiness"));
for (const s of sleeps) out.push(...toUpsert(mapSleep(s), "sleep"));
for (const a of activities) out.push(...toUpsert(mapDailyActivity(a), "activity"));
return out;
};
try {
return await run(accessToken);
} catch (err) {
// Reactive refresh: a 401 means the access token expired. Refresh once
// (rotating both tokens) and retry. A 403 / other error is NOT a refresh
// case — rethrow so the caller classifies it.
if (!(err instanceof OuraApiError) || err.httpStatus !== 401) throw err;
const creds = getOuraCredentials();
if (!creds) throw err;
const rotated = await refreshAccessToken(refreshToken, creds);
await storeOuraTokens(userId, rotated.access_token, rotated.refresh_token);
return run(rotated.access_token);
}
}
/**
* Sync one user's Oura data. Returns the count of measurement rows written.
* A user with no Oura connection is a clean no-op (returns 0, touches no
* status row).
*/
export async function syncUserOura(
userId: string,
opts: { lookbackDays?: number } = {},
): Promise<number> {
const conn = await getOuraConnection(userId);
if (!conn) return 0;
let readings: OuraMeasurementUpsert[];
try {
readings = await fetchAll(
userId,
conn.accessToken,
conn.refreshToken,
opts.lookbackDays ?? OURA_SYNC_LOOKBACK_DAYS,
);
} catch (err) {
await recordSyncFailure({
userId,
integration: "oura",
kind: classifyOuraFailure(err),
message: err instanceof Error ? err.message : String(err),
errorCode:
err instanceof OuraApiError && err.httpStatus != null
? String(err.httpStatus)
: undefined,
});
throw err;
}
const imported = await upsertOuraMeasurements(userId, readings);
await recordSyncSuccess(userId, "oura");
return imported;
}
export async function upsertOuraMeasurements(
userId: string,
readings: OuraMeasurementUpsert[],
): Promise<number> {
if (readings.length === 0) return 0;
let imported = 0;
const touched: Array<{ type: MeasurementType; measuredAt: Date }> = [];
for (const r of readings) {
const type = r.type as MeasurementType;
try {
await prisma.measurement.upsert({
where: {
userId_type_source_externalId: {
userId,
type,
source: "OURA",
externalId: r.externalId,
},
},
create: {
userId,
type,
source: "OURA",
value: r.value,
unit: r.unit,
measuredAt: r.measuredAt,
externalId: r.externalId,
sleepStage: r.sleepStage ?? null,
},
update: {
value: r.value,
unit: r.unit,
measuredAt: r.measuredAt,
sleepStage: r.sleepStage ?? null,
syncVersion: { increment: 1 },
},
});
touched.push({ type, measuredAt: r.measuredAt });
imported++;
} catch (err) {
getEvent()?.addWarning(`oura: failed to upsert measurement: ${err}`);
}
}
try {
const keys = collapseToTypeDayKeys(touched);
for (const k of keys) {
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
}
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
).catch((err) => {
getEvent()?.addWarning(
`oura: status-insight invalidate failed for ${userId}: ${err}`,
);
});
} catch (err) {
getEvent()?.addWarning(`oura: rollup recompute failed for ${userId}: ${err}`);
}
return imported;
}
+213
View File
@@ -0,0 +1,213 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
POLAR_OAUTH_SCOPE,
exchangeCode,
fetchNightlyRecharges,
getAuthorizationUrl,
getPolarCredentials,
getPolarRedirectUri,
mapActivity,
mapNightlyRecharge,
mapSleep,
registerUser,
type PolarActivity,
type PolarNightlyRecharge,
type PolarSleep,
} from "../client";
import { PolarApiError } from "../response-classifier";
const CREDS = { clientId: "cid", clientSecret: "csecret" };
function installFetchMock(pages: Array<{ status: number; body?: unknown }>) {
let i = 0;
const fetchMock = vi.fn(async (...args: [string, RequestInit?]) => {
void args;
const page = pages[Math.min(i, pages.length - 1)]!;
i += 1;
return {
status: page.status,
json: async () => page.body ?? null,
};
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
beforeEach(() => {
process.env.POLAR_CLIENT_ID = "env-cid";
process.env.POLAR_CLIENT_SECRET = "env-secret";
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
delete process.env.POLAR_REDIRECT_URI;
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("getPolarCredentials", () => {
it("reads from env", () => {
expect(getPolarCredentials()).toEqual({
clientId: "env-cid",
clientSecret: "env-secret",
});
});
it("returns null when unconfigured", () => {
delete process.env.POLAR_CLIENT_ID;
expect(getPolarCredentials()).toBeNull();
});
});
describe("getAuthorizationUrl", () => {
it("builds the Polar authorize URL with scope + state", () => {
const url = getAuthorizationUrl("signed-state", CREDS);
expect(url).toContain("flow.polar.com/oauth2/authorization");
expect(url).toContain("response_type=code");
expect(url).toContain("scope=accesslink.read_all");
expect(url).toContain("state=signed-state");
expect(url).toContain("client_id=cid");
});
});
describe("getPolarRedirectUri", () => {
it("derives from app URL when override absent", () => {
expect(getPolarRedirectUri()).toBe(
"https://app.example/api/polar/callback",
);
});
it("honours the explicit override", () => {
process.env.POLAR_REDIRECT_URI = "https://custom/cb";
expect(getPolarRedirectUri()).toBe("https://custom/cb");
});
});
describe("exchangeCode", () => {
it("posts Basic-auth + returns the token and x_user_id", async () => {
const fetchMock = installFetchMock([
{
status: 200,
body: { access_token: "tok", token_type: "bearer", x_user_id: 42 },
},
]);
const tok = await exchangeCode("code123", CREDS);
expect(tok.access_token).toBe("tok");
expect(tok.x_user_id).toBe(42);
const [, init] = fetchMock.mock.calls[0]!;
const headers = init!.headers as Record<string, string>;
expect(headers.Authorization).toBe(
`Basic ${Buffer.from("cid:csecret").toString("base64")}`,
);
expect(init!.body).toContain("grant_type=authorization_code");
});
it("throws a classified PolarApiError on a 401", async () => {
installFetchMock([{ status: 401, body: { error: "invalid_grant" } }]);
await expect(exchangeCode("bad", CREDS)).rejects.toBeInstanceOf(
PolarApiError,
);
});
});
describe("registerUser", () => {
it("treats a 409 (already registered) as success", async () => {
installFetchMock([{ status: 409, body: {} }]);
await expect(registerUser("tok", "42")).resolves.toBeUndefined();
});
it("succeeds on a 200", async () => {
installFetchMock([{ status: 200, body: {} }]);
await expect(registerUser("tok", "42")).resolves.toBeUndefined();
});
it("throws on a 500", async () => {
installFetchMock([{ status: 500 }]);
await expect(registerUser("tok", "42")).rejects.toBeInstanceOf(
PolarApiError,
);
});
});
describe("fetchNightlyRecharges", () => {
it("returns [] on a 204 No Content", async () => {
installFetchMock([{ status: 204 }]);
expect(await fetchNightlyRecharges("tok", "42")).toEqual([]);
});
it("unwraps the recharges array", async () => {
installFetchMock([
{ status: 200, body: { recharges: [{ date: "2026-06-10" }] } },
]);
const r = await fetchNightlyRecharges("tok", "42");
expect(r).toHaveLength(1);
expect(r[0]!.date).toBe("2026-06-10");
});
});
describe("mapNightlyRecharge", () => {
it("maps the recovery band to a 0-100 RECOVERY_SCORE", () => {
const rec: PolarNightlyRecharge = {
date: "2026-06-10",
nightly_recharge_status: 6,
hrv_avg: 55,
heart_rate_avg: 52.4,
breathing_rate_avg: 14.2,
};
const mapped = mapNightlyRecharge(rec);
const recovery = mapped.find((m) => m.type === "RECOVERY_SCORE");
expect(recovery?.value).toBe(100);
expect(mapped.find((m) => m.type === "HRV_RMSSD")?.value).toBe(55);
expect(mapped.find((m) => m.type === "RESTING_HEART_RATE")?.value).toBe(52);
expect(mapped.find((m) => m.type === "RESPIRATORY_RATE")?.value).toBe(14.2);
});
it("rescales status 1 -> 0", () => {
const mapped = mapNightlyRecharge({
date: "2026-06-10",
nightly_recharge_status: 1,
});
expect(mapped.find((m) => m.type === "RECOVERY_SCORE")?.value).toBe(0);
});
it("skips a bad date", () => {
expect(mapNightlyRecharge({ date: "not-a-date" })).toEqual([]);
});
});
describe("mapSleep", () => {
it("maps per-stage durations s->min and the sleep score", () => {
const s: PolarSleep = {
date: "2026-06-10",
light_sleep: 3600,
deep_sleep: 1800,
rem_sleep: 5400,
sleep_score: 82,
};
const mapped = mapSleep(s);
const core = mapped.find((m) => m.sleepStage === "CORE");
expect(core?.value).toBe(60);
expect(mapped.find((m) => m.sleepStage === "DEEP")?.value).toBe(30);
expect(mapped.find((m) => m.sleepStage === "REM")?.value).toBe(90);
expect(mapped.find((m) => m.type === "SLEEP_PERFORMANCE")?.value).toBe(82);
});
});
describe("mapActivity", () => {
it("maps steps and active energy to canonical MeasurementType enum values", () => {
const a: PolarActivity = {
date: "2026-06-10",
"active-steps": 8123,
"active-calories": 540.6,
calories: 2400,
};
const mapped = mapActivity(a);
expect(mapped.find((m) => m.type === "ACTIVITY_STEPS")?.value).toBe(8123);
// ACTIVE portion only — NOT the total `calories` (2400, incl. BMR).
expect(mapped.find((m) => m.type === "ACTIVE_ENERGY_BURNED")?.value).toBe(
540.6,
);
});
});
describe("POLAR_OAUTH_SCOPE", () => {
it("is the read-all scope", () => {
expect(POLAR_OAUTH_SCOPE).toBe("accesslink.read_all");
});
});
+140
View File
@@ -0,0 +1,140 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
getConnMock,
fetchRechargesMock,
fetchSleepsMock,
fetchActivitiesMock,
upsertMock,
recordSuccessMock,
recordFailureMock,
recomputeMock,
invalidateMock,
} = vi.hoisted(() => ({
getConnMock: vi.fn(),
fetchRechargesMock: vi.fn(),
fetchSleepsMock: vi.fn(),
fetchActivitiesMock: vi.fn(),
upsertMock: vi.fn(),
recordSuccessMock: vi.fn(),
recordFailureMock: vi.fn(),
recomputeMock: vi.fn(),
invalidateMock: vi.fn(),
}));
vi.mock("../credentials", () => ({
getPolarConnection: getConnMock,
}));
vi.mock("@/lib/db", () => ({
prisma: { measurement: { upsert: upsertMock } },
}));
vi.mock("@/lib/integrations/status", () => ({
recordSyncSuccess: recordSuccessMock,
recordSyncFailure: recordFailureMock,
}));
vi.mock("@/lib/rollups/measurement-rollups", () => ({
collapseToTypeDayKeys: (rows: Array<{ type: string; measuredAt: Date }>) =>
rows.map((r) => ({ type: r.type, measuredAt: r.measuredAt })),
recomputeBucketsForMeasurement: recomputeMock,
}));
vi.mock("@/lib/insights/comprehensive-generate", () => ({
invalidateStatusInsightsForTypes: invalidateMock,
}));
vi.mock("../client", async (importOriginal) => {
const actual = await importOriginal<typeof import("../client")>();
return {
...actual,
fetchNightlyRecharges: fetchRechargesMock,
fetchSleeps: fetchSleepsMock,
fetchActivities: fetchActivitiesMock,
};
});
import { syncUserPolar } from "../sync";
import { PolarApiError } from "../response-classifier";
const CONN = { accessToken: "tok", polarUserId: "42" };
beforeEach(() => {
getConnMock.mockReset();
fetchRechargesMock.mockReset();
fetchSleepsMock.mockReset();
fetchActivitiesMock.mockReset();
upsertMock.mockReset().mockResolvedValue({});
recordSuccessMock.mockReset().mockResolvedValue(undefined);
recordFailureMock.mockReset().mockResolvedValue(undefined);
recomputeMock.mockReset().mockResolvedValue(undefined);
invalidateMock.mockReset().mockResolvedValue(undefined);
fetchRechargesMock.mockResolvedValue([]);
fetchSleepsMock.mockResolvedValue([]);
fetchActivitiesMock.mockResolvedValue([]);
});
afterEach(() => vi.clearAllMocks());
describe("syncUserPolar", () => {
it("no-ops cleanly for an unconnected user", async () => {
getConnMock.mockResolvedValue(null);
expect(await syncUserPolar("u1")).toBe(0);
expect(upsertMock).not.toHaveBeenCalled();
expect(recordSuccessMock).not.toHaveBeenCalled();
});
it("maps a recharge RECOVERY_SCORE row with source POLAR + stable externalId", async () => {
getConnMock.mockResolvedValue(CONN);
fetchRechargesMock.mockResolvedValue([
{ date: "2026-06-10", nightly_recharge_status: 6 },
]);
const imported = await syncUserPolar("u1");
expect(imported).toBe(1);
const arg = upsertMock.mock.calls[0]![0];
expect(arg.where.userId_type_source_externalId).toMatchObject({
userId: "u1",
type: "RECOVERY_SCORE",
source: "POLAR",
externalId: "recharge:2026-06-10:recovery",
});
expect(arg.create.source).toBe("POLAR");
expect(arg.create.value).toBe(100);
expect(recordSuccessMock).toHaveBeenCalledWith("u1", "polar");
});
it("is idempotent — a re-sync upserts on the same externalId", async () => {
getConnMock.mockResolvedValue(CONN);
fetchRechargesMock.mockResolvedValue([
{ date: "2026-06-10", nightly_recharge_status: 3 },
]);
await syncUserPolar("u1");
await syncUserPolar("u1");
expect(upsertMock.mock.calls[0]![0].where).toEqual(
upsertMock.mock.calls[1]![0].where,
);
});
it("records a reauth_required failure on a 401 and rethrows", async () => {
getConnMock.mockResolvedValue(CONN);
fetchRechargesMock.mockRejectedValue(
new PolarApiError({
verb: "fetchNightlyRecharges",
classification: "reauth_required",
httpStatus: 401,
reason: "http_401",
}),
);
await expect(syncUserPolar("u1")).rejects.toBeInstanceOf(PolarApiError);
expect(recordFailureMock).toHaveBeenCalledWith(
expect.objectContaining({
userId: "u1",
integration: "polar",
kind: "reauth_required",
errorCode: "401",
}),
);
expect(recordSuccessMock).not.toHaveBeenCalled();
});
});
+465
View File
@@ -0,0 +1,465 @@
/**
* v1.17.0 (F4) — Polar AccessLink v3 client for OAuth + data fetching.
* Docs: https://www.polar.com/accesslink-api/ (re-verify at build).
*
* Mirrors the WHOOP client structure (`src/lib/whoop/client.ts`): hand-rolled
* fetch over `safeFetch` (no SDK), an OAuth handshake, typed collection
* fetchers, and a single source-of-truth field→Measurement mapping
* (`POLAR_FIELD_MAP`).
*
* Polar specifics that differ from WHOOP:
* - The token endpoint uses HTTP Basic auth (`base64(client_id:client_secret)`)
* rather than client_id/secret in the body.
* - Polar access tokens DO NOT expire and there is NO refresh token. The
* `User.polarRefreshTokenEncrypted` column stays null; `getValidToken`
* never refreshes. A revoked grant surfaces as a 401 on the next data read.
* - The token response carries `x_user_id` — Polar's numeric member id — which
* every data path needs (`/v3/users/{userId}/...`). It is persisted as
* `User.polarUserIdEncrypted` at connect time.
* - The client must register the user once (`POST /v3/users`) before data
* reads work; a 409 from registration means "already registered" and is
* treated as success.
*/
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import { PolarApiError, classifyPolarResponse } from "./response-classifier";
const POLAR_API_BASE = "https://www.polaraccesslink.com";
const POLAR_OAUTH_AUTH_URL = "https://flow.polar.com/oauth2/authorization";
const POLAR_OAUTH_TOKEN_URL = "https://polarremote.com/v2/oauth2/token";
export interface PolarCredentials {
clientId: string;
clientSecret: string;
}
/** Resolve the env-configured shared OAuth credentials (F4 = full OAuth from
* env, not per-user BYO keys). Returns null when unconfigured so the connect
* route can surface a clean "integration disabled" message. */
export function getPolarCredentials(): PolarCredentials | null {
const clientId = process.env.POLAR_CLIENT_ID;
const clientSecret = process.env.POLAR_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
return { clientId, clientSecret };
}
export function getPolarRedirectUri(): string {
// `||`, not `??`: the compose whitelist materialises an unset var as an empty
// string, which must still fall through to the derived URI.
return (
process.env.POLAR_REDIRECT_URI ||
`${process.env.NEXT_PUBLIC_APP_URL}/api/polar/callback`
);
}
/**
* Polar OAuth scope. `accesslink.read_all` grants every read collection so the
* user consents once. Space-separated on the wire.
*/
export const POLAR_OAUTH_SCOPE = "accesslink.read_all" as const;
/** Build the browser-redirect authorization URL. `state` is the signed nonce. */
export function getAuthorizationUrl(
state: string,
creds: PolarCredentials,
): string {
const params = new URLSearchParams({
response_type: "code",
client_id: creds.clientId,
redirect_uri: getPolarRedirectUri(),
scope: POLAR_OAUTH_SCOPE,
state,
});
return `${POLAR_OAUTH_AUTH_URL}?${params}`;
}
export interface PolarTokenResponse {
access_token: string;
token_type: string;
expires_in?: number;
x_user_id: number;
}
function basicAuthHeader(creds: PolarCredentials): string {
const raw = `${creds.clientId}:${creds.clientSecret}`;
return `Basic ${Buffer.from(raw).toString("base64")}`;
}
/** Exchange an authorization code for the access token + `x_user_id`. */
export async function exchangeCode(
code: string,
creds: PolarCredentials,
): Promise<PolarTokenResponse> {
const start = performance.now();
const res = await safeFetch(POLAR_OAUTH_TOKEN_URL, {
method: "POST",
headers: {
Authorization: basicAuthHeader(creds),
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: getPolarRedirectUri(),
}).toString(),
});
const json = await res.json().catch(() => null);
const verdict = classifyPolarResponse(res.status);
getEvent()?.addExternalCall({
service: "polar",
method: "exchangeCode",
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw new PolarApiError({
verb: "exchangeCode",
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
upstreamError:
typeof json?.error === "string" ? json.error : undefined,
});
}
return json as PolarTokenResponse;
}
/**
* Register the user with the Polar app (`POST /v3/users`). Required once before
* any data read works. A 409 means the member is already registered for this
* app — treated as success. Other non-2xx throw a classified error.
*/
export async function registerUser(
accessToken: string,
memberId: string,
): Promise<void> {
const start = performance.now();
const res = await safeFetch(`${POLAR_API_BASE}/v3/users`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ "member-id": memberId }),
});
const verdict = classifyPolarResponse(res.status);
getEvent()?.addExternalCall({
service: "polar",
method: "registerUser",
duration_ms: Math.round(performance.now() - start),
status: res.status,
error:
res.status === 409 || verdict.classification === "success"
? undefined
: verdict.reason,
});
// 409 Conflict — member already registered for this app. Idempotent success.
if (res.status === 409) return;
if (verdict.classification !== "success") {
throw new PolarApiError({
verb: "registerUser",
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
});
}
}
// ─── Collection reads ──────────────────────────────────────────
/** One Polar Nightly Recharge record. */
export interface PolarNightlyRecharge {
date: string;
nightly_recharge_status?: number | null;
ans_charge?: number | null;
ans_charge_status?: number | null;
heart_rate_avg?: number | null;
hrv_avg?: number | null;
breathing_rate_avg?: number | null;
}
/** One Polar Sleep record. Durations are seconds. */
export interface PolarSleep {
date: string;
sleep_start_time?: string;
sleep_end_time?: string;
light_sleep?: number | null;
deep_sleep?: number | null;
rem_sleep?: number | null;
total_interruption_duration?: number | null;
sleep_charge?: number | null;
sleep_score?: number | null;
heart_rate_samples?: Record<string, number>;
}
/** One Polar daily-activity summary. */
export interface PolarActivity {
date: string;
"active-calories"?: number | null;
"active-steps"?: number | null;
calories?: number | null;
}
interface CollectionResult<T> {
records: T[];
}
async function fetchCollection<T>(
path: string,
accessToken: string,
verb: string,
recordsKey: string,
): Promise<T[]> {
const start = performance.now();
const res = await safeFetch(`${POLAR_API_BASE}${path}`, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
});
const verdict = classifyPolarResponse(res.status);
getEvent()?.addExternalCall({
service: "polar",
method: verb,
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw new PolarApiError({
verb,
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
});
}
// 204 No Content — a window with no records. Polar returns an empty body.
if (res.status === 204) return [];
const json = (await res.json().catch(() => null)) as
| CollectionResult<T>
| Record<string, T[]>
| null;
if (!json) return [];
const list = (json as Record<string, unknown>)[recordsKey];
return Array.isArray(list) ? (list as T[]) : [];
}
export function fetchNightlyRecharges(
accessToken: string,
userId: string,
): Promise<PolarNightlyRecharge[]> {
return fetchCollection<PolarNightlyRecharge>(
`/v3/users/${encodeURIComponent(userId)}/nightly-recharge`,
accessToken,
"fetchNightlyRecharges",
"recharges",
);
}
export function fetchSleeps(
accessToken: string,
userId: string,
): Promise<PolarSleep[]> {
return fetchCollection<PolarSleep>(
`/v3/users/${encodeURIComponent(userId)}/sleep`,
accessToken,
"fetchSleeps",
"nights",
);
}
export function fetchActivities(
accessToken: string,
userId: string,
): Promise<PolarActivity[]> {
return fetchCollection<PolarActivity>(
`/v3/users/${encodeURIComponent(userId)}/activity-transactions`,
accessToken,
"fetchActivities",
"activity-log",
);
}
// ─── Field → Measurement mapping ───────────────────────────────
const SEC_TO_MIN = 1 / 60;
function round2(n: number): number {
return parseFloat(n.toFixed(2));
}
/** A single mapped reading destined for one `Measurement` row. The sync layer
* stamps `source = POLAR` + `externalId = <date>:<fieldTag>`. */
export interface MappedMeasurement {
type: string;
value: number;
unit: string;
measuredAt: Date;
fieldTag: string;
sleepStage?: "CORE" | "DEEP" | "REM";
}
/** A `nightly_recharge_status` is Polar's 1–6 recovery band; map it to a
* 0–100 RECOVERY_SCORE so it feeds the canonical recovery ladder below WHOOP.
* The status is the most stable recovery signal Polar exposes; we linearly
* rescale 1–6 → 0–100 (1 = worst, 6 = best). */
function rechargeStatusToScore(status: number): number {
const clamped = Math.max(1, Math.min(6, status));
return round2(((clamped - 1) / 5) * 100);
}
/**
* Map one Polar Nightly Recharge record. The recovery band → `RECOVERY_SCORE`
* (source POLAR, distinct from the COMPUTED proxy); HRV → `HRV_RMSSD`; the
* nightly average HR → `RESTING_HEART_RATE`; breathing rate → `RESPIRATORY_RATE`.
* `measuredAt` is the record's date at local midnight UTC anchor (the wake
* morning the night belongs to).
*/
export function mapNightlyRecharge(r: PolarNightlyRecharge): MappedMeasurement[] {
const measuredAt = new Date(`${r.date}T00:00:00.000Z`);
if (Number.isNaN(measuredAt.getTime())) return [];
const out: MappedMeasurement[] = [];
if (typeof r.nightly_recharge_status === "number") {
out.push({
type: "RECOVERY_SCORE",
value: rechargeStatusToScore(r.nightly_recharge_status),
unit: "score",
measuredAt,
fieldTag: "recovery",
});
}
if (typeof r.hrv_avg === "number" && r.hrv_avg > 0) {
out.push({
type: "HRV_RMSSD",
value: round2(r.hrv_avg),
unit: "ms",
measuredAt,
fieldTag: "hrv_rmssd",
});
}
if (typeof r.heart_rate_avg === "number" && r.heart_rate_avg > 0) {
out.push({
type: "RESTING_HEART_RATE",
value: Math.round(r.heart_rate_avg),
unit: "bpm",
measuredAt,
fieldTag: "rhr",
});
}
if (typeof r.breathing_rate_avg === "number" && r.breathing_rate_avg > 0) {
out.push({
type: "RESPIRATORY_RATE",
value: round2(r.breathing_rate_avg),
unit: "breaths/min",
measuredAt,
fieldTag: "resp_rate",
});
}
return out;
}
/** Map one Polar Sleep record: per-stage `SLEEP_DURATION` (s→min) + the
* `SLEEP_SCORE` percentage where present. */
export function mapSleep(s: PolarSleep): MappedMeasurement[] {
const measuredAt = new Date(`${s.date}T00:00:00.000Z`);
if (Number.isNaN(measuredAt.getTime())) return [];
const out: MappedMeasurement[] = [];
const stages: Array<[number | null | undefined, MappedMeasurement["sleepStage"], string]> = [
[s.light_sleep, "CORE", "sleep_core"],
[s.deep_sleep, "DEEP", "sleep_deep"],
[s.rem_sleep, "REM", "sleep_rem"],
];
for (const [sec, stage, fieldTag] of stages) {
if (typeof sec === "number" && sec >= 0) {
out.push({
type: "SLEEP_DURATION",
value: round2(sec * SEC_TO_MIN),
unit: "minutes",
measuredAt,
fieldTag,
sleepStage: stage,
});
}
}
if (typeof s.sleep_score === "number") {
out.push({
type: "SLEEP_PERFORMANCE",
value: round2(s.sleep_score),
unit: "%",
measuredAt,
fieldTag: "sleep_score",
});
}
return out;
}
/** Map one Polar daily-activity record: `ACTIVITY_STEPS` +
* `ACTIVE_ENERGY_BURNED` where Polar reports them. Steps live under
* `active-steps`; the ACTIVE energy portion under `active-calories` (NOT the
* total `calories`, which includes BMR — matching the Fitbit active-only
* convention). */
export function mapActivity(a: PolarActivity): MappedMeasurement[] {
const measuredAt = new Date(`${a.date}T00:00:00.000Z`);
if (Number.isNaN(measuredAt.getTime())) return [];
const out: MappedMeasurement[] = [];
const steps = a["active-steps"];
if (typeof steps === "number" && steps >= 0) {
out.push({
type: "ACTIVITY_STEPS",
value: Math.round(steps),
unit: "steps",
measuredAt,
fieldTag: "steps",
});
}
const activeCalories = a["active-calories"];
if (typeof activeCalories === "number" && activeCalories >= 0) {
out.push({
type: "ACTIVE_ENERGY_BURNED",
value: round2(activeCalories),
unit: "kcal",
measuredAt,
fieldTag: "active_energy",
});
}
return out;
}
/** Field→Measurement reference table (mirror of the mappers above). */
export const POLAR_FIELD_MAP: Record<
string,
{ type: string; unit: string; note?: string }
> = {
"nightly_recharge.nightly_recharge_status": {
type: "RECOVERY_SCORE",
unit: "score",
note: "1-6 band rescaled to 0-100; feeds recovery ladder below WHOOP",
},
"nightly_recharge.hrv_avg": { type: "HRV_RMSSD", unit: "ms" },
"nightly_recharge.heart_rate_avg": {
type: "RESTING_HEART_RATE",
unit: "bpm",
},
"nightly_recharge.breathing_rate_avg": {
type: "RESPIRATORY_RATE",
unit: "breaths/min",
},
"sleep.{light,deep,rem}_sleep": {
type: "SLEEP_DURATION",
unit: "minutes",
note: "s->min, one row per stage (light->CORE, deep->DEEP, rem->REM)",
},
"sleep.sleep_score": { type: "SLEEP_PERFORMANCE", unit: "%" },
"activity.active-steps": { type: "ACTIVITY_STEPS", unit: "steps" },
"activity.active-calories": {
type: "ACTIVE_ENERGY_BURNED",
unit: "kcal",
note: "active portion only (NOT total calories incl. BMR)",
},
};
+40
View File
@@ -0,0 +1,40 @@
/**
* v1.17.0 (F4) — per-user Polar token accessors.
*
* Unlike WHOOP (per-user BYO client id/secret), Polar uses a single shared
* OAuth app whose client id/secret come from env (`getPolarCredentials`). What
* is per-user is the granted token + Polar member id, stored encrypted on
* `User`. Polar access tokens do not expire and carry no refresh token, so
* there is no refresh path — a revoked grant surfaces as a 401 on the next read.
*/
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
export interface PolarConnection {
accessToken: string;
/** Polar numeric member id (`x_user_id`), needed for every data path. */
polarUserId: string;
}
/**
* Fetch + decrypt the user's stored Polar token. Returns null when the user has
* not connected Polar (no access token) or the member id is missing.
*/
export async function getPolarConnection(
userId: string,
): Promise<PolarConnection | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
polarAccessTokenEncrypted: true,
polarUserIdEncrypted: true,
},
});
if (!user?.polarAccessTokenEncrypted || !user?.polarUserIdEncrypted) {
return null;
}
return {
accessToken: decrypt(user.polarAccessTokenEncrypted),
polarUserId: decrypt(user.polarUserIdEncrypted),
};
}
+110
View File
@@ -0,0 +1,110 @@
/**
* v1.17.0 (F4) — off-response classifier for Polar AccessLink replies.
*
* Mirrors the WHOOP classifier (`src/lib/whoop/response-classifier.ts`): Polar
* signals failure through the HTTP status code, so the classifier is
* status-driven.
*
* - `success` → HTTP 2xx (incl. 204 No Content — Polar returns 204
* when a collection window holds no records).
* - `transient` → retryable: 429 (rate-limited), 5xx upstream, 3xx
* CDN glitch, and an unknown status.
* - `reauth_required` → 401 / 403 — the access token is rejected or the user
* revoked the grant; the user must reconnect.
* - `persistent` → any other 4xx — a malformed request / contract
* mismatch that a retry won't fix.
*/
export type PolarClassification =
| "success"
| "transient"
| "reauth_required"
| "persistent";
export interface ClassifiedPolarResponse {
classification: PolarClassification;
httpStatus: number | undefined;
reason: string;
}
export function classifyPolarResponse(
httpStatus: number,
): ClassifiedPolarResponse {
if (httpStatus >= 200 && httpStatus < 300) {
return { classification: "success", httpStatus, reason: "ok" };
}
if (httpStatus === 429) {
return { classification: "transient", httpStatus, reason: "http_429" };
}
if (httpStatus === 401 || httpStatus === 403) {
return {
classification: "reauth_required",
httpStatus,
reason: `http_${httpStatus}`,
};
}
if (httpStatus >= 400 && httpStatus < 500) {
return {
classification: "persistent",
httpStatus,
reason: `http_${httpStatus}`,
};
}
if (httpStatus >= 500 && httpStatus < 600) {
return {
classification: "transient",
httpStatus,
reason: `http_${httpStatus}`,
};
}
return {
classification: "transient",
httpStatus,
reason: `http_${httpStatus}_unknown`,
};
}
/** Typed error carrying the classification verdict. Mirrors `WhoopApiError`. */
export class PolarApiError extends Error {
readonly classification: PolarClassification;
readonly httpStatus: number | undefined;
readonly reason: string;
readonly verb: string;
readonly upstreamError: string | undefined;
constructor(opts: {
verb: string;
classification: PolarClassification;
httpStatus: number | undefined;
reason: string;
upstreamError?: string;
}) {
const statusLabel =
typeof opts.httpStatus === "number" ? opts.httpStatus : "?";
const errSegment = opts.upstreamError ? ` - ${opts.upstreamError}` : "";
const raw = `Polar ${opts.verb} error: ${statusLabel}${errSegment}`;
super(raw.slice(0, 1024));
this.name = "PolarApiError";
this.verb = opts.verb;
this.classification = opts.classification;
this.httpStatus = opts.httpStatus;
this.reason = opts.reason;
this.upstreamError = opts.upstreamError;
}
}
/**
* Read the classification from any caught error. A non-`PolarApiError` defaults
* to `transient` so a plain `Error` retries rather than disabling the
* integration. Falls back to parsing the status out of the message shape for a
* caller that lost the prototype across a pg-boss retry.
*/
export function classifyPolarError(err: unknown): PolarClassification {
if (err instanceof PolarApiError) return err.classification;
const msg = err instanceof Error ? err.message : String(err);
const m = /Polar\s+\w+\s+error:\s*(\d+)/.exec(msg);
if (!m) return "transient";
const status = Number.parseInt(m[1]!, 10);
if (!Number.isFinite(status)) return "transient";
return classifyPolarResponse(status).classification;
}
+195
View File
@@ -0,0 +1,195 @@
/**
* v1.17.0 (F4) — Polar AccessLink sync.
*
* Pulls Nightly Recharge (→ RECOVERY_SCORE / HRV / RHR / respiratory rate),
* sleep (per-stage durations + sleep score), and daily activity (steps +
* active energy) for one connected user, mapping each into `Measurement` rows
* tagged `source = POLAR`.
*
* Token model: Polar tokens do not expire and have no refresh path, so there is
* no `getValidToken` refresh dance (the WHOOP shape). A revoked grant surfaces
* as a 401 → `reauth_required` on the shared integration ledger (`polar` key),
* exactly like Nightscout's token-rejected path.
*
* Idempotency: each row's `externalId` is `<date>:<fieldTag>` and the write is
* an upsert keyed on `(userId, type, source = POLAR, externalId)`. Polar
* re-scores a night for a short window after the fact, so the `update` branch
* overwrites in place (WHOOP-style re-score), not first-write-wins.
*
* The measurement-write tail (per-row upsert → rollup fold → status-insight
* invalidate) mirrors the shared WHOOP / Nightscout sync tail; it is NOT a new
* write path.
*/
import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { getEvent } from "@/lib/logging/context";
import {
recordSyncFailure,
recordSyncSuccess,
type FailureKind,
} from "@/lib/integrations/status";
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
fetchActivities,
fetchNightlyRecharges,
fetchSleeps,
mapActivity,
mapNightlyRecharge,
mapSleep,
type MappedMeasurement,
} from "./client";
import { getPolarConnection } from "./credentials";
import { PolarApiError, classifyPolarError } from "./response-classifier";
/** Map a Polar error onto the shared integration-ledger failure kind. */
export function classifyPolarFailure(err: unknown): FailureKind {
const c = classifyPolarError(err);
if (c === "reauth_required") return "reauth_required";
if (c === "persistent") return "persistent";
return "transient";
}
/** One mapped reading with its `externalId` resolved. */
export interface PolarMeasurementUpsert {
type: string;
value: number;
unit: string;
measuredAt: Date;
externalId: string;
sleepStage?: "CORE" | "DEEP" | "REM" | null;
}
function toUpsert(
mapped: MappedMeasurement[],
resourcePrefix: string,
): PolarMeasurementUpsert[] {
return mapped.map((m) => ({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
// `<resource>:<date>:<fieldTag>` — stable across re-syncs of the same day.
externalId: `${resourcePrefix}:${m.measuredAt.toISOString().slice(0, 10)}:${m.fieldTag}`,
sleepStage: m.sleepStage ?? null,
}));
}
/**
* Sync one user's Polar data. Returns the count of measurement rows written.
* A user with no Polar connection is a clean no-op (returns 0, touches no
* status row).
*/
export async function syncUserPolar(userId: string): Promise<number> {
const conn = await getPolarConnection(userId);
if (!conn) return 0;
const readings: PolarMeasurementUpsert[] = [];
try {
const [recharges, sleeps, activities] = await Promise.all([
fetchNightlyRecharges(conn.accessToken, conn.polarUserId),
fetchSleeps(conn.accessToken, conn.polarUserId),
fetchActivities(conn.accessToken, conn.polarUserId),
]);
for (const r of recharges) {
readings.push(...toUpsert(mapNightlyRecharge(r), "recharge"));
}
for (const s of sleeps) {
readings.push(...toUpsert(mapSleep(s), "sleep"));
}
for (const a of activities) {
readings.push(...toUpsert(mapActivity(a), "activity"));
}
} catch (err) {
await recordSyncFailure({
userId,
integration: "polar",
kind: classifyPolarFailure(err),
message: err instanceof Error ? err.message : String(err),
errorCode:
err instanceof PolarApiError && err.httpStatus != null
? String(err.httpStatus)
: undefined,
});
throw err;
}
const imported = await upsertPolarMeasurements(userId, readings);
await recordSyncSuccess(userId, "polar");
return imported;
}
/**
* Upsert a batch of mapped Polar readings, then fold the rollup tier +
* invalidate status-insight caches once at the end (mirrors the WHOOP /
* Nightscout sync tail). Idempotent: the `(userId, type, source, externalId)`
* unique key makes a re-post overwrite in place. Best-effort on the rollup fold.
*/
export async function upsertPolarMeasurements(
userId: string,
readings: PolarMeasurementUpsert[],
): Promise<number> {
if (readings.length === 0) return 0;
let imported = 0;
const touched: Array<{ type: MeasurementType; measuredAt: Date }> = [];
for (const r of readings) {
const type = r.type as MeasurementType;
try {
await prisma.measurement.upsert({
where: {
userId_type_source_externalId: {
userId,
type,
source: "POLAR",
externalId: r.externalId,
},
},
create: {
userId,
type,
source: "POLAR",
value: r.value,
unit: r.unit,
measuredAt: r.measuredAt,
externalId: r.externalId,
sleepStage: r.sleepStage ?? null,
},
update: {
value: r.value,
unit: r.unit,
measuredAt: r.measuredAt,
sleepStage: r.sleepStage ?? null,
syncVersion: { increment: 1 },
},
});
touched.push({ type, measuredAt: r.measuredAt });
imported++;
} catch (err) {
getEvent()?.addWarning(`polar: failed to upsert measurement: ${err}`);
}
}
try {
const keys = collapseToTypeDayKeys(touched);
for (const k of keys) {
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
}
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
).catch((err) => {
getEvent()?.addWarning(
`polar: status-insight invalidate failed for ${userId}: ${err}`,
);
});
} catch (err) {
getEvent()?.addWarning(`polar: rollup recompute failed for ${userId}: ${err}`);
}
return imported;
}
+8
View File
@@ -34,6 +34,14 @@ export const integrationKeys = {
nightscout: () => ["nightscout"] as const,
nightscoutStatus: () => ["nightscout", "status"] as const,
// v1.17.0 (F4) — Polar + Oura OAuth integration cards. Self-contained status
// reads (not the consolidated /api/integrations/status envelope), so the
// connect/disconnect mutations invalidate these single keys.
polar: () => ["polar"] as const,
polarStatus: () => ["polar", "status"] as const,
oura: () => ["oura"] as const,
ouraStatus: () => ["oura", "status"] as const,
moodlogStatus: () => ["moodlog-status"] as const,
integrationsStatus: () => ["integrations", "status"] as const,