feat(integrations): per-user Polar and Oura OAuth credentials

Polar and Oura resolved their OAuth client id/secret from the server
environment only, forcing every user on a deploy to share one app. Both
now read per-user BYO keys first, falling back to the shared env app when
unset, mirroring the WHOOP and Fitbit model.

- Add DB-first-then-env resolvers in src/lib/{polar,oura}/credentials.ts,
  encrypting the client id/secret at rest on User.
- Repoint the connect, callback, status routes and the Oura refresh path
  to the user-scoped resolver.
- Add /api/{polar,oura}/credentials (GET/PUT/DELETE) with Zod-validated
  input and userId narrowed from the session.
- Render an optional 'your OAuth app credentials' form on the Polar and
  Oura cards; the status read exposes hasOwnCredentials for the saved
  placeholder. Existing env-only deploys keep working unchanged.
- Cover both resolvers (DB hit, env fallback, no creds) and the card form
  with unit tests; add the new strings across all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 23:19:47 +02:00
1 parent 111fa7a323
commit 38c7604ce4
28 files changed
+856 -76

No files matched your search

+16 -2
View File
@@ -4603,7 +4603,14 @@
"polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen und Tagesaktivität zu synchronisieren.",
"polarViewData": "Schlaf & Erholung ansehen",
"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.",
"polarCredentials": "API-Zugangsdaten",
"polarCredentialsHelp": "Optional: Registriere deine eigene Polar-AccessLink-App und füge hier Client ID und Client Secret ein. Leer lassen, um die gemeinsame Polar-App des Servers zu nutzen.",
"polarClientId": "Client ID",
"polarClientSecret": "Client Secret",
"polarCredentialsSaved": "Zugangsdaten gespeichert",
"polarCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen",
"polarSaveCredentials": "Speichern",
"polarUnavailable": "Polar ist auf diesem Server nicht konfiguriert. Füge oben deine eigenen Polar-OAuth-Zugangsdaten hinzu oder bitte deinen Administrator, die gemeinsame App zu konfigurieren.",
"polarConnect": "Polar verbinden",
"polarDisconnect": "Trennen",
"polarDisconnectTitle": "Polar trennen?",
@@ -4624,7 +4631,14 @@
"ouraDescription": "Verbinde dein Oura-Konto, um Readiness, Schlafphasen, HRV und Tagesaktivität zu synchronisieren.",
"ouraViewData": "Schlaf & Erholung ansehen",
"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.",
"ouraCredentials": "API-Zugangsdaten",
"ouraCredentialsHelp": "Optional: Registriere deine eigene Oura-App und füge hier Client ID und Client Secret ein. Leer lassen, um die gemeinsame Oura-App des Servers zu nutzen.",
"ouraClientId": "Client ID",
"ouraClientSecret": "Client Secret",
"ouraCredentialsSaved": "Zugangsdaten gespeichert",
"ouraCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen",
"ouraSaveCredentials": "Speichern",
"ouraUnavailable": "Oura ist auf diesem Server nicht konfiguriert. Füge oben deine eigenen Oura-OAuth-Zugangsdaten hinzu oder bitte deinen Administrator, die gemeinsame App zu konfigurieren.",
"ouraConnect": "Oura verbinden",
"ouraDisconnect": "Trennen",
"ouraDisconnectTitle": "Oura trennen?",
+16 -2
View File
@@ -4603,7 +4603,14 @@
"polarDescription": "Connect your Polar account to sync Nightly Recharge recovery, sleep stages, and daily activity.",
"polarViewData": "View your sleep & recovery",
"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.",
"polarCredentials": "API Credentials",
"polarCredentialsHelp": "Optional: register your own Polar AccessLink app and paste its Client ID and Client Secret here. Leave blank to use the server's shared Polar app.",
"polarClientId": "Client ID",
"polarClientSecret": "Client Secret",
"polarCredentialsSaved": "Credentials saved",
"polarCredentialsSavedPlaceholder": "Saved — enter new to replace",
"polarSaveCredentials": "Save",
"polarUnavailable": "Polar is not configured on this server. Add your own Polar OAuth credentials above, or ask your administrator to configure the shared app.",
"polarConnect": "Connect Polar",
"polarDisconnect": "Disconnect",
"polarDisconnectTitle": "Disconnect Polar?",
@@ -4624,7 +4631,14 @@
"ouraDescription": "Connect your Oura account to sync readiness, sleep stages, HRV, and daily activity.",
"ouraViewData": "View your sleep & recovery",
"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.",
"ouraCredentials": "API Credentials",
"ouraCredentialsHelp": "Optional: register your own Oura app and paste its Client ID and Client Secret here. Leave blank to use the server's shared Oura app.",
"ouraClientId": "Client ID",
"ouraClientSecret": "Client Secret",
"ouraCredentialsSaved": "Credentials saved",
"ouraCredentialsSavedPlaceholder": "Saved — enter new to replace",
"ouraSaveCredentials": "Save",
"ouraUnavailable": "Oura is not configured on this server. Add your own Oura OAuth credentials above, or ask your administrator to configure the shared app.",
"ouraConnect": "Connect Oura",
"ouraDisconnect": "Disconnect",
"ouraDisconnectTitle": "Disconnect Oura?",
+16 -2
View File
@@ -4603,7 +4603,14 @@
"polarDescription": "Conecta tu cuenta de Polar para sincronizar la recuperación Nightly Recharge, las fases de sueño y la actividad diaria.",
"polarViewData": "Ver tu sueño y recuperación",
"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.",
"polarCredentials": "Credenciales de API",
"polarCredentialsHelp": "Opcional: registra tu propia app de Polar AccessLink y pega aquí su Client ID y Client Secret. Déjalo en blanco para usar la app compartida del servidor.",
"polarClientId": "Client ID",
"polarClientSecret": "Client Secret",
"polarCredentialsSaved": "Credenciales guardadas",
"polarCredentialsSavedPlaceholder": "Guardado — introduce nuevas para reemplazar",
"polarSaveCredentials": "Guardar",
"polarUnavailable": "Polar no está configurado en este servidor. Añade arriba tus propias credenciales OAuth de Polar, o pide a tu administrador que configure la app compartida.",
"polarConnect": "Conectar Polar",
"polarDisconnect": "Desconectar",
"polarDisconnectTitle": "¿Desconectar Polar?",
@@ -4624,7 +4631,14 @@
"ouraDescription": "Conecta tu cuenta de Oura para sincronizar readiness, fases de sueño, VFC y actividad diaria.",
"ouraViewData": "Ver tu sueño y recuperación",
"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.",
"ouraCredentials": "Credenciales de API",
"ouraCredentialsHelp": "Opcional: registra tu propia app de Oura y pega aquí su Client ID y Client Secret. Déjalo en blanco para usar la app compartida del servidor.",
"ouraClientId": "Client ID",
"ouraClientSecret": "Client Secret",
"ouraCredentialsSaved": "Credenciales guardadas",
"ouraCredentialsSavedPlaceholder": "Guardado — introduce nuevas para reemplazar",
"ouraSaveCredentials": "Guardar",
"ouraUnavailable": "Oura no está configurado en este servidor. Añade arriba tus propias credenciales OAuth de Oura, o pide a tu administrador que configure la app compartida.",
"ouraConnect": "Conectar Oura",
"ouraDisconnect": "Desconectar",
"ouraDisconnectTitle": "¿Desconectar Oura?",
+16 -2
View File
@@ -4603,7 +4603,14 @@
"polarDescription": "Connectez votre compte Polar pour synchroniser la récupération Nightly Recharge, les phases de sommeil et l'activité quotidienne.",
"polarViewData": "Voir votre sommeil et récupération",
"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.",
"polarCredentials": "Identifiants API",
"polarCredentialsHelp": "Facultatif : enregistrez votre propre application Polar AccessLink et collez ici son Client ID et son Client Secret. Laissez vide pour utiliser l'application Polar partagée du serveur.",
"polarClientId": "Client ID",
"polarClientSecret": "Client Secret",
"polarCredentialsSaved": "Identifiants enregistrés",
"polarCredentialsSavedPlaceholder": "Enregistré — saisir de nouveaux pour remplacer",
"polarSaveCredentials": "Enregistrer",
"polarUnavailable": "Polar n'est pas configuré sur ce serveur. Ajoutez vos propres identifiants OAuth Polar ci-dessus, ou demandez à votre administrateur de configurer l'application partagée.",
"polarConnect": "Connecter Polar",
"polarDisconnect": "Déconnecter",
"polarDisconnectTitle": "Déconnecter Polar ?",
@@ -4624,7 +4631,14 @@
"ouraDescription": "Connectez votre compte Oura pour synchroniser le readiness, les phases de sommeil, la VFC et l'activité quotidienne.",
"ouraViewData": "Voir votre sommeil et récupération",
"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.",
"ouraCredentials": "Identifiants API",
"ouraCredentialsHelp": "Facultatif : enregistrez votre propre application Oura et collez ici son Client ID et son Client Secret. Laissez vide pour utiliser l'application Oura partagée du serveur.",
"ouraClientId": "Client ID",
"ouraClientSecret": "Client Secret",
"ouraCredentialsSaved": "Identifiants enregistrés",
"ouraCredentialsSavedPlaceholder": "Enregistré — saisir de nouveaux pour remplacer",
"ouraSaveCredentials": "Enregistrer",
"ouraUnavailable": "Oura n'est pas configuré sur ce serveur. Ajoutez vos propres identifiants OAuth Oura ci-dessus, ou demandez à votre administrateur de configurer l'application partagée.",
"ouraConnect": "Connecter Oura",
"ouraDisconnect": "Déconnecter",
"ouraDisconnectTitle": "Déconnecter Oura ?",
+16 -2
View File
@@ -4603,7 +4603,14 @@
"polarDescription": "Collega il tuo account Polar per sincronizzare il recupero Nightly Recharge, le fasi del sonno e l'attività giornaliera.",
"polarViewData": "Vedi sonno e recupero",
"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.",
"polarCredentials": "Credenziali API",
"polarCredentialsHelp": "Facoltativo: registra la tua app Polar AccessLink e incolla qui il suo Client ID e Client Secret. Lascia vuoto per usare l'app Polar condivisa del server.",
"polarClientId": "Client ID",
"polarClientSecret": "Client Secret",
"polarCredentialsSaved": "Credenziali salvate",
"polarCredentialsSavedPlaceholder": "Salvato — inserisci nuove per sostituire",
"polarSaveCredentials": "Salva",
"polarUnavailable": "Polar non è configurato su questo server. Aggiungi sopra le tue credenziali OAuth di Polar oppure chiedi al tuo amministratore di configurare l'app condivisa.",
"polarConnect": "Collega Polar",
"polarDisconnect": "Disconnetti",
"polarDisconnectTitle": "Disconnettere Polar?",
@@ -4624,7 +4631,14 @@
"ouraDescription": "Collega il tuo account Oura per sincronizzare readiness, fasi del sonno, HRV e attività giornaliera.",
"ouraViewData": "Vedi sonno e recupero",
"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.",
"ouraCredentials": "Credenziali API",
"ouraCredentialsHelp": "Facoltativo: registra la tua app Oura e incolla qui il suo Client ID e Client Secret. Lascia vuoto per usare l'app Oura condivisa del server.",
"ouraClientId": "Client ID",
"ouraClientSecret": "Client Secret",
"ouraCredentialsSaved": "Credenziali salvate",
"ouraCredentialsSavedPlaceholder": "Salvato — inserisci nuove per sostituire",
"ouraSaveCredentials": "Salva",
"ouraUnavailable": "Oura non è configurato su questo server. Aggiungi sopra le tue credenziali OAuth di Oura oppure chiedi al tuo amministratore di configurare l'app condivisa.",
"ouraConnect": "Collega Oura",
"ouraDisconnect": "Disconnetti",
"ouraDisconnectTitle": "Disconnettere Oura?",
+16 -2
View File
@@ -4603,7 +4603,14 @@
"polarDescription": "Połącz konto Polar, aby synchronizować regenerację Nightly Recharge, fazy snu i aktywność dzienną.",
"polarViewData": "Zobacz sen i regenerację",
"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.",
"polarCredentials": "Dane API",
"polarCredentialsHelp": "Opcjonalnie: zarejestruj własną aplikację Polar AccessLink i wklej tutaj jej Client ID oraz Client Secret. Pozostaw puste, aby użyć współdzielonej aplikacji Polar serwera.",
"polarClientId": "Client ID",
"polarClientSecret": "Client Secret",
"polarCredentialsSaved": "Dane zapisane",
"polarCredentialsSavedPlaceholder": "Zapisano — wpisz nowe, aby zastąpić",
"polarSaveCredentials": "Zapisz",
"polarUnavailable": "Polar nie jest skonfigurowany na tym serwerze. Dodaj powyżej własne dane OAuth Polar lub poproś administratora o skonfigurowanie współdzielonej aplikacji.",
"polarConnect": "Połącz Polar",
"polarDisconnect": "Rozłącz",
"polarDisconnectTitle": "Rozłączyć Polar?",
@@ -4624,7 +4631,14 @@
"ouraDescription": "Połącz konto Oura, aby synchronizować readiness, fazy snu, HRV i aktywność dzienną.",
"ouraViewData": "Zobacz sen i regenerację",
"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.",
"ouraCredentials": "Dane API",
"ouraCredentialsHelp": "Opcjonalnie: zarejestruj własną aplikację Oura i wklej tutaj jej Client ID oraz Client Secret. Pozostaw puste, aby użyć współdzielonej aplikacji Oura serwera.",
"ouraClientId": "Client ID",
"ouraClientSecret": "Client Secret",
"ouraCredentialsSaved": "Dane zapisane",
"ouraCredentialsSavedPlaceholder": "Zapisano — wpisz nowe, aby zastąpić",
"ouraSaveCredentials": "Zapisz",
"ouraUnavailable": "Oura nie jest skonfigurowana na tym serwerze. Dodaj powyżej własne dane OAuth Oura lub poproś administratora o skonfigurowanie współdzielonej aplikacji.",
"ouraConnect": "Połącz Oura",
"ouraDisconnect": "Rozłącz",
"ouraDisconnectTitle": "Rozłączyć Oura?",
+3 -2
View File
@@ -4,7 +4,8 @@ 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 { exchangeCode } from "@/lib/oura/client";
import { getOuraClientCredentials } from "@/lib/oura/credentials";
import {
oauthStateCookieName,
stateMatchesCookie,
@@ -65,7 +66,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!code) return ERR("nocode");
try {
const creds = getOuraCredentials();
const creds = await getOuraClientCredentials(userId);
if (!creds) return ERR("nocreds");
const tokens = await exchangeCode(code, creds);
+3 -2
View File
@@ -3,7 +3,8 @@ 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 { getAuthorizationUrl } from "@/lib/oura/client";
import { getOuraClientCredentials } from "@/lib/oura/credentials";
import {
OAUTH_STATE_TTL_MS,
mintSignedState,
@@ -40,7 +41,7 @@ export const GET = apiHandler(async () => {
);
}
const creds = getOuraCredentials();
const creds = await getOuraClientCredentials(user.id);
if (!creds) {
return apiError("Oura integration is not configured on this server.", 400);
}
+81
View File
@@ -0,0 +1,81 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import {
storeOuraClientCredentials,
clearOuraClientCredentials,
} from "@/lib/oura/credentials";
import { ouraCredentialsSchema } from "@/lib/validations/oura";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
/**
* v1.17.1 — per-user Oura BYO-key credentials.
*
* Whether the user has their own Oura client id/secret stored. The
* connect/callback/status routes resolve credentials DB-first then env, so an
* unset pair simply falls back to the shared env app.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "oura.credentials.get" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
ouraClientIdEncrypted: true,
ouraClientSecretEncrypted: true,
},
});
return apiSuccess({
hasCredentials:
!!dbUser?.ouraClientIdEncrypted && !!dbUser?.ouraClientSecretEncrypted,
});
});
/**
* Save Oura OAuth client credentials (encrypted at rest).
*/
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "oura.credentials.update" } });
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const result = z.safeParse(ouraCredentialsSchema, body);
if (!result.success) {
return apiError("Client ID and Client Secret are required", 422);
}
await storeOuraClientCredentials(
user.id,
result.data.clientId,
result.data.clientSecret,
);
return apiSuccess({ updated: true });
});
/**
* Delete Oura credentials and the active connection.
*/
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "oura.credentials.delete" } });
await prisma.user.update({
where: { id: user.id },
data: {
ouraAccessTokenEncrypted: null,
ouraRefreshTokenEncrypted: null,
},
});
await clearOuraClientCredentials(user.id);
return apiSuccess({ deleted: true });
});
@@ -15,8 +15,11 @@ 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/oura/credentials", () => ({
getOuraClientCredentials: vi.fn(async () => ({
clientId: "c",
clientSecret: "s",
})),
}));
vi.mock("@/lib/integrations/status", () => ({
@@ -32,10 +35,10 @@ vi.mock("@/lib/integrations/status", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getOuraCredentials } from "@/lib/oura/client";
import { getOuraClientCredentials } from "@/lib/oura/credentials";
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const credsMock = getOuraCredentials as ReturnType<typeof vi.fn>;
const credsMock = getOuraClientCredentials as ReturnType<typeof vi.fn>;
type Body = {
connected: boolean;
@@ -49,7 +52,7 @@ const call = () =>
describe("GET /api/oura/status", () => {
beforeEach(() => {
vi.clearAllMocks();
credsMock.mockReturnValue({ clientId: "c", clientSecret: "s" });
credsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" });
});
it("reports not-connected but available", async () => {
+21 -6
View File
@@ -2,15 +2,18 @@ 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 { getOuraClientCredentials } from "@/lib/oura/credentials";
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.
* `available` reports whether usable OAuth credentials resolve for this user —
* per-user BYO keys (v1.17.1) first, then the shared env app.
* `hasOwnCredentials` reports whether the user has stored their own BYO pair.
* `connected` is whether a token is stored. The ledger snapshot comes off the
* shared `oura` IntegrationKey. The access / refresh tokens are NEVER returned.
* shared `oura` IntegrationKey. The access / refresh tokens + client secret are
* NEVER returned.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
@@ -18,14 +21,25 @@ export const GET = apiHandler(async () => {
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { ouraAccessTokenEncrypted: true },
select: {
ouraAccessTokenEncrypted: true,
ouraClientIdEncrypted: true,
ouraClientSecretEncrypted: true,
},
});
const available = !!getOuraCredentials();
const available = !!(await getOuraClientCredentials(user.id));
const connected = !!dbUser?.ouraAccessTokenEncrypted;
const hasOwnCredentials =
!!dbUser?.ouraClientIdEncrypted && !!dbUser?.ouraClientSecretEncrypted;
if (!connected) {
return apiSuccess({ connected: false, configured: false, available });
return apiSuccess({
connected: false,
configured: false,
available,
hasOwnCredentials,
});
}
const status = await getIntegrationStatus(user.id, "oura");
@@ -34,6 +48,7 @@ export const GET = apiHandler(async () => {
connected: true,
configured: true,
available,
hasOwnCredentials,
state: status.state,
lastSuccessAt: status.lastSuccessAt,
lastAttemptAt: status.lastAttemptAt,
+3 -6
View File
@@ -4,11 +4,8 @@ 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 { exchangeCode, registerUser } from "@/lib/polar/client";
import { getPolarClientCredentials } from "@/lib/polar/credentials";
import {
oauthStateCookieName,
stateMatchesCookie,
@@ -75,7 +72,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!code) return ERR("nocode");
try {
const creds = getPolarCredentials();
const creds = await getPolarClientCredentials(userId);
if (!creds) return ERR("nocreds");
const tokens = await exchangeCode(code, creds);
+3 -2
View File
@@ -3,7 +3,8 @@ 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 { getAuthorizationUrl } from "@/lib/polar/client";
import { getPolarClientCredentials } from "@/lib/polar/credentials";
import {
OAUTH_STATE_TTL_MS,
mintSignedState,
@@ -44,7 +45,7 @@ export const GET = apiHandler(async () => {
);
}
const creds = getPolarCredentials();
const creds = await getPolarClientCredentials(user.id);
if (!creds) {
return apiError(
"Polar integration is not configured on this server.",
+81
View File
@@ -0,0 +1,81 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import {
storePolarClientCredentials,
clearPolarClientCredentials,
} from "@/lib/polar/credentials";
import { polarCredentialsSchema } from "@/lib/validations/polar";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
/**
* v1.17.1 — per-user Polar BYO-key credentials.
*
* Whether the user has their own Polar AccessLink client id/secret stored. The
* connect/callback/status routes resolve credentials DB-first then env, so an
* unset pair simply falls back to the shared env app.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "polar.credentials.get" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
polarClientIdEncrypted: true,
polarClientSecretEncrypted: true,
},
});
return apiSuccess({
hasCredentials:
!!dbUser?.polarClientIdEncrypted && !!dbUser?.polarClientSecretEncrypted,
});
});
/**
* Save Polar OAuth client credentials (encrypted at rest).
*/
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "polar.credentials.update" } });
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const result = z.safeParse(polarCredentialsSchema, body);
if (!result.success) {
return apiError("Client ID and Client Secret are required", 422);
}
await storePolarClientCredentials(
user.id,
result.data.clientId,
result.data.clientSecret,
);
return apiSuccess({ updated: true });
});
/**
* Delete Polar credentials and the active connection.
*/
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "polar.credentials.delete" } });
await prisma.user.update({
where: { id: user.id },
data: {
polarAccessTokenEncrypted: null,
polarUserIdEncrypted: null,
},
});
await clearPolarClientCredentials(user.id);
return apiSuccess({ deleted: true });
});
@@ -15,8 +15,11 @@ 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/polar/credentials", () => ({
getPolarClientCredentials: vi.fn(async () => ({
clientId: "c",
clientSecret: "s",
})),
}));
vi.mock("@/lib/integrations/status", () => ({
@@ -32,10 +35,10 @@ vi.mock("@/lib/integrations/status", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getPolarCredentials } from "@/lib/polar/client";
import { getPolarClientCredentials } from "@/lib/polar/credentials";
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const credsMock = getPolarCredentials as ReturnType<typeof vi.fn>;
const credsMock = getPolarClientCredentials as ReturnType<typeof vi.fn>;
type Body = {
connected: boolean;
@@ -49,7 +52,7 @@ const call = () =>
describe("GET /api/polar/status", () => {
beforeEach(() => {
vi.clearAllMocks();
credsMock.mockReturnValue({ clientId: "c", clientSecret: "s" });
credsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" });
});
it("reports not-connected but available when env is set and no token stored", async () => {
@@ -60,7 +63,7 @@ describe("GET /api/polar/status", () => {
});
it("reports available=false when the server has no Polar app configured", async () => {
credsMock.mockReturnValue(null);
credsMock.mockResolvedValue(null);
userFind.mockResolvedValue(null);
const res = await call();
expect(res.available).toBe(false);
+23 -9
View File
@@ -2,17 +2,19 @@ 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 { getPolarClientCredentials } from "@/lib/polar/credentials";
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.
* `available` reports whether usable OAuth credentials resolve for this user —
* per-user BYO keys (v1.17.1) first, then the shared env app — so the card can
* grey out the connect button when neither is set. `hasOwnCredentials` reports
* whether the user has stored their own BYO pair (drives the saved-placeholder
* UI). `connected` is whether a token is stored. The ledger snapshot (state /
* lastSuccessAt / lastError) comes off the shared `polar` IntegrationKey. The
* access token, member id, and client secret are NEVER returned.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
@@ -20,14 +22,25 @@ export const GET = apiHandler(async () => {
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { polarAccessTokenEncrypted: true },
select: {
polarAccessTokenEncrypted: true,
polarClientIdEncrypted: true,
polarClientSecretEncrypted: true,
},
});
const available = !!getPolarCredentials();
const available = !!(await getPolarClientCredentials(user.id));
const connected = !!dbUser?.polarAccessTokenEncrypted;
const hasOwnCredentials =
!!dbUser?.polarClientIdEncrypted && !!dbUser?.polarClientSecretEncrypted;
if (!connected) {
return apiSuccess({ connected: false, configured: false, available });
return apiSuccess({
connected: false,
configured: false,
available,
hasOwnCredentials,
});
}
const status = await getIntegrationStatus(user.id, "polar");
@@ -36,6 +49,7 @@ export const GET = apiHandler(async () => {
connected: true,
configured: true,
available,
hasOwnCredentials,
state: status.state,
lastSuccessAt: status.lastSuccessAt,
lastAttemptAt: status.lastAttemptAt,
@@ -23,7 +23,7 @@ vi.mock("@tanstack/react-query", () => ({
import { I18nProvider } from "@/lib/i18n/context";
import { OAuthProviderCard } from "../oauth-provider-card";
function render() {
function render({ credentials = false }: { credentials?: boolean } = {}) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<OAuthProviderCard
@@ -32,6 +32,7 @@ function render() {
i18nPrefix="settings.polar"
icon={Watch}
dataHref="/insights/sleep"
credentials={credentials}
/>
</I18nProvider>,
);
@@ -86,3 +87,38 @@ describe("OAuthProviderCard — parked + test + data-link parity", () => {
expect(html).toContain('data-testid="polar-connect"');
});
});
describe("OAuthProviderCard — per-user BYO credentials form (v1.17.1)", () => {
it("renders the credentials form only when the `credentials` prop is set", () => {
statusPayload = {
connected: false,
configured: false,
available: true,
hasOwnCredentials: false,
};
// Opt-in: the BYO client-id/secret form + save button appear.
const withForm = render({ credentials: true });
expect(withForm).toContain('data-testid="polar-credentials"');
expect(withForm).toContain('id="polar-clientid"');
expect(withForm).toContain('id="polar-secret"');
// Default: no credential inputs (env-only behaviour preserved).
const withoutForm = render();
expect(withoutForm).not.toContain('data-testid="polar-credentials"');
expect(withoutForm).not.toContain('id="polar-clientid"');
});
it("shows the saved-placeholder once the user has stored their own pair", () => {
statusPayload = {
connected: true,
configured: true,
available: true,
hasOwnCredentials: true,
state: "connected",
lastSuccessAt: null,
lastError: null,
};
const html = render({ credentials: true });
expect(html).toContain("Saved — enter new to replace");
});
});
@@ -1,16 +1,17 @@
"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.
// v1.17.0 (F4) — shared OAuth integration card for Polar / Oura.
// v1.17.1 — these are now per-user BYO-key integrations (like WHOOP / Fitbit):
// the card renders an optional "your OAuth app credentials" form (driven by the
// `credentials` prop) above the connect button. Credentials resolve DB-first
// then env on the server, so a user who pastes their own client id/secret uses
// their own app while existing env-configured deploys keep working unchanged.
// Mirrors the Nightscout card's self-contained status pattern.
import { useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowRight, Link2, Unlink, type LucideIcon } from "lucide-react";
import { ArrowRight, Link2, Loader2, Save, Unlink, type LucideIcon } from "lucide-react";
import {
AlertDialog,
@@ -24,13 +25,16 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
IntegrationStatusPill,
type IntegrationPillState,
} from "@/components/settings/integration-status-pill";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
@@ -38,6 +42,8 @@ export interface OAuthProviderStatus {
connected: boolean;
configured: boolean;
available: boolean;
/** Whether the user has stored their own BYO client id/secret pair. */
hasOwnCredentials?: boolean;
state?:
| "connected"
| "error_transient"
@@ -74,6 +80,9 @@ export interface OAuthProviderCardProps {
icon: LucideIcon;
/** Where this provider's synced data surfaces (e.g. `/insights/sleep`). */
dataHref: string;
/** When set, render a per-user BYO OAuth-credentials form above the connect
* button. The endpoint is the PUT target (e.g. `/api/polar/credentials`). */
credentials?: boolean;
enabled?: boolean;
}
@@ -83,11 +92,19 @@ export function OAuthProviderCard({
i18nPrefix,
icon,
dataHref,
credentials = false,
enabled = true,
}: OAuthProviderCardProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [msg, setMsg] = useState<string | null>(null);
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [credsSaving, setCredsSaving] = useState(false);
const [credsMsg, setCredsMsg] = useState<string | null>(null);
const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>(
null,
);
const { data: status } = useQuery({
queryKey: statusQueryKey,
@@ -96,6 +113,42 @@ export function OAuthProviderCard({
refetchOnWindowFocus: true,
});
async function handleSaveCredentials(e: React.FormEvent) {
e.preventDefault();
setCredsSaving(true);
setCredsMsg(null);
setCredsMsgType(null);
try {
const res = await apiFetchRaw(`/api/${provider}/credentials`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: clientId.trim(),
clientSecret: clientSecret.trim(),
}),
});
if (res.ok) {
setCredsMsg(t(`${i18nPrefix}CredentialsSaved`));
setCredsMsgType("success");
setClientId("");
setClientSecret("");
queryClient.invalidateQueries({ queryKey: statusQueryKey });
} else {
try {
const json = await res.json();
setCredsMsg(json.error || t("settings.savingError"));
} catch {
setCredsMsg(t("settings.savingError"));
}
setCredsMsgType("error");
}
} catch {
setCredsMsg(t("common.networkError"));
setCredsMsgType("error");
}
setCredsSaving(false);
}
const disconnect = useMutation({
mutationFn: async () => {
await apiPost(`/api/${provider}/disconnect`);
@@ -182,6 +235,89 @@ export function OAuthProviderCard({
{t(`${i18nPrefix}Help`)}
</p>
{credentials && (
<div className="space-y-3" data-testid={`${provider}-credentials`}>
<h3 className="text-sm font-semibold">
{t(`${i18nPrefix}Credentials`)}
</h3>
<p className="text-muted-foreground text-xs">
{t(`${i18nPrefix}CredentialsHelp`)}
</p>
<form onSubmit={handleSaveCredentials} className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`${provider}-clientid`}>
{t(`${i18nPrefix}ClientId`)}
</Label>
<Input
id={`${provider}-clientid`}
value={clientId}
onChange={(e) => setClientId(e.target.value)}
placeholder={
status?.hasOwnCredentials
? t(`${i18nPrefix}CredentialsSavedPlaceholder`)
: t(`${i18nPrefix}ClientId`)
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`${provider}-secret`}>
{t(`${i18nPrefix}ClientSecret`)}
</Label>
<PasswordInput
id={`${provider}-secret`}
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
placeholder={
status?.hasOwnCredentials
? t(`${i18nPrefix}CredentialsSavedPlaceholder`)
: t(`${i18nPrefix}ClientSecret`)
}
maxLength={200}
autoComplete="off"
inputMode="text"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="done"
/>
</div>
</div>
<div className="flex justify-end">
<Button
type="submit"
variant="outline"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={
credsSaving || !clientId.trim() || !clientSecret.trim()
}
>
{credsSaving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="h-3.5 w-3.5" />
)}
{t(`${i18nPrefix}SaveCredentials`)}
</Button>
</div>
{credsMsg && (
<p
role="alert"
className={`text-sm ${credsMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{credsMsg}
</p>
)}
</form>
</div>
)}
{serverUnavailable && (
<p
className="text-muted-foreground/80 text-xs"
@@ -1,7 +1,8 @@
"use client";
// v1.17.0 (F4) — Oura Cloud integration card. Thin wrapper over the shared
// env-based OAuth card.
// OAuth card.
// v1.17.1 — per-user BYO OAuth credentials (DB-first then env).
import { CircleDot } from "lucide-react";
@@ -16,6 +17,7 @@ export function OuraCard({ enabled = true }: { enabled?: boolean }) {
i18nPrefix="settings.oura"
icon={CircleDot}
dataHref="/insights/sleep"
credentials
enabled={enabled}
/>
);
@@ -1,7 +1,8 @@
"use client";
// v1.17.0 (F4) — Polar AccessLink integration card. Thin wrapper over the
// shared env-based OAuth card.
// shared OAuth card.
// v1.17.1 — per-user BYO OAuth credentials (DB-first then env).
import { Watch } from "lucide-react";
@@ -16,6 +17,7 @@ export function PolarCard({ enabled = true }: { enabled?: boolean }) {
i18nPrefix="settings.polar"
icon={Watch}
dataHref="/insights/sleep"
credentials
enabled={enabled}
/>
);
@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const findUnique = vi.fn();
const getOuraCredentials = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: (...args: unknown[]) => findUnique(...args) } },
}));
vi.mock("@/lib/crypto", () => ({
decrypt: (cipher: string) => `dec(${cipher})`,
encrypt: (plain: string) => `enc(${plain})`,
}));
// The env fallback lives in ./client; stub it so the precedence is observable.
vi.mock("../client", () => ({
getOuraCredentials: () => getOuraCredentials(),
}));
import { getOuraClientCredentials } from "../credentials";
describe("getOuraClientCredentials — DB-first then env", () => {
beforeEach(() => {
findUnique.mockReset();
getOuraCredentials.mockReset();
});
it("returns the per-user BYO client id/secret when both columns are set", async () => {
findUnique.mockResolvedValue({
ouraClientIdEncrypted: "enc-id",
ouraClientSecretEncrypted: "enc-secret",
});
const creds = await getOuraClientCredentials("user-1");
expect(creds).toEqual({
clientId: "dec(enc-id)",
clientSecret: "dec(enc-secret)",
});
expect(getOuraCredentials).not.toHaveBeenCalled();
expect(findUnique).toHaveBeenCalledWith({
where: { id: "user-1" },
select: {
ouraClientIdEncrypted: true,
ouraClientSecretEncrypted: true,
},
});
});
it("falls back to the shared env app when the user has no BYO pair", async () => {
findUnique.mockResolvedValue({
ouraClientIdEncrypted: null,
ouraClientSecretEncrypted: null,
});
getOuraCredentials.mockReturnValue({
clientId: "env-id",
clientSecret: "env-secret",
});
const creds = await getOuraClientCredentials("user-1");
expect(creds).toEqual({ clientId: "env-id", clientSecret: "env-secret" });
expect(getOuraCredentials).toHaveBeenCalledOnce();
});
it("falls back to env when only one half of the BYO pair is present", async () => {
findUnique.mockResolvedValue({
ouraClientIdEncrypted: null,
ouraClientSecretEncrypted: "enc-secret",
});
getOuraCredentials.mockReturnValue({
clientId: "env-id",
clientSecret: "env-secret",
});
const creds = await getOuraClientCredentials("user-1");
expect(creds).toEqual({ clientId: "env-id", clientSecret: "env-secret" });
});
it("returns null when neither the user nor the env is configured", async () => {
findUnique.mockResolvedValue(null);
getOuraCredentials.mockReturnValue(null);
expect(await getOuraClientCredentials("ghost")).toBeNull();
});
});
+4 -2
View File
@@ -31,6 +31,7 @@ const {
vi.mock("../credentials", () => ({
getOuraConnection: getConnMock,
storeOuraTokens: storeTokensMock,
getOuraClientCredentials: getCredsMock,
}));
vi.mock("@/lib/db", () => ({
@@ -57,7 +58,6 @@ vi.mock("../client", async (importOriginal) => {
const actual = await importOriginal<typeof import("../client")>();
return {
...actual,
getOuraCredentials: getCredsMock,
fetchReadiness: fetchReadinessMock,
fetchSleep: fetchSleepMock,
fetchDailyActivity: fetchActivityMock,
@@ -73,7 +73,9 @@ const CONN = { accessToken: "acc", refreshToken: "ref" };
beforeEach(() => {
getConnMock.mockReset();
storeTokensMock.mockReset().mockResolvedValue(undefined);
getCredsMock.mockReset().mockReturnValue({ clientId: "c", clientSecret: "s" });
getCredsMock
.mockReset()
.mockResolvedValue({ clientId: "c", clientSecret: "s" });
fetchReadinessMock.mockReset().mockResolvedValue([]);
fetchSleepMock.mockReset().mockResolvedValue([]);
fetchActivityMock.mockReset().mockResolvedValue([]);
+67 -5
View File
@@ -1,14 +1,76 @@
/**
* v1.17.0 (F4) — per-user Oura token accessors.
* v1.17.1 — per-user BYO client id/secret resolver (DB-first then env).
*
* 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).
* Like WHOOP / Fitbit, Oura is now a per-user BYO-key integration: each user may
* register their own Oura app and store the client id/secret encrypted on
* `User`, with a fall-back to the shared env app for existing single-app
* deploys. Also per-user is the granted access + refresh token. The schema
* carries NO token-expiry column, so the sync layer refreshes REACTIVELY on a
* 401 rather than proactively on an expiry timestamp (persisting both rotated
* tokens).
*/
import { prisma } from "@/lib/db";
import { decrypt, encrypt } from "@/lib/crypto";
import { getOuraCredentials, type OuraCredentials } from "./client";
/**
* Resolve the user's Oura OAuth client id/secret, DB-first then env.
*
* v1.17.1 makes Oura a per-user BYO-key integration (mirroring WHOOP / Fitbit):
* a self-hoster registers their own Oura app and pastes the client id/secret
* into Settings, stored encrypted on `User`. When the user has not configured
* per-user keys we fall back to the shared env app (`OURA_CLIENT_ID` /
* `OURA_CLIENT_SECRET`) so existing single-app deploys keep working. Returns
* null when neither source is configured.
*/
export async function getOuraClientCredentials(
userId: string,
): Promise<OuraCredentials | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
ouraClientIdEncrypted: true,
ouraClientSecretEncrypted: true,
},
});
if (user?.ouraClientIdEncrypted && user?.ouraClientSecretEncrypted) {
return {
clientId: decrypt(user.ouraClientIdEncrypted),
clientSecret: decrypt(user.ouraClientSecretEncrypted),
};
}
// Fall back to the shared env-configured OAuth app.
return getOuraCredentials();
}
/** Persist the user's Oura OAuth client id/secret, encrypted at rest. */
export async function storeOuraClientCredentials(
userId: string,
clientId: string,
clientSecret: string,
): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: {
ouraClientIdEncrypted: encrypt(clientId),
ouraClientSecretEncrypted: encrypt(clientSecret),
},
});
}
/** Clear the user's stored Oura OAuth client id/secret. */
export async function clearOuraClientCredentials(userId: string): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: {
ouraClientIdEncrypted: null,
ouraClientSecretEncrypted: null,
},
});
}
export interface OuraConnection {
accessToken: string;
+6 -3
View File
@@ -35,14 +35,17 @@ import {
fetchDailyActivity,
fetchReadiness,
fetchSleep,
getOuraCredentials,
mapDailyActivity,
mapReadiness,
mapSleep,
refreshAccessToken,
type MappedMeasurement,
} from "./client";
import { getOuraConnection, storeOuraTokens } from "./credentials";
import {
getOuraClientCredentials,
getOuraConnection,
storeOuraTokens,
} from "./credentials";
import { OuraApiError, classifyOuraError } from "./response-classifier";
/** Default lookback window (days) for an incremental sync. Oura finalises a
@@ -118,7 +121,7 @@ async function fetchAll(
// case — rethrow so the caller classifies it.
if (!(err instanceof OuraApiError) || err.httpStatus !== 401) throw err;
const creds = getOuraCredentials();
const creds = await getOuraClientCredentials(userId);
if (!creds) throw err;
const rotated = await refreshAccessToken(refreshToken, creds);
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const findUnique = vi.fn();
const getPolarCredentials = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: (...args: unknown[]) => findUnique(...args) } },
}));
// decrypt() is the fail-closed AES-256-GCM reader; the resolver only needs it
// to round-trip the stored ciphertext, so a `dec(<x>)` shim is sufficient here.
vi.mock("@/lib/crypto", () => ({
decrypt: (cipher: string) => `dec(${cipher})`,
encrypt: (plain: string) => `enc(${plain})`,
}));
// The env fallback lives in ./client; stub it so the precedence is observable.
vi.mock("../client", () => ({
getPolarCredentials: () => getPolarCredentials(),
}));
import { getPolarClientCredentials } from "../credentials";
describe("getPolarClientCredentials — DB-first then env", () => {
beforeEach(() => {
findUnique.mockReset();
getPolarCredentials.mockReset();
});
it("returns the per-user BYO client id/secret when both columns are set", async () => {
findUnique.mockResolvedValue({
polarClientIdEncrypted: "enc-id",
polarClientSecretEncrypted: "enc-secret",
});
const creds = await getPolarClientCredentials("user-1");
expect(creds).toEqual({
clientId: "dec(enc-id)",
clientSecret: "dec(enc-secret)",
});
// DB hit must not consult the env fallback.
expect(getPolarCredentials).not.toHaveBeenCalled();
expect(findUnique).toHaveBeenCalledWith({
where: { id: "user-1" },
select: {
polarClientIdEncrypted: true,
polarClientSecretEncrypted: true,
},
});
});
it("falls back to the shared env app when the user has no BYO pair", async () => {
findUnique.mockResolvedValue({
polarClientIdEncrypted: null,
polarClientSecretEncrypted: null,
});
getPolarCredentials.mockReturnValue({
clientId: "env-id",
clientSecret: "env-secret",
});
const creds = await getPolarClientCredentials("user-1");
expect(creds).toEqual({ clientId: "env-id", clientSecret: "env-secret" });
expect(getPolarCredentials).toHaveBeenCalledOnce();
});
it("falls back to env when only one half of the BYO pair is present", async () => {
findUnique.mockResolvedValue({
polarClientIdEncrypted: "enc-id",
polarClientSecretEncrypted: null,
});
getPolarCredentials.mockReturnValue({
clientId: "env-id",
clientSecret: "env-secret",
});
const creds = await getPolarClientCredentials("user-1");
expect(creds).toEqual({ clientId: "env-id", clientSecret: "env-secret" });
});
it("returns null when neither the user nor the env is configured", async () => {
findUnique.mockResolvedValue(null);
getPolarCredentials.mockReturnValue(null);
expect(await getPolarClientCredentials("ghost")).toBeNull();
});
});
+67 -6
View File
@@ -1,14 +1,75 @@
/**
* v1.17.0 (F4) — per-user Polar token accessors.
* v1.17.1 — per-user BYO client id/secret resolver (DB-first then env).
*
* 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.
* Like WHOOP / Fitbit, Polar is now a per-user BYO-key integration: each user
* may register their own AccessLink app and store the client id/secret encrypted
* on `User`, with a fall-back to the shared env app for existing single-app
* deploys. Also per-user is the granted token + Polar member id. 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";
import { decrypt, encrypt } from "@/lib/crypto";
import { getPolarCredentials, type PolarCredentials } from "./client";
/**
* Resolve the user's Polar OAuth client id/secret, DB-first then env.
*
* v1.17.1 makes Polar a per-user BYO-key integration (mirroring WHOOP / Fitbit):
* a self-hoster registers their own Polar AccessLink app and pastes the client
* id/secret into Settings, stored encrypted on `User`. When the user has not
* configured per-user keys we fall back to the shared env app
* (`POLAR_CLIENT_ID` / `POLAR_CLIENT_SECRET`) so existing single-app deploys
* keep working. Returns null when neither source is configured.
*/
export async function getPolarClientCredentials(
userId: string,
): Promise<PolarCredentials | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
polarClientIdEncrypted: true,
polarClientSecretEncrypted: true,
},
});
if (user?.polarClientIdEncrypted && user?.polarClientSecretEncrypted) {
return {
clientId: decrypt(user.polarClientIdEncrypted),
clientSecret: decrypt(user.polarClientSecretEncrypted),
};
}
// Fall back to the shared env-configured OAuth app.
return getPolarCredentials();
}
/** Persist the user's Polar OAuth client id/secret, encrypted at rest. */
export async function storePolarClientCredentials(
userId: string,
clientId: string,
clientSecret: string,
): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: {
polarClientIdEncrypted: encrypt(clientId),
polarClientSecretEncrypted: encrypt(clientSecret),
},
});
}
/** Clear the user's stored Polar OAuth client id/secret. */
export async function clearPolarClientCredentials(userId: string): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: {
polarClientIdEncrypted: null,
polarClientSecretEncrypted: null,
},
});
}
export interface PolarConnection {
accessToken: string;
+16
View File
@@ -0,0 +1,16 @@
import { z } from "zod/v4";
/**
* Per-user Oura BYO-key credentials. Each self-hoster registers their own Oura
* app and pastes the client id/secret into Settings. Stored encrypted on
* `User`. When unset, the integration falls back to the shared env-configured
* OAuth app.
*/
export const ouraCredentialsSchema = z.object({
// Trimmed: a trailing space or newline from the portal's copy button reaches
// Oura verbatim and answers as "unknown client".
clientId: z.string().trim().min(1).max(200),
clientSecret: z.string().trim().min(1).max(200),
});
export type OuraCredentialsInput = z.infer<typeof ouraCredentialsSchema>;
+16
View File
@@ -0,0 +1,16 @@
import { z } from "zod/v4";
/**
* Per-user Polar BYO-key credentials. Each self-hoster registers their own
* Polar AccessLink app and pastes the client id/secret into Settings. Stored
* encrypted on `User`. When unset, the integration falls back to the shared
* env-configured OAuth app.
*/
export const polarCredentialsSchema = z.object({
// Trimmed: a trailing space or newline from the portal's copy button reaches
// Polar verbatim and answers as "unknown client".
clientId: z.string().trim().min(1).max(200),
clientSecret: z.string().trim().min(1).max(200),
});
export type PolarCredentialsInput = z.infer<typeof polarCredentialsSchema>;