Files
HealthLog/src/app/api/strava/credentials/route.ts
T
Marc-André Bombeck 75eb0637e6 feat(strava): add per-user BYO-OAuth workout source + Garmin indirect-path docs
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.
2026-07-09 21:25:25 +02:00

102 lines
3.1 KiB
TypeScript

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 { auditLog } from "@/lib/auth/audit";
import { markDisconnected } from "@/lib/integrations/status";
import {
storeStravaClientCredentials,
clearStravaClientCredentials,
} from "@/lib/strava/credentials";
import { stravaCredentialsSchema } from "@/lib/validations/strava";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
/**
* v1.28.x — per-user Strava BYO-key credentials.
*
* Whether the user has their own Strava 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: "strava.credentials.get" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
stravaClientIdEncrypted: true,
stravaClientSecretEncrypted: true,
},
});
return apiSuccess({
hasCredentials:
!!dbUser?.stravaClientIdEncrypted &&
!!dbUser?.stravaClientSecretEncrypted,
});
});
/**
* Save Strava OAuth client credentials (encrypted at rest).
*/
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "strava.credentials.update" } });
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const result = z.safeParse(stravaCredentialsSchema, body);
if (!result.success) {
return apiError("Client ID and Client Secret are required", 422);
}
await storeStravaClientCredentials(
user.id,
result.data.clientId,
result.data.clientSecret,
);
return apiSuccess({ updated: true });
});
/**
* Delete Strava credentials and the active connection.
*
* Deleting the BYO credentials drops a live connection (a token minted against
* the deleted app is now orphaned), so when an access token was present this
* mirrors `/api/strava/disconnect`: audit the event and park the integration
* ledger at `disconnected` rather than leaving it stale at its last state.
*/
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "strava.credentials.delete" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { stravaAccessTokenEncrypted: true },
});
const wasConnected = !!dbUser?.stravaAccessTokenEncrypted;
await prisma.user.update({
where: { id: user.id },
data: {
stravaAccessTokenEncrypted: null,
stravaRefreshTokenEncrypted: null,
stravaAthleteId: null,
},
});
await clearStravaClientCredentials(user.id);
if (wasConnected) {
await auditLog("strava.credentials.delete", { userId: user.id });
await markDisconnected(user.id, "strava");
}
return apiSuccess({ deleted: true });
});