Files
HealthLog/src/lib/polar/credentials.ts
T
Marc-André Bombeck 38c7604ce4 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.
2026-06-14 23:19:47 +02:00

102 lines
3.3 KiB
TypeScript

/**
* v1.17.0 (F4) — per-user Polar token accessors.
* v1.17.1 — per-user BYO client id/secret resolver (DB-first then env).
*
* 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, encrypt } from "@/lib/crypto";
import { getPolarCredentials, type PolarCredentials } from "./client";
/**
* Resolve the user's Polar OAuth client id/secret, DB-first then env.
*
* v1.17.1 makes Polar a per-user BYO-key integration (mirroring WHOOP / Fitbit):
* a self-hoster registers their own Polar AccessLink app and pastes the client
* id/secret into Settings, stored encrypted on `User`. When the user has not
* configured per-user keys we fall back to the shared env app
* (`POLAR_CLIENT_ID` / `POLAR_CLIENT_SECRET`) so existing single-app deploys
* keep working. Returns null when neither source is configured.
*/
export async function getPolarClientCredentials(
userId: string,
): Promise<PolarCredentials | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
polarClientIdEncrypted: true,
polarClientSecretEncrypted: true,
},
});
if (user?.polarClientIdEncrypted && user?.polarClientSecretEncrypted) {
return {
clientId: decrypt(user.polarClientIdEncrypted),
clientSecret: decrypt(user.polarClientSecretEncrypted),
};
}
// Fall back to the shared env-configured OAuth app.
return getPolarCredentials();
}
/** Persist the user's Polar OAuth client id/secret, encrypted at rest. */
export async function storePolarClientCredentials(
userId: string,
clientId: string,
clientSecret: string,
): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: {
polarClientIdEncrypted: encrypt(clientId),
polarClientSecretEncrypted: encrypt(clientSecret),
},
});
}
/** Clear the user's stored Polar OAuth client id/secret. */
export async function clearPolarClientCredentials(userId: string): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: {
polarClientIdEncrypted: null,
polarClientSecretEncrypted: null,
},
});
}
export interface PolarConnection {
accessToken: string;
/** Polar numeric member id (`x_user_id`), needed for every data path. */
polarUserId: string;
}
/**
* Fetch + decrypt the user's stored Polar token. Returns null when the user has
* not connected Polar (no access token) or the member id is missing.
*/
export async function getPolarConnection(
userId: string,
): Promise<PolarConnection | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
polarAccessTokenEncrypted: true,
polarUserIdEncrypted: true,
},
});
if (!user?.polarAccessTokenEncrypted || !user?.polarUserIdEncrypted) {
return null;
}
return {
accessToken: decrypt(user.polarAccessTokenEncrypted),
polarUserId: decrypt(user.polarUserIdEncrypted),
};
}