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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-09 21:25:25 +02:00
1 parent 89634e20ee
commit 75eb0637e6
49 files changed
+2683 -15

No files matched your search

+20
View File
@@ -30,6 +30,7 @@ import {
} from "@/lib/integrations/metric-freshness";
import { getOuraClientCredentials } from "@/lib/oura/credentials";
import { getPolarClientCredentials } from "@/lib/polar/credentials";
import { getStravaClientCredentials } from "@/lib/strava/credentials";
import { hasActivityScope } from "@/lib/withings/client";
import { readMoodLogSecret } from "@/lib/moodlog-secret";
@@ -47,6 +48,7 @@ export const GET = apiHandler(async () => {
googleHealthStatus,
polarStatus,
ouraStatus,
stravaStatus,
dbUser,
withingsConn,
whoopConn,
@@ -58,6 +60,7 @@ export const GET = apiHandler(async () => {
// /api/<provider>/status the consolidated envelope now replaces.
polarAvailable,
ouraAvailable,
stravaAvailable,
] = await Promise.all([
getIntegrationStatus(user.id, "withings"),
getIntegrationStatus(user.id, "moodlog"),
@@ -66,6 +69,7 @@ export const GET = apiHandler(async () => {
getIntegrationStatus(user.id, "google-health"),
getIntegrationStatus(user.id, "polar"),
getIntegrationStatus(user.id, "oura"),
getIntegrationStatus(user.id, "strava"),
prisma.user.findUnique({
where: { id: user.id },
select: {
@@ -83,6 +87,9 @@ export const GET = apiHandler(async () => {
ouraAccessTokenEncrypted: true,
ouraClientIdEncrypted: true,
ouraClientSecretEncrypted: true,
stravaAccessTokenEncrypted: true,
stravaClientIdEncrypted: true,
stravaClientSecretEncrypted: true,
moodLogUrlEncrypted: true,
moodLogApiKeyEncrypted: true,
moodLogEnabled: true,
@@ -133,6 +140,7 @@ export const GET = apiHandler(async () => {
}),
getPolarClientCredentials(user.id).then((c) => !!c),
getOuraClientCredentials(user.id).then((c) => !!c),
getStravaClientCredentials(user.id).then((c) => !!c),
]);
// F-SYNC-1 — per-metric-type last-value timestamps so the card can show that a
@@ -259,6 +267,18 @@ export const GET = apiHandler(async () => {
!!dbUser?.ouraClientSecretEncrypted,
metricFreshness: metricFreshness.oura ?? [],
} satisfies IntegrationViewModel & OAuthProviderExtras,
{
...stravaStatus,
connected: !!dbUser?.stravaAccessTokenEncrypted,
configured: !!dbUser?.stravaAccessTokenEncrypted,
available: stravaAvailable,
hasOwnCredentials:
!!dbUser?.stravaClientIdEncrypted &&
!!dbUser?.stravaClientSecretEncrypted,
// Strava writes Workout rows, not Measurements, so it has no
// per-metric measurement freshness (like moodLog).
metricFreshness: metricFreshness.strava ?? [],
} satisfies IntegrationViewModel & OAuthProviderExtras,
],
});
});
+122
View File
@@ -0,0 +1,122 @@
import { prisma } from "@/lib/db";
import { apiHandler } from "@/lib/api-handler";
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 } from "@/lib/strava/client";
import { getStravaClientCredentials } from "@/lib/strava/credentials";
import {
oauthStateCookieName,
stateMatchesCookie,
verifySignedState,
} from "@/lib/oauth/signed-state";
import { markReconnected } from "@/lib/integrations/status";
import { NextRequest, NextResponse } from "next/server";
/**
* v1.28.x — OAuth callback from Strava.
*
* Identity + CSRF mirror the Polar / Oura callbacks: the `state` param is a
* stateless HMAC-signed token, enforced as a byte-exact double-submit against
* the httpOnly cookie AND verified for signature / expiry / `strava` provider
* binding. The `userId` is read from inside the verified token, cross-checked
* against the web session when present.
*
* On success: exchange the code, persist the encrypted access + rotated refresh
* token + the numeric athlete id on `User`, clear any prior reauth state, and
* redirect back to Settings. A re-auth that returns a DIFFERENT athlete id than
* the one already stored is rejected (`cross_user`) so a second Strava account
* cannot silently overwrite the first's grant.
*/
const ERR = (reason: string) =>
NextResponse.redirect(
new URL(
`/settings/integrations?strava=error&reason=${reason}`,
process.env.NEXT_PUBLIC_APP_URL!,
),
);
export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "strava.callback" } });
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const state = searchParams.get("state");
const cookieState = request.cookies.get(
oauthStateCookieName("strava"),
)?.value;
if (!stateMatchesCookie(state, cookieState)) {
annotate({ meta: { reason: "csrf1" } });
return ERR("csrf1");
}
const verified = verifySignedState("strava", state);
if (!verified) {
annotate({ meta: { reason: "state" } });
return ERR("state");
}
const userId = verified.userId;
const evt = getEvent();
if (evt) evt.setAuth({ user_id: userId, auth_method: "session" });
const sessionData = await getSession();
if (sessionData && sessionData.user.id !== userId) {
annotate({ meta: { reason: "cross_user" } });
return ERR("cross_user");
}
if (!code) return ERR("nocode");
try {
const creds = await getStravaClientCredentials(userId);
if (!creds) return ERR("nocreds");
const tokens = await exchangeCode(code, creds);
const athleteId =
typeof tokens.athlete?.id === "number" ? String(tokens.athlete.id) : null;
// Reject a re-auth that swaps the connected Strava account. A different
// athlete id than the one already pinned means a second account is trying
// to overwrite the first's grant.
const existing = await prisma.user.findUnique({
where: { id: userId },
select: { stravaAthleteId: true },
});
if (
existing?.stravaAthleteId &&
athleteId &&
existing.stravaAthleteId !== athleteId
) {
annotate({ meta: { reason: "cross_user" } });
return ERR("cross_user");
}
await prisma.user.update({
where: { id: userId },
data: {
stravaAccessTokenEncrypted: encrypt(tokens.access_token),
stravaRefreshTokenEncrypted: encrypt(tokens.refresh_token),
stravaAthleteId: athleteId,
},
});
await auditLog("strava.connect", { userId });
await markReconnected(userId, "strava");
const response = NextResponse.redirect(
new URL(
"/settings/integrations?strava=connected",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
response.cookies.delete(oauthStateCookieName("strava"));
return response;
} catch (err) {
getEvent()?.setError(err);
return ERR("token");
}
});
+66
View File
@@ -0,0 +1,66 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
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 } from "@/lib/strava/client";
import { getStravaClientCredentials } from "@/lib/strava/credentials";
import {
OAUTH_STATE_TTL_MS,
mintSignedState,
oauthStateCookieName,
} from "@/lib/oauth/signed-state";
import { NextResponse } from "next/server";
/**
* v1.28.x — redirect the user to the Strava consent screen.
*
* Credentials resolve DB-first then env (`getStravaClientCredentials`): a
* user's BYO Strava client id/secret wins, falling back to the shared env app
* (`STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET`) when none is stored. CSRF via
* the stateless signed state (`src/lib/oauth/signed-state.ts`): the same token
* is the `state` URL param AND an httpOnly cookie. Rate-limited per user.
*/
const CONNECT_RATE_LIMIT = 10;
const CONNECT_WINDOW_MS = 60_000;
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "strava.connect" } });
const rl = await checkRateLimit(
`strava:connect:${user.id}`,
CONNECT_RATE_LIMIT,
CONNECT_WINDOW_MS,
);
if (!rl.allowed) {
annotate({ action: { name: "strava.connect.rate_limited" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?strava=error&reason=rate_limited",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
const creds = await getStravaClientCredentials(user.id);
if (!creds) {
return apiError(
"Strava integration is not configured on this server.",
400,
);
}
const state = mintSignedState("strava", user.id);
const url = getAuthorizationUrl(state, creds);
const response = NextResponse.redirect(url);
response.cookies.set(oauthStateCookieName("strava"), state, {
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: Math.floor(OAUTH_STATE_TTL_MS / 1000),
path: "/",
});
return response;
});
+101
View File
@@ -0,0 +1,101 @@
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 });
});
+56
View File
@@ -0,0 +1,56 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate, getEvent } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { markDisconnected } from "@/lib/integrations/status";
import { deauthorize } from "@/lib/strava/client";
import { getStravaConnection } from "@/lib/strava/credentials";
/**
* v1.28.x — disconnect Strava for the current user.
*
* Best-effort deauthorize at Strava, then clear the stored access + refresh
* token + athlete id on `User` and park the integration status at
* `disconnected`. Imported `source = STRAVA` workouts are left intact; a
* reconnect resumes sync.
*/
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "strava.disconnect" } });
const rl = await checkRateLimit(`strava-disconnect:${user.id}`, 20, 60_000);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
const connection = await getStravaConnection(user.id);
if (!connection) {
return apiError("No Strava connection", 404);
}
// Best-effort revoke at Strava; a Strava-side outage must never block the
// local disconnect.
try {
await deauthorize(connection.accessToken);
} catch (err) {
getEvent()?.addWarning(`strava: deauthorize failed on disconnect: ${err}`);
}
await prisma.user.update({
where: { id: user.id },
data: {
stravaAccessTokenEncrypted: null,
stravaRefreshTokenEncrypted: null,
stravaAthleteId: null,
},
});
await auditLog("strava.disconnect", { userId: user.id });
await markDisconnected(user.id, "strava");
return apiSuccess({ disconnected: true });
});
+57
View File
@@ -0,0 +1,57 @@
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 { getStravaClientCredentials } from "@/lib/strava/credentials";
import { getIntegrationStatus } from "@/lib/integrations/status";
/**
* v1.28.x — Strava connection status for the current user.
*
* `available` reports whether usable OAuth credentials resolve for this user —
* per-user BYO keys 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 `strava`
* IntegrationKey. The access / refresh tokens + client secret are NEVER
* returned.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "strava.status" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
stravaAccessTokenEncrypted: true,
stravaClientIdEncrypted: true,
stravaClientSecretEncrypted: true,
},
});
const available = !!(await getStravaClientCredentials(user.id));
const connected = !!dbUser?.stravaAccessTokenEncrypted;
const hasOwnCredentials =
!!dbUser?.stravaClientIdEncrypted && !!dbUser?.stravaClientSecretEncrypted;
if (!connected) {
return apiSuccess({
connected: false,
configured: false,
available,
hasOwnCredentials,
});
}
const status = await getIntegrationStatus(user.id, "strava");
return apiSuccess({
connected: true,
configured: true,
available,
hasOwnCredentials,
state: status.state,
lastSuccessAt: status.lastSuccessAt,
lastAttemptAt: status.lastAttemptAt,
lastError: status.lastError,
});
});
+91
View File
@@ -0,0 +1,91 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { getStravaConnection } from "@/lib/strava/credentials";
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
export const dynamic = "force-dynamic";
// Lightweight token probe — fetch the authenticated athlete, the cheapest
// authenticated Strava call that confirms the stored grant is still valid.
// Reuses `getStravaConnection`; no new credential path.
const STRAVA_ATHLETE_URL = "https://www.strava.com/api/v3/athlete";
const TIMEOUT_MS = 5_000;
function categoriseHttpStatus(status: number): {
code: string;
message: string;
} {
if (status === 401 || status === 403) {
return {
code: "credentials_rejected",
message: "Strava rejected the token",
};
}
if (status === 429) {
return { code: "rate_limited", message: "Strava rate-limited the request" };
}
if (status >= 500) {
return {
code: "upstream_error",
message: "Strava returned a server error",
};
}
return { code: "connection_failed", message: "Strava connection failed" };
}
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "strava.test" } });
const rl = await checkRateLimit(`strava-test:${user.id}`, 5, 60_000);
if (!rl.allowed) {
return apiError("Too many test requests", 429, {
errorCode: "rate_limited_self",
});
}
const connection = await getStravaConnection(user.id);
if (!connection) {
return apiError("Strava not connected", 422, {
errorCode: "not_configured",
});
}
const start = performance.now();
try {
const res = await safeFetch(
STRAVA_ATHLETE_URL,
{
method: "GET",
headers: {
Authorization: `Bearer ${connection.accessToken}`,
Accept: "application/json",
},
},
{ timeoutMs: TIMEOUT_MS },
);
const latencyMs = Math.round(performance.now() - start);
if (!res.ok) {
const cat = categoriseHttpStatus(res.status);
annotate({
meta: { strava_test_status: res.status, strava_test_code: cat.code },
});
return apiError(cat.message, 502, { errorCode: cat.code });
}
return apiSuccess({ ok: true, latencyMs });
} catch (e) {
const isTimeout = e instanceof SafeFetchError && e.kind === "timeout";
const code = isTimeout ? "timeout" : "connection_failed";
annotate({ meta: { strava_test_code: code } });
return apiError(
isTimeout ? "Strava request timed out" : "Strava connection failed",
502,
{ errorCode: code },
);
}
});
@@ -131,10 +131,11 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
});
const html = render();
// Exactly one pill per card → 7 pills total (Withings, WHOOP, Fitbit,
// Google Health, Polar, Oura, Nightscout). The moodLog integration was
// removed; Polar + Oura (F4) were added in v1.17.0; Google Health in v1.27.0.
expect(count(html, 'data-testid="integration-status-pill"')).toBe(7);
// Exactly one pill per card → 8 pills total (Withings, WHOOP, Fitbit,
// Google Health, Polar, Oura, Strava, Nightscout). The moodLog integration
// was removed; Polar + Oura (F4) were added in v1.17.0; Google Health in
// v1.27.0; Strava in v1.28.x. (The Garmin info note carries no pill.)
expect(count(html, 'data-testid="integration-status-pill"')).toBe(8);
// The redundant banner from v1.4.15 is gone.
expect(html).not.toContain('data-testid="integration-status-banner"');
// Card-body "letzter Sync" repetition is gone — no
@@ -326,8 +327,9 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
});
const html = render();
// One setup-guide link per card → seven providers.
expect(count(html, 'data-slot="integration-setup-guide"')).toBe(7);
// One setup-guide link per card → eight providers (the Garmin info note is
// not an OAuth card and carries its own docs link, not a setup-guide).
expect(count(html, 'data-slot="integration-setup-guide"')).toBe(8);
for (const provider of [
"withings",
"whoop",
@@ -335,6 +337,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
"google-health",
"polar",
"oura",
"strava",
"nightscout",
]) {
expect(html).toContain(`data-testid="${provider}-setup-guide"`);
@@ -11,7 +11,7 @@ import { parseOAuthOutcome, oauthReasonKey } from "../integrations-section";
describe("parseOAuthOutcome", () => {
it("reads a connected outcome for every OAuth provider", () => {
for (const p of ["polar", "oura", "whoop", "fitbit"] as const) {
for (const p of ["polar", "oura", "whoop", "fitbit", "strava"] as const) {
expect(parseOAuthOutcome(`?${p}=connected`)).toEqual({
provider: p,
kind: "connected",
@@ -25,6 +25,11 @@ describe("parseOAuthOutcome", () => {
kind: "error",
reason: "token",
});
expect(parseOAuthOutcome("?strava=error&reason=cross_user")).toEqual({
provider: "strava",
kind: "error",
reason: "cross_user",
});
expect(parseOAuthOutcome("?fitbit=error&reason=expired")).toEqual({
provider: "fitbit",
kind: "error",
@@ -17,11 +17,13 @@ import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { FitbitCard } from "@/components/settings/integrations/fitbit-card";
import { GarminInfoNote } from "@/components/settings/integrations/garmin-info-note";
import { GoogleHealthCard } from "@/components/settings/integrations/google-health-card";
import { NightscoutCard } from "@/components/settings/integrations/nightscout-card";
import type { OAuthProviderStatus } from "@/components/settings/integrations/oauth-provider-card";
import { OuraCard } from "@/components/settings/integrations/oura-card";
import { PolarCard } from "@/components/settings/integrations/polar-card";
import { StravaCard } from "@/components/settings/integrations/strava-card";
import {
pickStatus,
useIntegrationStatuses,
@@ -71,6 +73,8 @@ const OAUTH_OUTCOME_PROVIDERS = [
"whoop",
"fitbit",
"googleHealth",
// v1.28.x — Strava callback redirects with `?strava=connected|error&reason=…`.
"strava",
] as const;
type OAuthOutcomeProvider = (typeof OAUTH_OUTCOME_PROVIDERS)[number];
@@ -83,6 +87,7 @@ const OAUTH_OUTCOME_KEYS: Record<
whoop: queryKeys.whoop,
fitbit: queryKeys.fitbit,
googleHealth: queryKeys.googleHealth,
strava: queryKeys.strava,
};
/**
@@ -259,6 +264,10 @@ export function ConnectionsPanel() {
// no longer fire their own /api/<provider>/status round-trip.
const polarViewModel = toOAuthStatus(pickStatus(integrationStatus, "polar"));
const ouraViewModel = toOAuthStatus(pickStatus(integrationStatus, "oura"));
// v1.28.x — Strava reads off the same consolidated envelope.
const stravaViewModel = toOAuthStatus(
pickStatus(integrationStatus, "strava"),
);
return (
<div className="space-y-6" data-slot="connections-panel">
@@ -282,9 +291,18 @@ export function ConnectionsPanel() {
<div id="oura" className="scroll-mt-28">
<OuraCard enabled={isAuthenticated} viewModel={ouraViewModel} />
</div>
<div id="strava" className="scroll-mt-28">
<StravaCard enabled={isAuthenticated} viewModel={stravaViewModel} />
</div>
<div id="nightscout" className="scroll-mt-28">
<NightscoutCard enabled={isAuthenticated} />
</div>
{/* Garmin: no direct connector (business-partner-only). A quiet
non-connector note pointing to the Apple Health / Health Connect
path — not an OAuthProviderCard (nothing to connect). */}
<div id="garmin" className="scroll-mt-28">
<GarminInfoNote />
</div>
</div>
);
}
@@ -0,0 +1,43 @@
"use client";
// v1.28.x — Garmin is NOT a direct connector: Garmin's developer program is
// business-partner-only (approval-gated, needs public callbacks), so the
// self-hosted BYO model cannot register a paste-your-key Garmin app. The honest
// path is Apple Health (iOS) or Google Health Connect (Android), which already
// works with zero extra code. This quiet, non-connector info note pre-empts the
// recurring "where's Garmin?" question and points to the runbook.
import { ExternalLink, Watch } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { INTEGRATION_DOCS_BASE } from "@/components/settings/integrations/setup-guide-link";
import { useTranslations } from "@/lib/i18n/context";
export function GarminInfoNote() {
const { t } = useTranslations();
return (
<SettingsCard data-testid="garmin-info">
<SettingsCardHeader
icon={Watch}
title={t("settings.garminInfo.title")}
description={
<p className="text-muted-foreground">
{t("settings.garminInfo.body")}{" "}
<a
href={`${INTEGRATION_DOCS_BASE}/garmin`}
target="_blank"
rel="noopener noreferrer"
data-testid="garmin-docs-link"
data-slot="garmin-docs-link"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 underline underline-offset-2"
>
{t("settings.garminInfo.docsLink")}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
</p>
}
/>
</SettingsCard>
);
}
@@ -89,7 +89,7 @@ function pillStateFor(
export interface OAuthProviderCardProps {
/** Lower-case provider key, used for routes + query keys + testids. */
provider: "polar" | "oura";
provider: "polar" | "oura" | "strava";
/** The query-key array shared by the status read + the invalidations. */
statusQueryKey: readonly unknown[];
/** i18n key prefix (e.g. `settings.polar`). */
@@ -30,7 +30,9 @@ export type IntegrationDocsProvider =
| "oura"
| "nightscout"
// v1.27.0 — Google Health runbook (docs.healthlog.dev/integrations/google-health).
| "google-health";
| "google-health"
// v1.28.x — Strava runbook (docs.healthlog.dev/integrations/strava).
| "strava";
export function integrationDocsHref(provider: IntegrationDocsProvider): string {
return `${INTEGRATION_DOCS_BASE}/${provider}`;
@@ -26,7 +26,9 @@ export type IntegrationKey =
| "polar"
| "oura"
// v1.27.0 — Google Health (Fitbit + Pixel Watch + Fitbit Air).
| "google-health";
| "google-health"
// v1.28.x — Strava OAuth workout source.
| "strava";
export type IntegrationState =
"connected" | "error_transient" | "error_reauth" | "disconnected" | "parked";
@@ -0,0 +1,35 @@
"use client";
// v1.28.x — Strava integration card. Thin wrapper over the shared OAuth card
// (per-user BYO OAuth credentials, DB-first then env). Strava is a workout
// source: connecting it syncs your activities into the workout surface, where
// the cross-source picker collapses any twin already captured by a wearable.
import { Activity } from "lucide-react";
import {
OAuthProviderCard,
type OAuthProviderStatus,
} from "@/components/settings/integrations/oauth-provider-card";
import { queryKeys } from "@/lib/query-keys";
export function StravaCard({
enabled = true,
viewModel,
}: {
enabled?: boolean;
viewModel?: OAuthProviderStatus;
}) {
return (
<OAuthProviderCard
provider="strava"
statusQueryKey={queryKeys.strava()}
i18nPrefix="settings.strava"
icon={Activity}
dataHref="/insights/workouts"
credentials
enabled={enabled}
viewModel={viewModel}
/>
);
}
+4
View File
@@ -77,6 +77,10 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
{ model: "User", field: "ouraRefreshTokenEncrypted", kind: "string" },
{ model: "User", field: "ouraClientIdEncrypted", kind: "string" },
{ model: "User", field: "ouraClientSecretEncrypted", kind: "string" },
{ model: "User", field: "stravaClientIdEncrypted", kind: "string" },
{ model: "User", field: "stravaClientSecretEncrypted", kind: "string" },
{ model: "User", field: "stravaAccessTokenEncrypted", kind: "string" },
{ model: "User", field: "stravaRefreshTokenEncrypted", kind: "string" },
{ model: "User", field: "aiAnthropicKeyEncrypted", kind: "string" },
{ model: "User", field: "aiLocalKeyEncrypted", kind: "string" },
{ model: "User", field: "aiOpenaiKeyEncrypted", kind: "string" },
+3
View File
@@ -52,6 +52,8 @@ export type IntegrationKey =
// successor Google Health API. Separate connection from the classic
// `fitbit` transport, which sunsets Sept 2026.
| "google-health"
// v1.28.x — Strava OAuth workout source.
| "strava"
| "moodlog";
/**
@@ -814,6 +816,7 @@ const INTEGRATION_LABELS: Record<IntegrationKey, string> = {
polar: "Polar",
oura: "Oura",
"google-health": "Google Health",
strava: "Strava",
moodlog: "moodLog",
};
@@ -35,6 +35,13 @@ import {
enqueueBootTimeGoogleHealthBackfill,
type GoogleHealthBackfillPayload,
} from "@/lib/jobs/google-health-backfill";
import {
STRAVA_BACKFILL_QUEUE,
STRAVA_BACKFILL_CONCURRENCY,
runStravaBackfillForUser,
enqueueBootTimeStravaBackfill,
type StravaBackfillPayload,
} from "@/lib/jobs/strava-backfill";
import {
SLEEP_TIMELINE_BACKFILL_QUEUE,
SLEEP_TIMELINE_BACKFILL_CONCURRENCY,
@@ -76,6 +83,7 @@ import {
import { NightscoutSyncPayload, handleNightscoutSync } from "./nightscout-sync";
import { PolarSyncPayload, handlePolarSync } from "./polar-sync";
import { OuraSyncPayload, handleOuraSync } from "./oura-sync";
import { StravaSyncPayload, handleStravaSync } from "./strava-sync";
import {
FitbitSyncPayload,
handleFitbitSync,
@@ -197,6 +205,13 @@ const POLAR_SYNC_QUEUE = "polar-sync";
const POLAR_SYNC_CRON = "13 * * * *"; // every hour at :13
const OURA_SYNC_QUEUE = "oura-sync";
const OURA_SYNC_CRON = "15 * * * *"; // every hour at :15
// v1.28.x — Strava OAuth poll sync. Poll-only (webhook deferred): one hourly
// tick re-walks every connected user. :17 staggers off the Polar (:13) / Oura
// (:15) ticks so the polls don't pile up on one boss poll. The queue MUST be
// registered in `allQueues` below or pg-boss never provisions it and the
// schedule silently no-ops (the v1.4.37 dead-queue class).
const STRAVA_SYNC_QUEUE = "strava-sync";
const STRAVA_SYNC_CRON = "17 * * * *"; // every hour at :17
// pg-boss v12 requires explicit queue creation before scheduling. Every queue
// MUST be registered here or pg-boss never provisions it and both the webhook
@@ -244,6 +259,9 @@ const allQueues = [
// v1.17.0 (F4) — Polar + Oura OAuth poll sync.
POLAR_SYNC_QUEUE,
OURA_SYNC_QUEUE,
// v1.28.x — Strava OAuth poll sync + self-converging boot backfill.
STRAVA_SYNC_QUEUE,
STRAVA_BACKFILL_QUEUE,
];
const schedules: ScheduleEntry[] = [
@@ -279,6 +297,8 @@ const schedules: ScheduleEntry[] = [
// v1.17.0 (F4) — hourly Polar (:13) + Oura (:15) OAuth polls.
[POLAR_SYNC_QUEUE, POLAR_SYNC_CRON],
[OURA_SYNC_QUEUE, OURA_SYNC_CRON],
// v1.28.x — hourly Strava (:17) OAuth poll.
[STRAVA_SYNC_QUEUE, STRAVA_SYNC_CRON],
];
/**
@@ -475,6 +495,31 @@ export async function registerIntegrationSyncQueues(
{ localConcurrency: 1 },
handleOuraSync,
);
// v1.28.x — Strava OAuth poll-cohort sync (poll-only; webhook deferred). The
// hourly cron tick (no `userId`) re-walks every connected user; one user's
// revoked grant is warned, not fatal.
await boss.work<StravaSyncPayload>(
STRAVA_SYNC_QUEUE,
{ localConcurrency: 1 },
handleStravaSync,
);
// v1.28.x — self-converging Strava backfill. The boot enqueue below sends one
// full-history sync per un-backfilled connection; this handler runs it and
// stamps `stravaBackfillCompletedAt` so the discovery query drops the account.
await boss.work<StravaBackfillPayload>(
STRAVA_BACKFILL_QUEUE,
{ localConcurrency: STRAVA_BACKFILL_CONCURRENCY },
async (jobs) => {
for (const job of jobs) {
const { userId } = job.data;
const { imported } = await runStravaBackfillForUser(userId);
workerLog(
"info",
`[strava-backfill] user=${userId} imported=${imported}`,
);
}
},
);
await boss.work<MoodLogSyncPayload>(
MOODLOG_SYNC_QUEUE,
{ localConcurrency: 1 },
@@ -580,6 +625,26 @@ export async function enqueueIntegrationSyncBootDiscovery(): Promise<void> {
);
}
// v1.28.x — Strava backfill. Finds every Strava connection not yet
// backfilled and enqueues one full-history sync per account.
try {
const { enqueued, skipped, error } = await enqueueBootTimeStravaBackfill();
if (error) {
workerLog("error", `[strava-backfill] boot discovery failed: ${error}`);
} else {
workerLog(
"info",
`[strava-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
);
}
} catch (err) {
workerLog(
"error",
"[strava-backfill] boot discovery threw an unexpected error",
err,
);
}
// v1.18.1 — one-shot lab-biomarker backfill. Finds every user holding an
// un-linked live lab reading and enqueues one job each. A completed pass
// links every reading, dropping the user from the discovery set.
+29
View File
@@ -0,0 +1,29 @@
/**
* v1.28.x — Strava poll-cohort sync handler.
*
* Poll-only: Strava has a Webhook Events API, but this ship runs the simpler
* poll model (matching Polar/Oura/Fitbit). One hourly cron tick carries no
* `userId` and the handler iterates every user with a stored Strava token,
* re-syncing each via `syncUserStrava`. One user's revoked grant / outage is
* warned and the pass continues. The shared control flow lives in
* `./poll-cohort`.
*
* The queue name, cron schedule, and boss.work registration live in
* `register-integration-sync.ts`.
*/
import { syncUserStrava } from "@/lib/strava/sync";
import { makePollCohortHandler, type PollCohortPayload } from "./poll-cohort";
export type StravaSyncPayload = PollCohortPayload;
export const handleStravaSync = makePollCohortHandler({
taskName: "job.strava_sync",
findCohort: async (prisma) => {
const users = await prisma.user.findMany({
where: { stravaAccessTokenEncrypted: { not: null } },
select: { id: true },
});
return users.map((u) => u.id);
},
syncUser: (userId) => syncUserStrava(userId),
});
+124
View File
@@ -0,0 +1,124 @@
/**
* v1.28.x — pg-boss queue + boot-time self-converging backfill for newly
* connected Strava accounts. Modelled on the WHOOP / Fitbit backfill: a
* discovery query enqueues one job per connection that has NOT yet been
* backfilled, and the pass is idempotent across reboots (the predicate
* `strava_backfill_completed_at IS NULL` drops a connection once its backfill
* finishes).
*
* Unlike WHOOP/Fitbit (which carry the marker on a dedicated connection table),
* Strava rides `User` columns like Oura/Polar, so the marker is
* `User.stravaBackfillCompletedAt`.
*
* The queue name MUST be registered in `allQueues` in
* `register-integration-sync.ts` or pg-boss never provisions it and the boot
* enqueue silently never drains (the v1.4.37 dead-queue class).
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { syncUserStrava } from "@/lib/strava/sync";
export const STRAVA_BACKFILL_QUEUE = "strava-backfill";
/**
* Serial concurrency — a backfill walks the account's activity history and is
* rate-bounded by Strava's 200-req / 15-min cap; concurrency-1 keeps it from
* crowding the request pool, matching `WHOOP_BACKFILL_CONCURRENCY`.
*/
export const STRAVA_BACKFILL_CONCURRENCY = 1;
export interface StravaBackfillPayload {
userId: string;
enqueuedAt: string;
}
/**
* Per-user backfill handler. Runs a full-history sync for one account and
* stamps `stravaBackfillCompletedAt` so the discovery query drops it.
* Idempotent: the `(userId, source, externalId)` workout upsert is key-stable,
* so a re-run (e.g. a reboot mid-walk) overwrites rather than duplicating.
*/
export async function runStravaBackfillForUser(
userId: string,
): Promise<{ imported: number }> {
const imported = await syncUserStrava(userId, { fullSync: true });
await prisma.user.update({
where: { id: userId },
data: { stravaBackfillCompletedAt: new Date() },
});
annotate({
action: {
name: "strava.backfill.complete",
details: { imported },
},
});
return { imported };
}
/**
* Boot-time discovery. Finds every Strava connection not yet backfilled
* (a stored access token + `strava_backfill_completed_at IS NULL`) and enqueues
* one backfill job per account.
*
* Idempotent across reboots: once a connection's backfill completes,
* `stravaBackfillCompletedAt` is set and the predicate drops it. pg-boss
* `singletonKey` coalesces duplicate sends so a fast restart while a job is
* queued doesn't double up.
*
* Best-effort: errors come back through the result value so the worker boot
* never fails because of a backfill miss.
*/
export async function enqueueBootTimeStravaBackfill(): Promise<{
enqueued: number;
skipped: number;
error: string | null;
}> {
const boss = getGlobalBoss();
if (!boss) {
return { enqueued: 0, skipped: 0, error: null };
}
try {
const connections = await prisma.user.findMany({
where: {
stravaAccessTokenEncrypted: { not: null },
stravaBackfillCompletedAt: null,
},
select: { id: true },
});
if (connections.length === 0) {
return { enqueued: 0, skipped: 0, error: null };
}
let enqueued = 0;
let skipped = 0;
for (const { id: userId } of connections) {
const payload: StravaBackfillPayload = {
userId,
enqueuedAt: new Date().toISOString(),
};
const jobId = await boss.send(STRAVA_BACKFILL_QUEUE, payload, {
retryLimit: 3,
retryDelay: 60,
retryBackoff: true,
singletonKey: `strava-backfill|${userId}`,
});
if (jobId) {
enqueued += 1;
} else {
skipped += 1;
}
}
return { enqueued, skipped, error: null };
} catch (err) {
return {
enqueued: 0,
skipped: 0,
error: err instanceof Error ? err.message : String(err),
};
}
}
+1 -1
View File
@@ -32,7 +32,7 @@
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
/** Providers that share this signed-state machinery. */
export type OAuthStateProvider = "polar" | "oura";
export type OAuthStateProvider = "polar" | "oura" | "strava";
/** 10-minute TTL — long enough to approve a consent screen, short enough that
* an abandoned handshake's token is useless within the hour. Mirrors the
+7
View File
@@ -57,6 +57,13 @@ export const integrationKeys = {
oura: () => ["oura"] as const,
ouraStatus: () => ["oura", "status"] as const,
// v1.28.x — Strava OAuth integration card. Reads status off the consolidated
// /api/integrations/status envelope (like WHOOP/Fitbit/Polar/Oura); the
// connect/disconnect/credentials mutations invalidate this key so the card
// repaints.
strava: () => ["strava"] as const,
stravaStatus: () => ["strava", "status"] as const,
moodlogStatus: () => ["moodlog-status"] as const,
integrationsStatus: () => ["integrations", "status"] as const,
@@ -99,6 +99,12 @@ export const DEFAULT_WORKOUT_SOURCE_PRIORITY: readonly MeasurementSource[] = [
// session.
"FITBIT",
"WITHINGS",
// v1.28.x — Strava ranks below the device-native captures (Apple/WHOOP/
// Fitbit/Withings on-wrist HR) but above MANUAL/IMPORT: a Strava activity is
// often itself a re-upload of an Apple/Garmin recording, so its HR/calories
// are lower-fidelity or absent on the summary. A user who records primarily
// on Strava (phone GPS) can promote it in Settings → Sources.
"STRAVA",
"MANUAL",
"IMPORT",
] as const;
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import {
mapActivity,
summaryHasHeartRate,
type StravaSummaryActivity,
type StravaDetailedActivity,
} from "../client";
import { classifyStravaResponse } from "../response-classifier";
const baseSummary: StravaSummaryActivity = {
id: 123456,
name: "Morning Run",
distance: 5000,
moving_time: 1500,
elapsed_time: 1600,
total_elevation_gain: 42,
type: "Run",
sport_type: "TrailRun",
start_date: "2026-07-01T06:30:00Z",
};
describe("mapActivity", () => {
it("maps a summary-only activity (no detail → no calories)", () => {
const row = mapActivity({
...baseSummary,
average_heartrate: 148,
max_heartrate: 171,
});
expect(row).not.toBeNull();
expect(row!.externalId).toBe("123456");
expect(row!.sportType).toBe("TrailRun");
expect(row!.startedAt.toISOString()).toBe("2026-07-01T06:30:00.000Z");
// endedAt = start + elapsed_time (clock window), not moving_time.
expect(row!.endedAt.toISOString()).toBe("2026-07-01T06:56:40.000Z");
// durationSec prefers moving_time.
expect(row!.durationSec).toBe(1500);
expect(row!.totalDistanceM).toBe(5000);
expect(row!.elevationM).toBe(42);
expect(row!.avgHeartRate).toBe(148);
expect(row!.maxHeartRate).toBe(171);
// No detail → calories null.
expect(row!.totalEnergyKcal).toBeNull();
});
it("fills calories + HR from the DetailedActivity when the summary lacks them", () => {
const summary: StravaSummaryActivity = { ...baseSummary };
delete (summary as unknown as Record<string, unknown>).average_heartrate;
const detail: StravaDetailedActivity = {
...baseSummary,
average_heartrate: 150,
max_heartrate: 175,
calories: 480,
};
const row = mapActivity(summary, detail);
expect(row!.totalEnergyKcal).toBe(480);
expect(row!.avgHeartRate).toBe(150);
expect(row!.maxHeartRate).toBe(175);
});
it("keeps the activity name in metadata as data, never surfaced as a field", () => {
const row = mapActivity(baseSummary);
const meta = row!.metadata as Record<string, unknown>;
expect(meta.stravaName).toBe("Morning Run");
// The row shape has no top-level name/description — free-text stays in meta.
expect((row as unknown as Record<string, unknown>).name).toBeUndefined();
});
it("falls back to `type` then a generic label for the sport", () => {
const noSport: StravaSummaryActivity = { ...baseSummary };
delete (noSport as unknown as Record<string, unknown>).sport_type;
expect(mapActivity(noSport)!.sportType).toBe("Run");
const noneAtAll: StravaSummaryActivity = { ...baseSummary };
delete (noneAtAll as unknown as Record<string, unknown>).sport_type;
delete (noneAtAll as unknown as Record<string, unknown>).type;
expect(mapActivity(noneAtAll)!.sportType).toBe("workout");
});
it("returns null for an activity with no id or no start instant", () => {
expect(
mapActivity({ ...baseSummary, id: undefined as unknown as number }),
).toBeNull();
const noStart: StravaSummaryActivity = { ...baseSummary };
delete (noStart as unknown as Record<string, unknown>).start_date;
expect(mapActivity(noStart)).toBeNull();
});
it("drops negative / non-finite numeric fields rather than storing them", () => {
const row = mapActivity({
...baseSummary,
distance: -1,
average_heartrate: -5,
});
expect(row!.totalDistanceM).toBeNull();
expect(row!.avgHeartRate).toBeNull();
});
});
describe("summaryHasHeartRate", () => {
it("detects HR presence via flag or values", () => {
expect(summaryHasHeartRate({ id: 1, has_heartrate: true })).toBe(true);
expect(summaryHasHeartRate({ id: 1, average_heartrate: 140 })).toBe(true);
expect(summaryHasHeartRate({ id: 1 })).toBe(false);
});
});
describe("classifyStravaResponse", () => {
it("classifies the status classes the sync depends on", () => {
expect(classifyStravaResponse(200).classification).toBe("success");
expect(classifyStravaResponse(401).classification).toBe("reauth_required");
expect(classifyStravaResponse(429).classification).toBe("transient");
expect(classifyStravaResponse(500).classification).toBe("transient");
});
});
@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const findUnique = vi.fn();
const updateMany = vi.fn();
const getStravaCredentials = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: (...args: unknown[]) => findUnique(...args),
updateMany: (...args: unknown[]) => updateMany(...args),
},
},
}));
vi.mock("@/lib/crypto", () => ({
decrypt: (cipher: string) => `dec(${cipher})`,
encrypt: (plain: string) => `enc(${plain})`,
}));
vi.mock("../client", () => ({
getStravaCredentials: () => getStravaCredentials(),
}));
import { getStravaClientCredentials, storeStravaTokens } from "../credentials";
describe("getStravaClientCredentials — DB-first then env", () => {
beforeEach(() => {
findUnique.mockReset();
getStravaCredentials.mockReset();
});
it("returns the per-user BYO pair when both columns are set", async () => {
findUnique.mockResolvedValue({
stravaClientIdEncrypted: "enc-id",
stravaClientSecretEncrypted: "enc-secret",
});
const creds = await getStravaClientCredentials("user-1");
expect(creds).toEqual({
clientId: "dec(enc-id)",
clientSecret: "dec(enc-secret)",
});
expect(getStravaCredentials).not.toHaveBeenCalled();
});
it("falls back to the shared env app when the user has no BYO pair", async () => {
findUnique.mockResolvedValue({
stravaClientIdEncrypted: null,
stravaClientSecretEncrypted: null,
});
getStravaCredentials.mockReturnValue({
clientId: "env-id",
clientSecret: "env-secret",
});
const creds = await getStravaClientCredentials("user-1");
expect(creds).toEqual({ clientId: "env-id", clientSecret: "env-secret" });
expect(getStravaCredentials).toHaveBeenCalledOnce();
});
});
describe("storeStravaTokens — rotating-refresh compare-and-swap", () => {
beforeEach(() => {
findUnique.mockReset();
updateMany.mockReset();
});
it("persists and returns the fresh access token when the CAS wins", async () => {
updateMany.mockResolvedValue({ count: 1 });
const token = await storeStravaTokens(
"user-1",
"fresh-access",
"fresh-refresh",
"stored-refresh-ciphertext",
);
expect(token).toBe("fresh-access");
// The guard keys on the exact stored ciphertext, not a re-encryption.
expect(updateMany).toHaveBeenCalledWith({
where: {
id: "user-1",
stravaRefreshTokenEncrypted: "stored-refresh-ciphertext",
},
data: {
stravaAccessTokenEncrypted: "enc(fresh-access)",
stravaRefreshTokenEncrypted: "enc(fresh-refresh)",
},
});
});
it("returns the peer's rotated access token when the CAS loses the race", async () => {
updateMany.mockResolvedValue({ count: 0 });
findUnique.mockResolvedValue({
stravaAccessTokenEncrypted: "peer-access-cipher",
});
const token = await storeStravaTokens(
"user-1",
"fresh-access",
"fresh-refresh",
"stale-ciphertext",
);
expect(token).toBe("dec(peer-access-cipher)");
});
it("returns null when the connection vanished mid-race", async () => {
updateMany.mockResolvedValue({ count: 0 });
findUnique.mockResolvedValue({ stravaAccessTokenEncrypted: null });
const token = await storeStravaTokens(
"user-1",
"fresh-access",
"fresh-refresh",
"stale-ciphertext",
);
expect(token).toBeNull();
});
});
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import { DEFAULT_WORKOUT_SOURCE_PRIORITY } from "@/lib/sources/pick-canonical-workout";
import { DEFAULT_SOURCE_PRIORITY } from "@/lib/validations/source-priority";
/**
* v1.28.x — Strava rides the SAME source-agnostic workout dedup engine as every
* other source: adding `STRAVA` to the ladders is all that is needed for an
* Apple-Watch + Strava twin of one run to collapse to a single canonical row.
* No Strava-specific dedup path exists (nor should it).
*/
const D = (iso: string) => new Date(iso);
describe("STRAVA workout dedup — read-time canonical picker", () => {
it("collapses an Apple-Health + Strava twin of the same run to the device row", () => {
const rows = [
{
startedAt: D("2026-07-01T06:30:00Z"),
sportType: "running",
source: "APPLE_HEALTH" as const,
},
// Same run, re-uploaded to Strava a minute later — same sport, same
// 5-minute slot.
{
startedAt: D("2026-07-01T06:31:00Z"),
sportType: "running",
source: "STRAVA" as const,
},
];
const canonical = pickCanonicalWorkoutRows(rows);
expect(canonical).toHaveLength(1);
// The device-native capture outranks the Strava re-upload.
expect(canonical[0].source).toBe("APPLE_HEALTH");
});
it("keeps a Strava-only run (no competing source) untouched", () => {
const rows = [
{
startedAt: D("2026-07-02T18:00:00Z"),
sportType: "cycling",
source: "STRAVA" as const,
},
];
const canonical = pickCanonicalWorkoutRows(rows);
expect(canonical).toHaveLength(1);
expect(canonical[0].source).toBe("STRAVA");
});
it("ranks STRAVA above MANUAL when both cover the same workout", () => {
const rows = [
{
startedAt: D("2026-07-03T07:00:00Z"),
sportType: "running",
source: "MANUAL" as const,
},
{
startedAt: D("2026-07-03T07:00:30Z"),
sportType: "running",
source: "STRAVA" as const,
},
];
const canonical = pickCanonicalWorkoutRows(rows);
expect(canonical).toHaveLength(1);
expect(canonical[0].source).toBe("STRAVA");
});
});
describe("STRAVA sits in the workout source ladders", () => {
it("is present in the workout default ladder, below the wearables, above MANUAL", () => {
const strava = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("STRAVA");
const withings = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("WITHINGS");
const manual = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("MANUAL");
expect(strava).toBeGreaterThan(withings);
expect(strava).toBeLessThan(manual);
});
it("is present in the `steps` ladder the read picker resolves against", () => {
const steps = DEFAULT_SOURCE_PRIORITY.steps;
const strava = steps.indexOf("STRAVA");
const apple = steps.indexOf("APPLE_HEALTH");
const manual = steps.indexOf("MANUAL");
expect(strava).toBeGreaterThan(apple);
expect(strava).toBeLessThan(manual);
});
});
+415
View File
@@ -0,0 +1,415 @@
/**
* v1.28.x — Strava API v3 client for OAuth + activity fetching.
* Docs: https://developers.strava.com/docs/reference/ (re-verify at build).
*
* Mirrors the WHOOP / Polar / Oura client structure: hand-rolled fetch over
* `safeFetch` (no vendor SDK), an OAuth handshake (`getAuthorizationUrl` /
* `exchangeCode` / `refreshAccessToken`), typed collection fetchers, and a
* single field → `Workout` mapper.
*
* Strava specifics:
* - It is a WORKOUT source only — the API is activity-centric and exposes no
* sleep / recovery / body / glucose data. It feeds `Workout` rows and only
* `Workout` rows. No training-depth analytics (FTP/TSS/power-curve) are
* derived — Strava is just another workout source.
* - It ROTATES its refresh token on every refresh, so the sync persists BOTH
* rotated tokens (the Oura reactive-refresh + compare-and-set model). There
* is no token-expiry column; the sync refreshes reactively on a 401.
* - The Activities list (`SummaryActivity`) carries distance / moving_time /
* elapsed_time / elevation / HR, but `calories` lives only on the
* per-activity `DetailedActivity` (`GET /activities/{id}`).
* - Rate limits: 200 requests / 15 min and 2000 / day per app. The sync walks
* bounded pages and fetches detail budget-aware.
*/
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import type { Prisma } from "@/generated/prisma/client";
import { StravaApiError, classifyStravaResponse } from "./response-classifier";
const STRAVA_API_BASE = "https://www.strava.com/api/v3";
const STRAVA_OAUTH_AUTH_URL = "https://www.strava.com/oauth/authorize";
const STRAVA_OAUTH_TOKEN_URL = "https://www.strava.com/oauth/token";
const STRAVA_DEAUTHORIZE_URL = "https://www.strava.com/oauth/deauthorize";
export interface StravaCredentials {
clientId: string;
clientSecret: string;
}
/**
* Env-configured shared OAuth credentials — the FALLBACK app used when a user
* has not registered their own BYO Strava client id/secret. The per-user
* resolver `getStravaClientCredentials` (DB-first) is the primary path and
* calls this only when no per-user pair is stored.
*/
export function getStravaCredentials(): StravaCredentials | null {
const clientId = process.env.STRAVA_CLIENT_ID;
const clientSecret = process.env.STRAVA_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
return { clientId, clientSecret };
}
export function getStravaRedirectUri(): string {
// `||`, not `??`: the compose whitelist materialises an unset var as an empty
// string, which must still fall through to the derived URI.
return (
process.env.STRAVA_REDIRECT_URI ||
`${process.env.NEXT_PUBLIC_APP_URL}/api/strava/callback`
);
}
/**
* Strava OAuth scope. `activity:read_all` reads every activity including those
* marked "only me" (`activity:read` omits private ones). Comma-separated on the
* wire per Strava's authorization endpoint.
*/
export const STRAVA_OAUTH_SCOPE = "activity:read_all" as const;
export function getAuthorizationUrl(
state: string,
creds: StravaCredentials,
): string {
const params = new URLSearchParams({
client_id: creds.clientId,
response_type: "code",
redirect_uri: getStravaRedirectUri(),
approval_prompt: "auto",
scope: STRAVA_OAUTH_SCOPE,
state,
});
return `${STRAVA_OAUTH_AUTH_URL}?${params}`;
}
/** Strava's `SummaryAthlete` — only the numeric id is retained (the granted
* account pin + webhook fan-out key). */
export interface StravaAthlete {
id?: number | null;
}
export interface StravaTokenResponse {
access_token: string;
refresh_token: string;
expires_at?: number;
expires_in?: number;
token_type?: string;
athlete?: StravaAthlete | null;
}
async function postToken(
params: URLSearchParams,
verb: string,
): Promise<StravaTokenResponse> {
const start = performance.now();
const res = await safeFetch(STRAVA_OAUTH_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: params.toString(),
});
const json = await res.json().catch(() => null);
const verdict = classifyStravaResponse(res.status);
getEvent()?.addExternalCall({
service: "strava",
method: verb,
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw new StravaApiError({
verb,
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
upstreamError:
typeof json?.message === "string" ? json.message : undefined,
});
}
return json as StravaTokenResponse;
}
export async function exchangeCode(
code: string,
creds: StravaCredentials,
): Promise<StravaTokenResponse> {
return postToken(
new URLSearchParams({
client_id: creds.clientId,
client_secret: creds.clientSecret,
code,
grant_type: "authorization_code",
}),
"exchangeCode",
);
}
export async function refreshAccessToken(
refreshToken: string,
creds: StravaCredentials,
): Promise<StravaTokenResponse> {
return postToken(
new URLSearchParams({
client_id: creds.clientId,
client_secret: creds.clientSecret,
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
"refreshAccessToken",
);
}
/**
* Best-effort deauthorize at Strava on disconnect. Failure is swallowed by the
* caller — the local grant is cleared regardless, so a Strava-side outage never
* strands the user with a card that refuses to disconnect.
*/
export async function deauthorize(accessToken: string): Promise<void> {
const start = performance.now();
const res = await safeFetch(STRAVA_DEAUTHORIZE_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
});
getEvent()?.addExternalCall({
service: "strava",
method: "deauthorize",
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: res.ok ? undefined : `http_${res.status}`,
});
}
// ─── Activity reads (v3) ───────────────────────────────────────
/** Strava `SummaryActivity` — the list-endpoint shape. Distances are metres,
* durations seconds, `start_date` a UTC ISO-8601 instant. HR / power fields are
* present when the activity recorded them; `calories` is NOT (detail only). */
export interface StravaSummaryActivity {
id: number;
name?: string | null;
distance?: number | null;
moving_time?: number | null;
elapsed_time?: number | null;
total_elevation_gain?: number | null;
type?: string | null;
sport_type?: string | null;
start_date?: string | null;
start_date_local?: string | null;
average_heartrate?: number | null;
max_heartrate?: number | null;
has_heartrate?: boolean | null;
average_watts?: number | null;
weighted_average_watts?: number | null;
device_watts?: boolean | null;
average_cadence?: number | null;
trainer?: boolean | null;
commute?: boolean | null;
gear_id?: string | null;
}
/** Strava `DetailedActivity` — the per-activity shape. Superset of the summary
* that additionally carries `calories` (kcal). */
export interface StravaDetailedActivity extends StravaSummaryActivity {
calories?: number | null;
description?: string | null;
}
export interface FetchActivitiesQuery {
/** Epoch SECONDS cursor — return activities started after this instant. */
after?: number;
page?: number;
perPage?: number;
}
async function stravaGet<T>(
path: string,
accessToken: string,
verb: string,
): Promise<T> {
const start = performance.now();
const res = await safeFetch(`${STRAVA_API_BASE}${path}`, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
});
const verdict = classifyStravaResponse(res.status);
getEvent()?.addExternalCall({
service: "strava",
method: verb,
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw new StravaApiError({
verb,
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
});
}
return (await res.json().catch(() => null)) as T;
}
/** One page of the athlete's activities (newest first). */
export async function fetchActivities(
accessToken: string,
query: FetchActivitiesQuery = {},
): Promise<StravaSummaryActivity[]> {
const params = new URLSearchParams();
if (typeof query.after === "number")
params.set("after", String(Math.floor(query.after)));
params.set("page", String(query.page ?? 1));
params.set("per_page", String(query.perPage ?? 100));
const json = await stravaGet<StravaSummaryActivity[] | null>(
`/athlete/activities?${params}`,
accessToken,
"fetchActivities",
);
return Array.isArray(json) ? json : [];
}
/** One activity's `DetailedActivity` — the only source of `calories`. */
export function fetchActivityById(
accessToken: string,
id: number | string,
): Promise<StravaDetailedActivity> {
return stravaGet<StravaDetailedActivity>(
`/activities/${encodeURIComponent(String(id))}?include_all_efforts=false`,
accessToken,
"fetchActivityById",
);
}
// ─── Activity → Workout mapping ────────────────────────────────
/** The `Workout`-row payload a mapped Strava activity produces. Field names
* mirror `prisma.workout` columns; the sync layer stamps `source = STRAVA` +
* `externalId` and upserts on `(userId, source, externalId)`. */
export interface StravaWorkoutRow {
externalId: string;
sportType: string;
startedAt: Date;
endedAt: Date;
durationSec: number;
totalEnergyKcal: number | null;
totalDistanceM: number | null;
avgHeartRate: number | null;
maxHeartRate: number | null;
elevationM: number | null;
metadata: Prisma.InputJsonValue;
}
function nonNegInt(n: number | null | undefined): number | null {
if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return null;
return Math.round(n);
}
function nonNegFloat(n: number | null | undefined): number | null {
if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return null;
return n;
}
/**
* Map one Strava activity into a `Workout` row. `sport_type` is preferred over
* the legacy `type`; free-text at the DB layer. Duration uses `moving_time`
* (active duration) with `elapsed_time` kept in `metadata`. HR / calories come
* from the summary when present, else from the optional `DetailedActivity`.
*
* The activity `name` / `description` are the user's own free-text DATA — they
* are stored in `metadata` only and NEVER interpolated into any prompt as an
* instruction (the MCP/coach free-text rule). Power / cadence / commute flags
* ride `metadata` for provenance but are NOT surfaced as training analytics.
* Returns null for an activity with no id / start instant (nothing to store).
*/
export function mapActivity(
summary: StravaSummaryActivity,
detail?: StravaDetailedActivity | null,
): StravaWorkoutRow | null {
if (typeof summary.id !== "number") return null;
const startIso = summary.start_date ?? detail?.start_date ?? null;
if (!startIso) return null;
const startedAt = new Date(startIso);
if (Number.isNaN(startedAt.getTime())) return null;
const elapsedSec = nonNegInt(summary.elapsed_time ?? detail?.elapsed_time);
const movingSec = nonNegInt(summary.moving_time ?? detail?.moving_time);
const durationSec = movingSec ?? elapsedSec ?? 0;
// The clock window is elapsed time (moving time excludes pauses); fall back to
// moving time when elapsed is absent so `endedAt` is never before `startedAt`.
const endedAt = new Date(
startedAt.getTime() + (elapsedSec ?? durationSec) * 1000,
);
const avgHeartRate = nonNegInt(
summary.average_heartrate ?? detail?.average_heartrate,
);
const maxHeartRate = nonNegInt(
summary.max_heartrate ?? detail?.max_heartrate,
);
const calories = nonNegFloat(detail?.calories);
const sportType =
summary.sport_type ?? summary.type ?? detail?.sport_type ?? "workout";
const metadata: Prisma.InputJsonValue = {
// Activity name/description are user free-text → DATA, never instructions.
...(summary.name ? { stravaName: summary.name } : {}),
...(detail?.description ? { stravaDescription: detail.description } : {}),
...(elapsedSec != null ? { elapsedTimeSec: elapsedSec } : {}),
...(summary.type ? { stravaType: summary.type } : {}),
...(typeof summary.average_watts === "number"
? { averageWatts: summary.average_watts }
: {}),
...(typeof summary.weighted_average_watts === "number"
? { weightedAverageWatts: summary.weighted_average_watts }
: {}),
...(typeof summary.device_watts === "boolean"
? { deviceWatts: summary.device_watts }
: {}),
...(typeof summary.average_cadence === "number"
? { averageCadence: summary.average_cadence }
: {}),
...(typeof summary.trainer === "boolean"
? { trainer: summary.trainer }
: {}),
...(typeof summary.commute === "boolean"
? { commute: summary.commute }
: {}),
...(summary.gear_id ? { gearId: summary.gear_id } : {}),
};
return {
externalId: String(summary.id),
sportType,
startedAt,
endedAt,
durationSec,
totalEnergyKcal: calories,
totalDistanceM: nonNegFloat(summary.distance ?? detail?.distance),
avgHeartRate,
maxHeartRate,
elevationM: nonNegFloat(
summary.total_elevation_gain ?? detail?.total_elevation_gain,
),
metadata,
};
}
/** True when the summary already carries HR + calories are irrelevant — i.e.
* the summary alone is enough and a detail fetch can be skipped. Strava never
* puts `calories` on the summary, so a detail fetch is what fills it; callers
* use this only to decide whether HR is already covered. */
export function summaryHasHeartRate(a: StravaSummaryActivity): boolean {
return (
a.has_heartrate === true ||
typeof a.average_heartrate === "number" ||
typeof a.max_heartrate === "number"
);
}
+145
View File
@@ -0,0 +1,145 @@
/**
* 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;
},
});
}
+56
View File
@@ -0,0 +1,56 @@
/**
* v1.28.x — off-response classifier for Strava API v3 replies.
* HTTP-status-driven; the shared core lives in
* `src/lib/integrations/http-status-classifier.ts` and is re-exported here
* under the Strava-specific names callers already use.
*
* - `success` → HTTP 2xx
* - `transient` → 429 (rate-limited — respect X-RateLimit headers),
* 5xx, 3xx, unknown.
* - `reauth_required` → 401 / 403 — token rejected / grant revoked, plus an
* `invalid_grant` on the refresh endpoint.
* - `persistent` → any other 4xx — malformed request / contract drift.
*/
import {
classifyHttpStatus,
classifyIntegrationError,
IntegrationApiError,
isOAuthInvalidGrant,
type ClassifiedHttpResponse,
type IntegrationClassification,
} from "@/lib/integrations/http-status-classifier";
export function classifyStravaResponse(
httpStatus: number,
): ClassifiedHttpResponse {
return classifyHttpStatus(httpStatus);
}
export class StravaApiError extends IntegrationApiError {
constructor(opts: {
verb: string;
classification: IntegrationClassification;
httpStatus: number | undefined;
reason: string;
upstreamError?: string;
}) {
super({ vendor: "Strava", ...opts });
this.name = "StravaApiError";
}
}
/**
* True when a caught error is an OAuth `invalid_grant` on the token endpoint —
* the canonical revoked / rotated-away refresh-token signal. Lifts a 400
* carrying `invalid_grant` from `persistent` to `reauth_required` so the
* connection surfaces the reconnect prompt.
*/
function isInvalidGrant(err: unknown): boolean {
return isOAuthInvalidGrant(err, "Strava");
}
export function classifyStravaError(err: unknown): IntegrationClassification {
if (isInvalidGrant(err)) return "reauth_required";
return classifyIntegrationError(err, "Strava");
}
+291
View File
@@ -0,0 +1,291 @@
/**
* v1.28.x — Strava API v3 sync.
*
* Pulls the athlete's activities and upserts each into the `Workout` table as
* `source = STRAVA`, keyed on `(userId, source, externalId)` so a re-fetch /
* `activity.update` overwrites in place. Strava is a WORKOUT source only — it
* exposes no daily metrics, so unlike the Oura/Polar sync this writes `Workout`
* rows and stops (no measurement rollup fold).
*
* Cross-source dedup is NOT done here: a Strava run and the same run via Apple
* Health remain two distinct `Workout` rows (different `source`); the read-time
* `pickCanonicalWorkoutRows` picker collapses the cross-source twin at read
* time via the user's source-priority ladder. One engine, no parallel path.
*
* Token model: Strava ROTATES its refresh token on every refresh. The schema
* has no expiry column, so the sync refreshes REACTIVELY — the first read that
* 401s triggers one refresh (persisting BOTH rotated tokens via a compare-and-
* set) and a single retry. A failed refresh (`invalid_grant`) records
* `reauth_required` on the `strava` ledger.
*
* Rate limits: 200 req / 15 min + 2000 / day per app. The walk is page-bounded
* and the per-activity detail fetch (for `calories`, absent from the summary)
* is budget-capped; a 429 mid-walk records a transient failure and the next
* cron tick resumes from the cursor.
*/
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import {
recordSyncFailure,
recordSyncSuccess,
toFailureKind,
type FailureKind,
} from "@/lib/integrations/status";
import {
fetchActivities,
fetchActivityById,
mapActivity,
refreshAccessToken,
summaryHasHeartRate,
type StravaDetailedActivity,
type StravaWorkoutRow,
} from "./client";
import {
getStravaClientCredentials,
getStravaConnection,
storeStravaTokens,
} from "./credentials";
import { StravaApiError, classifyStravaError } from "./response-classifier";
/** Default first-incremental lookback (days) when no cursor exists yet. */
export const STRAVA_SYNC_LOOKBACK_DAYS = 30;
/** Overlap (ms) subtracted from the stored cursor on an incremental sync so an
* activity uploaded late (started before the cursor, synced after) is still
* caught. The upsert is idempotent, so re-fetching the overlap window is cheap. */
export const STRAVA_SYNC_OVERLAP_MS = 24 * 60 * 60 * 1000;
const PER_PAGE = 100;
const INCREMENTAL_MAX_PAGES = 5;
const BACKFILL_MAX_PAGES = 40;
/** Per-activity detail calls (for `calories`) are capped per sync to stay well
* under Strava's 200-req / 15-min ceiling on a heavy backfill. */
const INCREMENTAL_DETAIL_BUDGET = 100;
const BACKFILL_DETAIL_BUDGET = 150;
export function classifyStravaFailure(err: unknown): FailureKind {
return toFailureKind(classifyStravaError(err));
}
export interface SyncUserStravaOptions {
fullSync?: boolean;
lookbackDays?: number;
}
/**
* Sync one user's Strava activities. Returns the count of `Workout` rows
* written. A user with no Strava connection is a clean no-op (returns 0,
* touches no status row).
*/
export async function syncUserStrava(
userId: string,
opts: SyncUserStravaOptions = {},
): Promise<number> {
const conn = await getStravaConnection(userId);
if (!conn) return 0;
const fullSync = opts.fullSync ?? false;
// Resolve the incremental cursor. On a full backfill we walk all history
// (`after` omitted). Otherwise start at the stored newest-activity instant
// minus the overlap, or a default lookback when no cursor exists yet.
let afterEpoch: number | undefined;
if (!fullSync) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { stravaLastActivityAt: true },
});
const lookbackMs =
(opts.lookbackDays ?? STRAVA_SYNC_LOOKBACK_DAYS) * 24 * 60 * 60 * 1000;
const base = user?.stravaLastActivityAt
? user.stravaLastActivityAt.getTime() - STRAVA_SYNC_OVERLAP_MS
: Date.now() - lookbackMs;
afterEpoch = Math.floor(base / 1000);
}
// Reactive refresh state — one refresh per sync, shared across every call.
let accessToken = conn.accessToken;
let refreshDone = false;
async function refreshOnce(): Promise<boolean> {
if (refreshDone) return false;
const creds = await getStravaClientCredentials(userId);
if (!creds) return false;
const rotated = await refreshAccessToken(conn!.refreshToken, creds);
const usable = await storeStravaTokens(
userId,
rotated.access_token,
rotated.refresh_token,
conn!.refreshTokenCiphertext,
);
if (!usable) return false;
accessToken = usable;
refreshDone = true;
return true;
}
async function authed<T>(fn: (token: string) => Promise<T>): Promise<T> {
try {
return await fn(accessToken);
} catch (err) {
if (
err instanceof StravaApiError &&
err.httpStatus === 401 &&
(await refreshOnce())
) {
return fn(accessToken);
}
throw err;
}
}
const maxPages = fullSync ? BACKFILL_MAX_PAGES : INCREMENTAL_MAX_PAGES;
let detailBudget = fullSync
? BACKFILL_DETAIL_BUDGET
: INCREMENTAL_DETAIL_BUDGET;
const rows: StravaWorkoutRow[] = [];
let newestStart: Date | null = null;
try {
for (let page = 1; page <= maxPages; page++) {
const activities = await authed((t) =>
fetchActivities(t, { after: afterEpoch, page, perPage: PER_PAGE }),
);
if (activities.length === 0) break;
for (const a of activities) {
// Fetch detail only to fill `calories` (absent from the summary), and
// only while the budget allows. When the summary already lacks HR we
// also let the detail top it up. A detail failure is non-fatal — map
// from the summary — but a 429 aborts the whole walk (transient).
let detail: StravaDetailedActivity | null = null;
if (detailBudget > 0) {
try {
detail = await authed((t) => fetchActivityById(t, a.id));
detailBudget -= 1;
} catch (err) {
if (err instanceof StravaApiError && err.httpStatus === 429) {
throw err;
}
getEvent()?.addWarning(
`strava: activity detail fetch failed for ${a.id}: ${err}`,
);
detail = null;
}
} else if (!summaryHasHeartRate(a)) {
getEvent()?.addWarning(
`strava: detail budget exhausted; mapping activity ${a.id} from summary only`,
);
}
const row = mapActivity(a, detail);
if (row) {
rows.push(row);
if (a.start_date) {
const d = new Date(a.start_date);
if (
!Number.isNaN(d.getTime()) &&
(!newestStart || d > newestStart)
) {
newestStart = d;
}
}
}
}
if (activities.length < PER_PAGE) break;
}
} catch (err) {
await recordSyncFailure({
userId,
integration: "strava",
kind: classifyStravaFailure(err),
message: err instanceof Error ? err.message : String(err),
errorCode:
err instanceof StravaApiError && err.httpStatus != null
? String(err.httpStatus)
: undefined,
});
throw err;
}
const imported = await upsertStravaWorkouts(userId, rows);
// Advance the incremental cursor to the newest activity start we saw, but
// never move it backwards (a bounded backfill page-walk may not reach the
// very newest activity in one pass).
if (newestStart) {
await prisma.user.updateMany({
where: {
id: userId,
OR: [
{ stravaLastActivityAt: null },
{ stravaLastActivityAt: { lt: newestStart } },
],
},
data: { stravaLastActivityAt: newestStart },
});
}
await recordSyncSuccess(userId, "strava");
annotate({
action: { name: "strava.sync.complete", details: { imported } },
});
return imported;
}
/**
* Upsert a batch of mapped Strava activities into `Workout`, keyed on
* `(userId, source = STRAVA, externalId)` so a re-fetch overwrites in place.
* Best-effort per row: a single bad row is logged, never thrown, so it can't
* fail the surrounding sync.
*/
export async function upsertStravaWorkouts(
userId: string,
rows: readonly StravaWorkoutRow[],
): Promise<number> {
if (rows.length === 0) return 0;
let imported = 0;
for (const r of rows) {
const data = {
sportType: r.sportType,
startedAt: r.startedAt,
endedAt: r.endedAt,
durationSec: r.durationSec,
totalEnergyKcal: r.totalEnergyKcal,
totalDistanceM: r.totalDistanceM,
avgHeartRate: r.avgHeartRate,
maxHeartRate: r.maxHeartRate,
elevationM: r.elevationM,
metadata: r.metadata,
};
try {
await prisma.workout.upsert({
where: {
userId_source_externalId: {
userId,
source: "STRAVA",
externalId: r.externalId,
},
},
create: {
userId,
source: "STRAVA",
externalId: r.externalId,
...data,
},
update: data,
});
imported += 1;
} catch (err) {
getEvent()?.addWarning(`strava: failed to upsert workout: ${err}`);
}
}
annotate({
action: { name: "strava.workout.ingest", details: { imported } },
});
return imported;
}
+7
View File
@@ -215,6 +215,13 @@ export const measurementSourceEnum = z.enum([
// deliberately absent from `WRITABLE_MEASUREMENT_SOURCES` + the batch
// allowlist so a client can never forge a GOOGLE_HEALTH-attributed row.
"GOOGLE_HEALTH",
// v1.28.x — Strava OAuth integration. Native Strava activities ingest
// server-side as `source = STRAVA` Workout rows (no client write path),
// exactly like WHOOP/FITBIT. Part of this enum so the read/response shapes
// (and the iOS decoder) can decode the rows it surfaces; deliberately absent
// from `WRITABLE_MEASUREMENT_SOURCES` + the batch allowlist so a client can
// never forge a STRAVA-attributed row.
"STRAVA",
]);
/**
+6
View File
@@ -238,6 +238,11 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
// successor API to the classic Fitbit Web API); it rides in the same
// wrist-wearable class as FITBIT and ranks immediately below it in every
// device ladder, above OURA/POLAR and MANUAL.
// v1.28.x — the `steps` ladder is also the ladder the cross-source WORKOUT
// picker (`pickCanonicalWorkoutRows`) resolves against. STRAVA slots below the
// device-native wearables and above MANUAL so an Apple-Watch + Strava twin of
// the same run collapses onto the richer device row, while a Strava-only run
// still survives.
steps: [
"APPLE_HEALTH",
"WITHINGS",
@@ -245,6 +250,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"GOOGLE_HEALTH",
"OURA",
"POLAR",
"STRAVA",
"MANUAL",
],
activeEnergy: [
+17
View File
@@ -0,0 +1,17 @@
import { z } from "zod/v4";
/**
* Per-user Strava BYO-key credentials. Strava caps every newly-created API app
* at single-player mode (athlete capacity 1), so a single shared app cannot
* serve many self-hosters — each registers their own Strava 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 stravaCredentialsSchema = z.object({
// Trimmed: a trailing space or newline from the portal's copy button reaches
// Strava verbatim and answers as "invalid client".
clientId: z.string().trim().min(1).max(200),
clientSecret: z.string().trim().min(1).max(200),
});
export type StravaCredentialsInput = z.infer<typeof stravaCredentialsSchema>;
+10 -1
View File
@@ -410,6 +410,15 @@ export function proxy(request: NextRequest) {
pathname.startsWith("/settings/integrations/whoop") ||
pathname.startsWith("/api/whoop/");
const whoopConnectSrc = isWhoopRoute ? " https://api.prod.whoop.com" : "";
// v1.28.x — `www.strava.com` gated to the Strava settings surface +
// `/api/strava/*`, mirroring the WHOOP gating shape. The Strava data client
// lives server-side and the OAuth handshake is a browser redirect (not a
// fetch), so this is belt-and-suspenders parity — no other surface ever needs
// to reach the Strava host from the browser.
const isStravaRoute =
pathname.startsWith("/settings/integrations/strava") ||
pathname.startsWith("/api/strava/");
const stravaConnectSrc = isStravaRoute ? " https://www.strava.com" : "";
// v1.5.5 — Gravatar host removed from `img-src`. The /me payload
// used to return `gravatarUrl: https://www.gravatar.com/avatar/<sha256(email)>`,
// which leaked the email digest to Automattic on every authenticated
@@ -433,7 +442,7 @@ export function proxy(request: NextRequest) {
`default-src 'none'; frame-ancestors 'self';`
: isDev
? `default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self';`
: `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'${aiConnectSrc}${withingsConnectSrc}${whoopConnectSrc}; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; worker-src 'self'; report-uri ${cspReportEndpoint}; report-to csp-endpoint;`;
: `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'${aiConnectSrc}${withingsConnectSrc}${whoopConnectSrc}${stravaConnectSrc}; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; worker-src 'self'; report-uri ${cspReportEndpoint}; report-to csp-endpoint;`;
response.headers.set("Content-Security-Policy", csp);
// Production-only headers. HSTS carries `preload` so the domain stays