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`)}
+
+
+
+ )}
+
{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;