feat(integrations): add Polar AccessLink OAuth sync

Connect a Polar account over AccessLink v3: an authorization-code OAuth
flow (shared app from POLAR_CLIENT_ID / POLAR_CLIENT_SECRET) backed by a
stateless HMAC-signed state token doubling as an httpOnly cookie for CSRF.
The callback verifies the signature, expiry, provider binding, and the
byte-exact cookie match, then encrypts the access token plus member id onto
the user row.

An hourly poll-cohort job maps Nightly Recharge recovery, sleep stages,
and daily activity to canonical measurements tagged source POLAR, idempotent
on (userId, type, source, externalId). The recovery band feeds RECOVERY_SCORE
through the canonical ladder, which now ranks native readiness above the
computed proxy. Settings gains a connect / disconnect card. Tokens are never
logged or returned; every egress goes through safeFetch.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 20:08:50 +02:00
1 parent e561f8d893
commit 31abd307fd
31 files changed
+2341 -13

No files matched your search

+12
View File
@@ -50,6 +50,18 @@ 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=""
# -----------------------------------------------------------------------------
# APNs -- iOS push (optional, all-or-none)
+9
View File
@@ -77,6 +77,15 @@ 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 OAuth (shared app from env). The pair
# together enables the connect button; an unset pair leaves the
# integration disabled. The optional POLAR_REDIRECT_URI overrides the
# derived `${NEXT_PUBLIC_APP_URL}/api/polar/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:-}"
# 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
+21 -1
View File
@@ -4585,7 +4585,27 @@
"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."
}
},
"admin": {
"title": "Administration",
+21 -1
View File
@@ -4585,7 +4585,27 @@
"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."
}
},
"admin": {
"title": "Administration",
+21 -1
View File
@@ -4585,7 +4585,27 @@
"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."
}
},
"admin": {
"title": "Administration",
+21 -1
View File
@@ -4585,7 +4585,27 @@
"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."
}
},
"admin": {
"title": "Administration",
+21 -1
View File
@@ -4585,7 +4585,27 @@
"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."
}
},
"admin": {
"title": "Administration",
+21 -1
View File
@@ -4585,7 +4585,27 @@
"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."
}
},
"admin": {
"title": "Administracja",
+16
View File
@@ -54,6 +54,22 @@
}
]
},
{
"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": "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.",
+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 → 5 pills total (Withings, WHOOP,
// Fitbit / Google Health, Polar, Nightscout). The moodLog integration was
// removed; Polar (F4) was added in v1.17.0.
expect(count(html, 'data-testid="integration-status-pill"')).toBe(5);
// 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,7 @@ import { toast } from "sonner";
import { FitbitCard } from "@/components/settings/integrations/fitbit-card";
import { NightscoutCard } from "@/components/settings/integrations/nightscout-card";
import { PolarCard } from "@/components/settings/integrations/polar-card";
import {
pickStatus,
useIntegrationStatuses,
@@ -90,6 +91,58 @@ export function IntegrationsSection() {
}
}, [withingsOauthOutcome, router, queryClient, t]);
// v1.17.0 (F4) — generic OAuth-callback toast for the env-based providers.
// The Polar callback redirects back with `?polar=connected` or
// `?polar=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"; kind: "connected" } | { provider: "polar"; kind: "error"; reason: string } | null
>(() => {
if (typeof window === "undefined") return null;
const params = new URLSearchParams(window.location.search);
for (const provider of ["polar"] 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 = queryKeys.polar();
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 +178,7 @@ export function IntegrationsSection() {
<WithingsCard viewModel={withingsViewModel} />
<WhoopCard viewModel={whoopViewModel} />
<FitbitCard viewModel={fitbitViewModel} />
<PolarCard 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) — 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 {
+21
View File
@@ -263,6 +263,7 @@ import {
NightscoutSyncPayload,
handleNightscoutSync,
} from "./reminder/nightscout-sync";
import { PolarSyncPayload, handlePolarSync } from "./reminder/polar-sync";
import {
handleMedicationInventoryExpire,
handleIntakeAutoSkip,
@@ -401,6 +402,13 @@ 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 OAuth poll sync. Poll-only (one hourly tick re-walks
// every connected user). :13 staggers off the other hourly sync ticks (WHOOP
// :05, Fitbit :08, Nightscout :11) so the polls don't pile up on one boss poll.
// The queue MUST be registered in `allQueues` below or pg-boss never provisions
// it 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
// 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 +630,9 @@ 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 OAuth poll sync. Registered here or pg-boss never
// provisions it and the hourly schedule below silently no-ops.
POLAR_SYNC_QUEUE,
OFFHOST_BACKUP_QUEUE,
RESTORE_DRILL_QUEUE,
HOST_METRIC_QUEUE,
@@ -845,6 +856,8 @@ 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) OAuth poll.
[POLAR_SYNC_QUEUE, POLAR_SYNC_CRON],
[OFFHOST_BACKUP_QUEUE, OFFHOST_BACKUP_CRON],
[RESTORE_DRILL_QUEUE, RESTORE_DRILL_CRON],
[HOST_METRIC_QUEUE, HOST_METRIC_CRON],
@@ -1045,6 +1058,14 @@ export async function startReminderWorker() {
{ localConcurrency: 1 },
handleNightscoutSync,
);
// v1.17.0 (F4) — Polar 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<GeneralStatusPayload>(
GENERAL_STATUS_QUEUE,
{ localConcurrency: 1 },
+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));
}
+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;
}
+6
View File
@@ -34,6 +34,12 @@ export const integrationKeys = {
nightscout: () => ["nightscout"] as const,
nightscoutStatus: () => ["nightscout", "status"] as const,
// v1.17.0 (F4) — Polar OAuth integration card. Self-contained status read
// (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,
moodlogStatus: () => ["moodlog-status"] as const,
integrationsStatus: () => ["integrations", "status"] as const,