mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Add Strava as a per-user BYO-OAuth workout source, mirroring the shipped WHOOP/Oura/Polar pattern. Strava caps every new app at single-player mode, so each self-hoster registers their own app and pastes the client id/secret into Settings (falling back to a shared env app). Strava is a workout source only — it exposes no daily metrics and no training-depth analytics are derived. - Schema (migration 0233): per-user encrypted client id/secret + access/refresh tokens, plaintext athlete id, incremental cursor, and backfill marker on `User`; `STRAVA` added to `MeasurementSource`. - Token model follows Oura's reactive-refresh-on-401 with a compare-and-set on the stored refresh ciphertext, because Strava rotates its refresh token on every refresh. - Client over safeFetch: authorization-code + refresh, activity list + budget- aware per-activity detail (for calories/HR), honouring the 200/15min cap; each activity maps to one `Workout` row. Activity name/description stay in metadata as data, never surfaced or interpolated. - Dedup reuses the existing source-agnostic two-layer picker: `STRAVA` slots into the workout ladder below the device-native wearables and above MANUAL/IMPORT, so an Apple-Watch + Strava twin collapses to one canonical row. - Poll-cohort hourly sync (:17) + self-converging boot backfill, wired into the integration-sync registrar. Webhook deferred. - OAuth routes (connect/callback/credentials/disconnect/status/test) with signed state, cross-account re-auth rejection, and a surgical `www.strava.com` connect-src gated to the Strava surfaces. - Settings card + consolidated status envelope entry; env + compose whitelist; i18n across all six locales. - Garmin: no direct connector is possible (business-partner-only). Add docs/integrations/garmin.md documenting the Apple Health / Google Health Connect path and Garmin's withheld proprietary scores, cross-linked from both platform docs, plus a non-connector info note in the Connections panel. Regenerate docs/api/openapi.yaml for the new `STRAVA` MeasurementSource value.
146 lines
4.8 KiB
TypeScript
146 lines
4.8 KiB
TypeScript
/**
|
|
* v1.28.x — per-user Strava token + BYO-key accessors.
|
|
*
|
|
* Like WHOOP / Oura / Polar, Strava is a per-user BYO-key integration: each
|
|
* user registers their own Strava app and stores the client id/secret encrypted
|
|
* on `User`, with a fall-back to the shared env app for 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 — and because Strava ROTATES its
|
|
* refresh token on every refresh, both rotated tokens are persisted with a
|
|
* compare-and-set on the stored refresh ciphertext (Oura's model verbatim).
|
|
*/
|
|
import { prisma } from "@/lib/db";
|
|
import { decrypt, encrypt } from "@/lib/crypto";
|
|
import { persistRotatedToken } from "@/lib/integrations/oauth-refresh";
|
|
import { getStravaCredentials, type StravaCredentials } from "./client";
|
|
|
|
/**
|
|
* Resolve the user's Strava OAuth client id/secret, DB-first then env. A
|
|
* self-hoster's own BYO pair wins; when none is stored we fall back to the
|
|
* shared env app (`STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET`). Returns null
|
|
* when neither source is configured.
|
|
*/
|
|
export async function getStravaClientCredentials(
|
|
userId: string,
|
|
): Promise<StravaCredentials | null> {
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
stravaClientIdEncrypted: true,
|
|
stravaClientSecretEncrypted: true,
|
|
},
|
|
});
|
|
|
|
if (user?.stravaClientIdEncrypted && user?.stravaClientSecretEncrypted) {
|
|
return {
|
|
clientId: decrypt(user.stravaClientIdEncrypted),
|
|
clientSecret: decrypt(user.stravaClientSecretEncrypted),
|
|
};
|
|
}
|
|
|
|
return getStravaCredentials();
|
|
}
|
|
|
|
/** Persist the user's Strava OAuth client id/secret, encrypted at rest. */
|
|
export async function storeStravaClientCredentials(
|
|
userId: string,
|
|
clientId: string,
|
|
clientSecret: string,
|
|
): Promise<void> {
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
stravaClientIdEncrypted: encrypt(clientId),
|
|
stravaClientSecretEncrypted: encrypt(clientSecret),
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Clear the user's stored Strava OAuth client id/secret. */
|
|
export async function clearStravaClientCredentials(
|
|
userId: string,
|
|
): Promise<void> {
|
|
await prisma.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
stravaClientIdEncrypted: null,
|
|
stravaClientSecretEncrypted: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
export interface StravaConnection {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
/**
|
|
* The exact stored refresh-token ciphertext (NOT the decrypted value). The
|
|
* rotating-refresh compare-and-swap in `storeStravaTokens` matches on this so
|
|
* a concurrent sync that already rotated the token is not clobbered.
|
|
*/
|
|
refreshTokenCiphertext: string;
|
|
}
|
|
|
|
export async function getStravaConnection(
|
|
userId: string,
|
|
): Promise<StravaConnection | null> {
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
stravaAccessTokenEncrypted: true,
|
|
stravaRefreshTokenEncrypted: true,
|
|
},
|
|
});
|
|
if (!user?.stravaAccessTokenEncrypted || !user?.stravaRefreshTokenEncrypted) {
|
|
return null;
|
|
}
|
|
return {
|
|
accessToken: decrypt(user.stravaAccessTokenEncrypted),
|
|
refreshToken: decrypt(user.stravaRefreshTokenEncrypted),
|
|
refreshTokenCiphertext: user.stravaRefreshTokenEncrypted,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Persist a rotated Strava token pair with compare-and-swap on the stored
|
|
* refresh ciphertext. Strava's tokens live on `User` and the refresh is
|
|
* REACTIVE on a 401, so two overlapping syncs can race the same one-time-use
|
|
* refresh token. The CAS guard writes only when the stored ciphertext still
|
|
* equals the one this caller spent; on a lost race (0 rows) it re-reads and
|
|
* returns the peer's freshly rotated access token.
|
|
*
|
|
* Returns the access token the caller should use for the current sync (its own
|
|
* on a win, the peer's on a loss), or null if the connection vanished.
|
|
*/
|
|
export async function storeStravaTokens(
|
|
userId: string,
|
|
accessToken: string,
|
|
refreshToken: string,
|
|
expectedRefreshCiphertext: string,
|
|
): Promise<string | null> {
|
|
return persistRotatedToken(accessToken, {
|
|
conditionalUpdate: async () => {
|
|
const { count } = await prisma.user.updateMany({
|
|
where: {
|
|
id: userId,
|
|
stravaRefreshTokenEncrypted: expectedRefreshCiphertext,
|
|
},
|
|
data: {
|
|
stravaAccessTokenEncrypted: encrypt(accessToken),
|
|
stravaRefreshTokenEncrypted: encrypt(refreshToken),
|
|
},
|
|
});
|
|
return count;
|
|
},
|
|
readPeerAccessToken: async () => {
|
|
const fresh = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { stravaAccessTokenEncrypted: true },
|
|
});
|
|
return fresh?.stravaAccessTokenEncrypted
|
|
? decrypt(fresh.stravaAccessTokenEncrypted)
|
|
: null;
|
|
},
|
|
});
|
|
}
|