From 38c7604ce41d2b0c1ae8eb3efa7c5e25ba28b969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:19:47 +0200 Subject: [PATCH] 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. --- messages/de.json | 18 ++- messages/en.json | 18 ++- messages/es.json | 18 ++- messages/fr.json | 18 ++- messages/it.json | 18 ++- messages/pl.json | 18 ++- src/app/api/oura/callback/route.ts | 5 +- src/app/api/oura/connect/route.ts | 5 +- src/app/api/oura/credentials/route.ts | 81 ++++++++++ .../api/oura/status/__tests__/route.test.ts | 13 +- src/app/api/oura/status/route.ts | 27 +++- src/app/api/polar/callback/route.ts | 9 +- src/app/api/polar/connect/route.ts | 5 +- src/app/api/polar/credentials/route.ts | 81 ++++++++++ .../api/polar/status/__tests__/route.test.ts | 15 +- src/app/api/polar/status/route.ts | 32 ++-- .../__tests__/oauth-provider-card.test.tsx | 38 ++++- .../integrations/oauth-provider-card.tsx | 150 +++++++++++++++++- .../settings/integrations/oura-card.tsx | 4 +- .../settings/integrations/polar-card.tsx | 4 +- src/lib/oura/__tests__/credentials.test.ts | 80 ++++++++++ src/lib/oura/__tests__/sync.test.ts | 6 +- src/lib/oura/credentials.ts | 72 ++++++++- src/lib/oura/sync.ts | 9 +- src/lib/polar/__tests__/credentials.test.ts | 83 ++++++++++ src/lib/polar/credentials.ts | 73 ++++++++- src/lib/validations/oura.ts | 16 ++ src/lib/validations/polar.ts | 16 ++ 28 files changed, 856 insertions(+), 76 deletions(-) create mode 100644 src/app/api/oura/credentials/route.ts create mode 100644 src/app/api/polar/credentials/route.ts create mode 100644 src/lib/oura/__tests__/credentials.test.ts create mode 100644 src/lib/polar/__tests__/credentials.test.ts create mode 100644 src/lib/validations/oura.ts create mode 100644 src/lib/validations/polar.ts diff --git a/messages/de.json b/messages/de.json index 01784ec8b..c910cf994 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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?", diff --git a/messages/en.json b/messages/en.json index a78cc1a24..f61d681eb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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?", diff --git a/messages/es.json b/messages/es.json index 07ef722cb..7f4564f12 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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?", diff --git a/messages/fr.json b/messages/fr.json index acec48c77..dab67d9f9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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 ?", diff --git a/messages/it.json b/messages/it.json index e825a1770..e4dcc3b21 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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?", diff --git a/messages/pl.json b/messages/pl.json index b551d7b0b..d58a5999d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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?", diff --git a/src/app/api/oura/callback/route.ts b/src/app/api/oura/callback/route.ts index e30899161..9dd9a0fad 100644 --- a/src/app/api/oura/callback/route.ts +++ b/src/app/api/oura/callback/route.ts @@ -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); diff --git a/src/app/api/oura/connect/route.ts b/src/app/api/oura/connect/route.ts index fd67aa350..e7cd4f237 100644 --- a/src/app/api/oura/connect/route.ts +++ b/src/app/api/oura/connect/route.ts @@ -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); } diff --git a/src/app/api/oura/credentials/route.ts b/src/app/api/oura/credentials/route.ts new file mode 100644 index 000000000..6a3bad80c --- /dev/null +++ b/src/app/api/oura/credentials/route.ts @@ -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 }); +}); diff --git a/src/app/api/oura/status/__tests__/route.test.ts b/src/app/api/oura/status/__tests__/route.test.ts index b62b1d884..dc73142d0 100644 --- a/src/app/api/oura/status/__tests__/route.test.ts +++ b/src/app/api/oura/status/__tests__/route.test.ts @@ -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; -const credsMock = getOuraCredentials as ReturnType; +const credsMock = getOuraClientCredentials as ReturnType; 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 () => { diff --git a/src/app/api/oura/status/route.ts b/src/app/api/oura/status/route.ts index d5dcb6484..f3d4d17f5 100644 --- a/src/app/api/oura/status/route.ts +++ b/src/app/api/oura/status/route.ts @@ -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, diff --git a/src/app/api/polar/callback/route.ts b/src/app/api/polar/callback/route.ts index 34b084e45..53ed9d194 100644 --- a/src/app/api/polar/callback/route.ts +++ b/src/app/api/polar/callback/route.ts @@ -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); diff --git a/src/app/api/polar/connect/route.ts b/src/app/api/polar/connect/route.ts index 5da066968..65d94e708 100644 --- a/src/app/api/polar/connect/route.ts +++ b/src/app/api/polar/connect/route.ts @@ -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.", diff --git a/src/app/api/polar/credentials/route.ts b/src/app/api/polar/credentials/route.ts new file mode 100644 index 000000000..9a92d467d --- /dev/null +++ b/src/app/api/polar/credentials/route.ts @@ -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 }); +}); diff --git a/src/app/api/polar/status/__tests__/route.test.ts b/src/app/api/polar/status/__tests__/route.test.ts index e7e94d27a..7a7d30162 100644 --- a/src/app/api/polar/status/__tests__/route.test.ts +++ b/src/app/api/polar/status/__tests__/route.test.ts @@ -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; -const credsMock = getPolarCredentials as ReturnType; +const credsMock = getPolarClientCredentials as ReturnType; 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); diff --git a/src/app/api/polar/status/route.ts b/src/app/api/polar/status/route.ts index cfa8ad71f..87d43742b 100644 --- a/src/app/api/polar/status/route.ts +++ b/src/app/api/polar/status/route.ts @@ -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, diff --git a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx index 1e6b801dd..830904429 100644 --- a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx +++ b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx @@ -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( , ); @@ -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"); + }); +}); diff --git a/src/components/settings/integrations/oauth-provider-card.tsx b/src/components/settings/integrations/oauth-provider-card.tsx index ea783d179..77b2e97fd 100644 --- a/src/components/settings/integrations/oauth-provider-card.tsx +++ b/src/components/settings/integrations/oauth-provider-card.tsx @@ -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(null); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [credsSaving, setCredsSaving] = useState(false); + const [credsMsg, setCredsMsg] = useState(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`)}

+ {credentials && ( +
+

+ {t(`${i18nPrefix}Credentials`)} +

+

+ {t(`${i18nPrefix}CredentialsHelp`)} +

+
+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+
+ +
+ {credsMsg && ( +

+ {credsMsg} +

+ )} +
+
+ )} + {serverUnavailable && (

); diff --git a/src/components/settings/integrations/polar-card.tsx b/src/components/settings/integrations/polar-card.tsx index 621cd932a..56a310212 100644 --- a/src/components/settings/integrations/polar-card.tsx +++ b/src/components/settings/integrations/polar-card.tsx @@ -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} /> ); diff --git a/src/lib/oura/__tests__/credentials.test.ts b/src/lib/oura/__tests__/credentials.test.ts new file mode 100644 index 000000000..5e63fc22e --- /dev/null +++ b/src/lib/oura/__tests__/credentials.test.ts @@ -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(); + }); +}); diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 2d0fc91eb..ebaf221d2 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -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(); 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([]); diff --git a/src/lib/oura/credentials.ts b/src/lib/oura/credentials.ts index ecf9b4200..4f95f9e1f 100644 --- a/src/lib/oura/credentials.ts +++ b/src/lib/oura/credentials.ts @@ -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 { + 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 { + 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 { + await prisma.user.update({ + where: { id: userId }, + data: { + ouraClientIdEncrypted: null, + ouraClientSecretEncrypted: null, + }, + }); +} export interface OuraConnection { accessToken: string; diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index 58d110e54..efadb0808 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -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); diff --git a/src/lib/polar/__tests__/credentials.test.ts b/src/lib/polar/__tests__/credentials.test.ts new file mode 100644 index 000000000..11f8d5b22 --- /dev/null +++ b/src/lib/polar/__tests__/credentials.test.ts @@ -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()` 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(); + }); +}); diff --git a/src/lib/polar/credentials.ts b/src/lib/polar/credentials.ts index ab1cd9fdb..61a614e2c 100644 --- a/src/lib/polar/credentials.ts +++ b/src/lib/polar/credentials.ts @@ -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 { + 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 { + 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 { + await prisma.user.update({ + where: { id: userId }, + data: { + polarClientIdEncrypted: null, + polarClientSecretEncrypted: null, + }, + }); +} export interface PolarConnection { accessToken: string; diff --git a/src/lib/validations/oura.ts b/src/lib/validations/oura.ts new file mode 100644 index 000000000..b444ff745 --- /dev/null +++ b/src/lib/validations/oura.ts @@ -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; diff --git a/src/lib/validations/polar.ts b/src/lib/validations/polar.ts new file mode 100644 index 000000000..c21123c15 --- /dev/null +++ b/src/lib/validations/polar.ts @@ -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;