mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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.
102 lines
3.3 KiB
TypeScript
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),
|
|
};
|
|
}
|