diff --git a/.env.production.example b/.env.production.example
index 673c3be1a..9613948f2 100644
--- a/.env.production.example
+++ b/.env.production.example
@@ -86,6 +86,22 @@ API_TOKEN_HMAC_KEY=""
# OURA_CLIENT_SECRET=""
# OURA_REDIRECT_URI=""
+# -----------------------------------------------------------------------------
+# Strava -- optional integration (full OAuth, shared app from env)
+# -----------------------------------------------------------------------------
+# Register an app at https://www.strava.com/settings/api and set its
+# "Authorization Callback Domain" to your instance host. Both vars together
+# enable the Strava connect button; set neither to leave the integration
+# disabled. Per-user BYO client id/secret in Settings take precedence over these
+# shared env values. Strava caps every new app at single-player mode (athlete
+# capacity 1) until you request a capacity increase — fine for a personal
+# self-host. Strava rotates its refresh token on every refresh; the sync
+# refreshes reactively on a 401. STRAVA_REDIRECT_URI overrides the derived
+# `${NEXT_PUBLIC_APP_URL}/api/strava/callback`.
+# STRAVA_CLIENT_ID=""
+# STRAVA_CLIENT_SECRET=""
+# STRAVA_REDIRECT_URI=""
+
# -----------------------------------------------------------------------------
# Web Push -- VAPID keys (optional; the admin panel is the easier path)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 97619d7b2..c1217fd14 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,16 @@
## [Unreleased]
+## [1.28.11] — 2026-07-09 — Strava workouts + Garmin guidance
+
+Connect Strava to bring your activities in as workouts. Like the other wearable
+connections, you register your own Strava application and paste its keys, so no
+shared account is involved. A run that also arrives through Apple Health is not
+double-counted — the existing source-priority picker collapses the duplicate.
+Strava is a workout source only (no sleep, recovery, or body metrics). Also adds
+a Garmin page and an in-app note: Garmin has no direct connection, but its data
+flows in through Apple Health or Google Health Connect.
+
## [1.28.10] — 2026-07-09 — Onboarding shows the AI value early
New accounts now meet the AI value up front instead of discovering it as a cold
diff --git a/docker-compose.yml b/docker-compose.yml
index da3301513..5c866d457 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -131,6 +131,15 @@ services:
OURA_CLIENT_ID: "${OURA_CLIENT_ID:-}"
OURA_CLIENT_SECRET: "${OURA_CLIENT_SECRET:-}"
OURA_REDIRECT_URI: "${OURA_REDIRECT_URI:-}"
+ # v1.28.x — Strava OAuth (shared app from env; per-user BYO keys override).
+ # Both together enable the connect button; an unset pair leaves it
+ # disabled. STRAVA_REDIRECT_URI overrides the derived
+ # `${NEXT_PUBLIC_APP_URL}/api/strava/callback`. Listed here so operator
+ # values in .env reach the container (the `environment:` block is a
+ # whitelist).
+ STRAVA_CLIENT_ID: "${STRAVA_CLIENT_ID:-}"
+ STRAVA_CLIENT_SECRET: "${STRAVA_CLIENT_SECRET:-}"
+ STRAVA_REDIRECT_URI: "${STRAVA_REDIRECT_URI:-}"
# Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates
# POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the
# deployment-dashboard URL (e.g. the Coolify UI) linked in
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 2fde2063f..9f24c2809 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
- version: 1.28.10
+ version: 1.28.11
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -2243,6 +2243,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
- in: query
name: valueMin
schema:
@@ -4016,6 +4017,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
activeEnergy:
maxItems: 8
type: array
@@ -4035,6 +4037,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
walkingRunningDistance:
maxItems: 8
type: array
@@ -4054,6 +4057,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
flightsClimbed:
maxItems: 8
type: array
@@ -4073,6 +4077,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
sleep:
maxItems: 8
type: array
@@ -4092,6 +4097,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
weight:
maxItems: 8
type: array
@@ -4111,6 +4117,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
bloodPressure:
maxItems: 8
type: array
@@ -4130,6 +4137,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
pulse:
maxItems: 8
type: array
@@ -4149,6 +4157,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
bodyFat:
maxItems: 8
type: array
@@ -4168,6 +4177,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
bodyTemperature:
maxItems: 8
type: array
@@ -4187,6 +4197,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
spo2:
maxItems: 8
type: array
@@ -4206,6 +4217,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
hrv:
maxItems: 8
type: array
@@ -4225,6 +4237,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
restingHeartRate:
maxItems: 8
type: array
@@ -4244,6 +4257,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
vo2Max:
maxItems: 8
type: array
@@ -4263,6 +4277,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
skinTemperature:
maxItems: 8
type: array
@@ -4282,6 +4297,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
respiratoryRate:
maxItems: 8
type: array
@@ -4301,6 +4317,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
recovery:
maxItems: 8
type: array
@@ -4320,6 +4337,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
stress:
maxItems: 8
type: array
@@ -4339,6 +4357,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
sleepDebt:
maxItems: 8
type: array
@@ -4358,6 +4377,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
metricPriority:
type: object
properties:
@@ -4380,6 +4400,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
activeEnergy:
maxItems: 8
type: array
@@ -4399,6 +4420,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
walkingRunningDistance:
maxItems: 8
type: array
@@ -4418,6 +4440,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
flightsClimbed:
maxItems: 8
type: array
@@ -4437,6 +4460,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
sleep:
maxItems: 8
type: array
@@ -4456,6 +4480,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
weight:
maxItems: 8
type: array
@@ -4475,6 +4500,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
bloodPressure:
maxItems: 8
type: array
@@ -4494,6 +4520,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
pulse:
maxItems: 8
type: array
@@ -4513,6 +4540,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
bodyFat:
maxItems: 8
type: array
@@ -4532,6 +4560,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
bodyTemperature:
maxItems: 8
type: array
@@ -4551,6 +4580,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
spo2:
maxItems: 8
type: array
@@ -4570,6 +4600,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
hrv:
maxItems: 8
type: array
@@ -4589,6 +4620,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
restingHeartRate:
maxItems: 8
type: array
@@ -4608,6 +4640,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
vo2Max:
maxItems: 8
type: array
@@ -4627,6 +4660,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
skinTemperature:
maxItems: 8
type: array
@@ -4646,6 +4680,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
respiratoryRate:
maxItems: 8
type: array
@@ -4665,6 +4700,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
recovery:
maxItems: 8
type: array
@@ -4684,6 +4720,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
stress:
maxItems: 8
type: array
@@ -4703,6 +4740,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
sleepDebt:
maxItems: 8
type: array
@@ -4722,6 +4760,7 @@ paths:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
deviceTypePriority:
type: object
properties:
@@ -14677,6 +14716,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
notes:
anyOf:
- type: string
@@ -15434,6 +15474,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
notes:
anyOf:
- type: string
@@ -15679,6 +15720,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
- type: "null"
start:
type: string
@@ -15808,6 +15850,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
- type: "null"
start:
type: string
@@ -15988,6 +16031,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
description: v1.25.0 — the active source the debt is resolved FROM, picked off the user's `sleepDebt` source ladder.
`COMPUTED` is HealthLog's own rolling-balance estimate (the only producer today); a provider value would
be a device-native debt. Clients explain the figure when it is `COMPUTED`.
@@ -16203,6 +16247,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
externalId:
anyOf:
- type: string
@@ -16314,6 +16359,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
externalId:
anyOf:
- type: string
@@ -18931,6 +18977,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
activeEnergy:
maxItems: 8
type: array
@@ -18950,6 +18997,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
walkingRunningDistance:
maxItems: 8
type: array
@@ -18969,6 +19017,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
flightsClimbed:
maxItems: 8
type: array
@@ -18988,6 +19037,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
sleep:
maxItems: 8
type: array
@@ -19007,6 +19057,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
weight:
maxItems: 8
type: array
@@ -19026,6 +19077,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
bloodPressure:
maxItems: 8
type: array
@@ -19045,6 +19097,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
pulse:
maxItems: 8
type: array
@@ -19064,6 +19117,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
bodyFat:
maxItems: 8
type: array
@@ -19083,6 +19137,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
bodyTemperature:
maxItems: 8
type: array
@@ -19102,6 +19157,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
spo2:
maxItems: 8
type: array
@@ -19121,6 +19177,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
hrv:
maxItems: 8
type: array
@@ -19140,6 +19197,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
restingHeartRate:
maxItems: 8
type: array
@@ -19159,6 +19217,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
vo2Max:
maxItems: 8
type: array
@@ -19178,6 +19237,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
skinTemperature:
maxItems: 8
type: array
@@ -19197,6 +19257,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
respiratoryRate:
maxItems: 8
type: array
@@ -19216,6 +19277,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
recovery:
maxItems: 8
type: array
@@ -19235,6 +19297,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
stress:
maxItems: 8
type: array
@@ -19254,6 +19317,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
sleepDebt:
maxItems: 8
type: array
@@ -19273,6 +19337,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
metricPriority:
type: object
properties:
@@ -19295,6 +19360,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
activeEnergy:
maxItems: 8
type: array
@@ -19314,6 +19380,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
walkingRunningDistance:
maxItems: 8
type: array
@@ -19333,6 +19400,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
flightsClimbed:
maxItems: 8
type: array
@@ -19352,6 +19420,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
sleep:
maxItems: 8
type: array
@@ -19371,6 +19440,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
weight:
maxItems: 8
type: array
@@ -19390,6 +19460,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
bloodPressure:
maxItems: 8
type: array
@@ -19409,6 +19480,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
pulse:
maxItems: 8
type: array
@@ -19428,6 +19500,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
bodyFat:
maxItems: 8
type: array
@@ -19447,6 +19520,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
bodyTemperature:
maxItems: 8
type: array
@@ -19466,6 +19540,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
spo2:
maxItems: 8
type: array
@@ -19485,6 +19560,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
hrv:
maxItems: 8
type: array
@@ -19504,6 +19580,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
restingHeartRate:
maxItems: 8
type: array
@@ -19523,6 +19600,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
vo2Max:
maxItems: 8
type: array
@@ -19542,6 +19620,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
skinTemperature:
maxItems: 8
type: array
@@ -19561,6 +19640,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
respiratoryRate:
maxItems: 8
type: array
@@ -19580,6 +19660,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
recovery:
maxItems: 8
type: array
@@ -19599,6 +19680,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
stress:
maxItems: 8
type: array
@@ -19618,6 +19700,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
sleepDebt:
maxItems: 8
type: array
@@ -19637,6 +19720,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
additionalProperties: false
deviceTypePriority:
type: object
@@ -19705,6 +19789,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
activeEnergy:
maxItems: 8
type: array
@@ -19724,6 +19809,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
walkingRunningDistance:
maxItems: 8
type: array
@@ -19743,6 +19829,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
flightsClimbed:
maxItems: 8
type: array
@@ -19762,6 +19849,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
sleep:
maxItems: 8
type: array
@@ -19781,6 +19869,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
weight:
maxItems: 8
type: array
@@ -19800,6 +19889,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
bloodPressure:
maxItems: 8
type: array
@@ -19819,6 +19909,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
pulse:
maxItems: 8
type: array
@@ -19838,6 +19929,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
bodyFat:
maxItems: 8
type: array
@@ -19857,6 +19949,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
bodyTemperature:
maxItems: 8
type: array
@@ -19876,6 +19969,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
spo2:
maxItems: 8
type: array
@@ -19895,6 +19989,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
hrv:
maxItems: 8
type: array
@@ -19914,6 +20009,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
restingHeartRate:
maxItems: 8
type: array
@@ -19933,6 +20029,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
vo2Max:
maxItems: 8
type: array
@@ -19952,6 +20049,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
skinTemperature:
maxItems: 8
type: array
@@ -19971,6 +20069,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
respiratoryRate:
maxItems: 8
type: array
@@ -19990,6 +20089,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
recovery:
maxItems: 8
type: array
@@ -20009,6 +20109,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
stress:
maxItems: 8
type: array
@@ -20028,6 +20129,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
sleepDebt:
maxItems: 8
type: array
@@ -20047,6 +20149,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
metricPriority:
type: object
properties:
@@ -20069,6 +20172,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
activeEnergy:
maxItems: 8
type: array
@@ -20088,6 +20192,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
walkingRunningDistance:
maxItems: 8
type: array
@@ -20107,6 +20212,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
flightsClimbed:
maxItems: 8
type: array
@@ -20126,6 +20232,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
sleep:
maxItems: 8
type: array
@@ -20145,6 +20252,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
weight:
maxItems: 8
type: array
@@ -20164,6 +20272,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
bloodPressure:
maxItems: 8
type: array
@@ -20183,6 +20292,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
pulse:
maxItems: 8
type: array
@@ -20202,6 +20312,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
bodyFat:
maxItems: 8
type: array
@@ -20221,6 +20332,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
bodyTemperature:
maxItems: 8
type: array
@@ -20240,6 +20352,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
spo2:
maxItems: 8
type: array
@@ -20259,6 +20372,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
hrv:
maxItems: 8
type: array
@@ -20278,6 +20392,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
restingHeartRate:
maxItems: 8
type: array
@@ -20297,6 +20412,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
vo2Max:
maxItems: 8
type: array
@@ -20316,6 +20432,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
skinTemperature:
maxItems: 8
type: array
@@ -20335,6 +20452,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
respiratoryRate:
maxItems: 8
type: array
@@ -20354,6 +20472,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
recovery:
maxItems: 8
type: array
@@ -20373,6 +20492,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
stress:
maxItems: 8
type: array
@@ -20392,6 +20512,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
sleepDebt:
maxItems: 8
type: array
@@ -20411,6 +20532,7 @@ components:
- TELEGRAM
- MCP
- GOOGLE_HEALTH
+ - STRAVA
additionalProperties: false
deviceTypePriority:
type: object
diff --git a/docs/integrations/apple-health.md b/docs/integrations/apple-health.md
index 3f9d3eefe..4d80057fc 100644
--- a/docs/integrations/apple-health.md
+++ b/docs/integrations/apple-health.md
@@ -199,3 +199,10 @@ The wide-event log line `import.apple-health.kickoff` (or
`import.apple-health.kickoff.denied` on failure) captures every
attempt with the upload size, SHA-256, and the resolved `ImportJob`
id for debugging.
+
+## Garmin owners
+
+Garmin has no direct connector for a self-hosted instance. Garmin Connect writes
+to Apple Health automatically, so the import path on this page is how Garmin data
+reaches HealthLog on iOS. See [garmin.md](./garmin.md) for what comes through and
+what Garmin withholds.
diff --git a/docs/integrations/garmin.md b/docs/integrations/garmin.md
new file mode 100644
index 000000000..b1aa7117e
--- /dev/null
+++ b/docs/integrations/garmin.md
@@ -0,0 +1,52 @@
+# Garmin (via Apple Health or Google Health Connect)
+
+**There is no direct Garmin connector for a self-hosted HealthLog instance, and
+there cannot be one.** Garmin's data-sharing program is business-partner-only:
+it is approval-gated, requires a public callback endpoint, and is not the
+"register your own app, paste a key" model the wearable integrations here rely
+on. So instead of a broken half-integration, HealthLog reads Garmin data the
+honest way — through the phone health platform your Garmin already feeds.
+
+## The path that works
+
+```
+Garmin device → Garmin Connect → Apple Health (iOS) ─┐
+ → Google Health Connect ├─→ HealthLog
+ (Android) ─┘
+```
+
+- **iOS:** Garmin Connect writes to Apple Health automatically. Import it with
+ the existing [Apple Health](./apple-health.md) path (the `export.zip` upload or
+ the native client's background sync).
+- **Android:** since Garmin Connect 5.14.1 (July 2025) the app writes to **Google
+ Health Connect**. Connect HealthLog through the existing
+ [Google Health](./google-health.md) integration.
+
+No extra code, no Garmin developer account, no approval queue — if your Garmin
+already syncs to your phone's health platform, HealthLog already reads it.
+
+## What comes through
+
+Everything Garmin shares with Apple Health / Health Connect flows in: steps,
+heart rate (including beat-to-beat variability), sleep stages (REM / deep /
+light), calories (active and total), distance, weight and body composition,
+cadence, and — partially — blood-oxygen saturation.
+
+## What Garmin withholds
+
+These are Garmin's **proprietary** scores. Garmin does **not** share them with
+Apple Health or Health Connect, so no self-hosted integration — and no amount of
+configuration — can obtain them. They are only available inside Garmin Connect
+itself:
+
+- Body Battery
+- HRV Status
+- VO₂ max
+- Training Load and Training Effect
+- Recovery Time
+- Intensity Minutes
+- Stress score
+- Running dynamics
+
+If you rely on those specific numbers, keep the Garmin Connect app for them;
+everything else lands in HealthLog through the platform path above.
diff --git a/docs/integrations/google-health.md b/docs/integrations/google-health.md
index a32793a04..410034c24 100644
--- a/docs/integrations/google-health.md
+++ b/docs/integrations/google-health.md
@@ -141,3 +141,10 @@ per metric.
The Settings **Disconnect** button removes the connection and its stored tokens.
Previously synced measurements stay in the database.
+
+## Garmin owners
+
+Garmin has no direct connector for a self-hosted instance. Since Garmin Connect
+5.14.1 (July 2025) the app writes to Google Health Connect, so this integration
+is how Garmin data reaches HealthLog on Android. See [garmin.md](./garmin.md) for
+what comes through and what Garmin withholds.
diff --git a/docs/integrations/strava.md b/docs/integrations/strava.md
new file mode 100644
index 000000000..da6a7ed3a
--- /dev/null
+++ b/docs/integrations/strava.md
@@ -0,0 +1,74 @@
+# Strava integration
+
+HealthLog reads your Strava activities and folds them into the same workout
+history as every other source. Strava is a **workout source only** — its API is
+activity-centric and exposes no sleep, recovery, body-composition or glucose
+data, so connecting it adds runs, rides, swims and the rest to your workout
+surface and nothing else.
+
+The OAuth app is brought **per user**: each self-hoster registers their own
+Strava API application and pastes the client id/secret into Settings. Strava
+caps every newly-created app at "single-player mode" (an athlete capacity of 1)
+until you request a capacity increase — perfectly fine for a personal
+self-host, and the reason a single shared app cannot serve many operators.
+
+## What you get — and what you don't
+
+**Covered** (one `Workout` row per activity): sport type, start and end time,
+moving duration, distance, elevation gain, average and maximum heart rate (when
+the activity recorded it), and calories. Power, cadence, the "trainer"/"commute"
+flags and the activity name are kept on the row's metadata for provenance but
+are **not** surfaced as training analytics — HealthLog treats Strava as another
+workout source, not a training-load platform.
+
+**Not covered**: Strava exposes no daily metrics (steps, sleep, resting heart
+rate, weight, …). Those come from your wearable's own integration or from Apple
+Health / Google Health Connect.
+
+## Duplicate activities are collapsed automatically
+
+If you record a run on an Apple Watch **and** it also lands in Strava, the same
+run arrives twice — once via Apple Health, once via Strava. HealthLog keeps one
+canonical row per logical workout using your **source-priority ladder**: by
+default the device-native capture (Apple Watch, WHOOP, Withings) wins over the
+Strava copy, because a Strava activity is often a re-upload with lower-fidelity
+or missing heart-rate data. A run you record **only** on Strava (phone GPS)
+survives untouched. You can promote Strava above the wearables in
+**Settings → Sources** if you prefer the Strava copy.
+
+## 1. Register a Strava API application
+
+1. Sign in at .
+2. Create an application. For **Authorization Callback Domain** enter the host
+ of your HealthLog instance (the domain only, no scheme or path).
+3. Copy the **Client ID** and **Client Secret**.
+
+## 2. Paste the credentials into HealthLog
+
+1. Open **Settings → Integrations** and find the **Strava** card.
+2. Paste the Client ID and Client Secret into the **API Credentials** fields and
+ save. (An operator can instead configure a shared app via the
+ `STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET` environment variables; per-user
+ credentials always take precedence.)
+3. Click **Connect Strava** and approve the access request. HealthLog requests
+ the `activity:read_all` scope so it can read every activity, including those
+ you marked "only me".
+
+## 3. First sync
+
+The first history import runs in the background shortly after you connect;
+afterwards an hourly poll pulls new activities. Strava rotates its refresh token
+on every refresh, and HealthLog persists the rotated token each time, so the
+connection stays healthy without you re-authorising.
+
+## Disconnecting
+
+Disconnecting revokes the grant at Strava and clears the stored tokens.
+Activities already imported stay in your history; reconnecting resumes the sync.
+
+## Related
+
+- Wearable that syncs to Strava but not directly to HealthLog? See the device's
+ own page, or ingest via [Apple Health](./apple-health.md) /
+ [Google Health Connect](./google-health.md).
+- **Garmin owners:** Garmin has no direct connector — see [garmin.md](./garmin.md).
diff --git a/e2e/settings-integrations-mobile.spec.ts b/e2e/settings-integrations-mobile.spec.ts
index 9c2125dfd..3fe949756 100644
--- a/e2e/settings-integrations-mobile.spec.ts
+++ b/e2e/settings-integrations-mobile.spec.ts
@@ -95,6 +95,20 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
webhookSecret: "ml_xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
entryCount: 42,
},
+ // v1.28.x — Strava OAuth card reads off this consolidated
+ // envelope; without a `strava` entry the new card's query key
+ // never resolves and the integrations page hangs.
+ {
+ integration: "strava",
+ state: "disconnected",
+ lastSuccessAt: null,
+ lastAttemptAt: null,
+ lastError: null,
+ connected: false,
+ configured: false,
+ available: false,
+ hasOwnCredentials: false,
+ },
],
},
error: null,
@@ -143,9 +157,10 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
// Wait for the pills to mount (queries resolve after first paint).
// One pill per card: Withings, WHOOP, Fitbit, Google Health, Polar,
- // Oura, Nightscout.
+ // Oura, Strava, Nightscout. (The Garmin info note is not an OAuth card and
+ // carries no pill.)
const pills = page.locator('[data-testid="integration-status-pill"]');
- await expect(pills).toHaveCount(7, { timeout: 10_000 });
+ await expect(pills).toHaveCount(8, { timeout: 10_000 });
// Withings is connected in this fixture; WHOOP is a BYO-keys card that
// stays dormant until an operator adds credentials, so assert the one
diff --git a/messages/de.json b/messages/de.json
index f1a9548d9..b0a1ecdad 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5478,6 +5478,38 @@
"connectionRevoke": "Trennen",
"connectionRevokeDescription": "Dies beendet den Zugriff des Assistenten sofort und verhindert eine Erneuerung. Für eine erneute Verbindung ist eine neue Autorisierung nötig.",
"connectionRevokeFailed": "Assistent konnte nicht getrennt werden."
+ },
+ "strava": "Strava",
+ "stravaDescription": "Verbinde dein Strava-Konto, um deine Aktivitäten in deinen Trainingsverlauf zu übernehmen.",
+ "stravaDescriptionSecondary": "Aktivitäten fließen im Hintergrund in dein Dashboard. Neu bei der Einrichtung? Folge der",
+ "stravaViewData": "Trainings ansehen",
+ "stravaCredentials": "API-Zugangsdaten",
+ "stravaClientId": "Client-ID",
+ "stravaClientSecret": "Client-Secret",
+ "stravaCredentialsSaved": "Zugangsdaten gespeichert",
+ "stravaCredentialsSavedPlaceholder": "Gespeichert — neue eingeben zum Ersetzen",
+ "stravaSaveCredentials": "Speichern",
+ "stravaUnavailable": "Strava ist auf diesem Server nicht konfiguriert. Füge oben deine eigenen Strava-API-Zugangsdaten hinzu oder bitte deinen Administrator, die gemeinsame App zu konfigurieren.",
+ "stravaConnect": "Strava verbinden",
+ "stravaDisconnect": "Trennen",
+ "stravaDisconnectTitle": "Strava trennen?",
+ "stravaDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Aktivitäten bleiben in deinem Verlauf.",
+ "stravaOauthConnected": "Strava verbunden — die erste Synchronisierung startet im Hintergrund.",
+ "stravaOauthFailed": "Strava-Verbindung fehlgeschlagen",
+ "stravaOauthError": {
+ "csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.",
+ "state": "Die Antwort von Strava passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.",
+ "cross_user": "Die Antwort gehört zu einer Anfrage eines anderen Kontos. Bitte starte die Verbindung von deinem eigenen Konto aus erneut.",
+ "nocode": "Strava hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Strava-Dialog.",
+ "nocreds": "Strava ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Strava-API-Zugangsdaten hinzuzufügen.",
+ "token": "Der Token-Austausch mit Strava ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
+ "rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.",
+ "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
+ },
+ "garminInfo": {
+ "title": "Garmin",
+ "body": "Garmin hat hier keine direkte Verbindung. Verbinde über Apple Health (iOS) oder Google Health Connect (Android). Garmins proprietäre Werte — Body Battery, Training Load, HRV-Status, VO₂max — werden von Garmin an keine der beiden Plattformen weitergegeben.",
+ "docsLink": "Zur Anleitung"
}
},
"admin": {
diff --git a/messages/en.json b/messages/en.json
index c592d27a3..95d906c39 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5478,6 +5478,38 @@
"connectionRevoke": "Disconnect",
"connectionRevokeDescription": "This ends the assistant's access immediately and stops it from renewing. It will need to be re-authorized to reconnect.",
"connectionRevokeFailed": "Could not disconnect the assistant."
+ },
+ "strava": "Strava",
+ "stravaDescription": "Connect your Strava account to sync your activities into your workout history.",
+ "stravaDescriptionSecondary": "Activities flow into your dashboard in the background. New to the setup? Follow the",
+ "stravaViewData": "View your workouts",
+ "stravaCredentials": "API Credentials",
+ "stravaClientId": "Client ID",
+ "stravaClientSecret": "Client Secret",
+ "stravaCredentialsSaved": "Credentials saved",
+ "stravaCredentialsSavedPlaceholder": "Saved — enter new to replace",
+ "stravaSaveCredentials": "Save",
+ "stravaUnavailable": "Strava is not configured on this server. Add your own Strava API credentials above, or ask your administrator to configure the shared app.",
+ "stravaConnect": "Connect Strava",
+ "stravaDisconnect": "Disconnect",
+ "stravaDisconnectTitle": "Disconnect Strava?",
+ "stravaDisconnectDescription": "Future syncs stop. Activities already imported stay in your history.",
+ "stravaOauthConnected": "Strava connected — the first sync starts in the background.",
+ "stravaOauthFailed": "Strava connection failed",
+ "stravaOauthError": {
+ "csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.",
+ "state": "The response from Strava did not match any open connection request. Please start the connection again.",
+ "cross_user": "The response belongs to a request from a different account. Please restart the connection from your own account.",
+ "nocode": "Strava did not return an authorization code. Try again and allow access in the Strava dialog.",
+ "nocreds": "Strava is not configured on this server. Ask your administrator to add the Strava API credentials.",
+ "token": "The token exchange with Strava failed. Please start the connection again.",
+ "rate_limited": "Too many connection attempts. Wait a moment and try again.",
+ "generic": "Unknown error while connecting. Please start the connection again."
+ },
+ "garminInfo": {
+ "title": "Garmin",
+ "body": "Garmin has no direct connection here. Connect via Apple Health (iOS) or Google Health Connect (Android). Garmin's proprietary scores — Body Battery, Training Load, HRV Status, VO₂ max — are not shared by Garmin to either platform.",
+ "docsLink": "Read the guide"
}
},
"admin": {
diff --git a/messages/es.json b/messages/es.json
index 6f7029960..24b832f90 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5478,6 +5478,38 @@
"connectionRevoke": "Desconectar",
"connectionRevokeDescription": "Esto finaliza el acceso del asistente de inmediato e impide su renovación. Será necesario autorizarlo de nuevo para volver a conectarlo.",
"connectionRevokeFailed": "No se pudo desconectar el asistente."
+ },
+ "strava": "Strava",
+ "stravaDescription": "Conecta tu cuenta de Strava para sincronizar tus actividades con tu historial de entrenamientos.",
+ "stravaDescriptionSecondary": "Las actividades llegan a tu panel en segundo plano. ¿Nuevo en la configuración? Sigue la",
+ "stravaViewData": "Ver tus entrenamientos",
+ "stravaCredentials": "Credenciales de API",
+ "stravaClientId": "ID de cliente",
+ "stravaClientSecret": "Secreto de cliente",
+ "stravaCredentialsSaved": "Credenciales guardadas",
+ "stravaCredentialsSavedPlaceholder": "Guardadas — introduce nuevas para reemplazar",
+ "stravaSaveCredentials": "Guardar",
+ "stravaUnavailable": "Strava no está configurado en este servidor. Añade arriba tus propias credenciales de API de Strava o pide a tu administrador que configure la app compartida.",
+ "stravaConnect": "Conectar Strava",
+ "stravaDisconnect": "Desconectar",
+ "stravaDisconnectTitle": "¿Desconectar Strava?",
+ "stravaDisconnectDescription": "Las sincronizaciones futuras se detienen. Las actividades ya importadas permanecen en tu historial.",
+ "stravaOauthConnected": "Strava conectado: la primera sincronización comienza en segundo plano.",
+ "stravaOauthFailed": "La conexión con Strava falló",
+ "stravaOauthError": {
+ "csrf1": "La comprobación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.",
+ "state": "La respuesta de Strava no coincidió con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.",
+ "cross_user": "La respuesta pertenece a una solicitud de otra cuenta. Reinicia la conexión desde tu propia cuenta.",
+ "nocode": "Strava no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Strava.",
+ "nocreds": "Strava no está configurado en este servidor. Pide a tu administrador que añada las credenciales de API de Strava.",
+ "token": "El intercambio de tokens con Strava falló. Inicia la conexión de nuevo.",
+ "rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.",
+ "generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
+ },
+ "garminInfo": {
+ "title": "Garmin",
+ "body": "Garmin no tiene conexión directa aquí. Conéctalo a través de Apple Health (iOS) o Google Health Connect (Android). Las métricas propias de Garmin — Body Battery, Training Load, estado de HRV, VO₂ máx.— no las comparte Garmin con ninguna de las dos plataformas.",
+ "docsLink": "Leer la guía"
}
},
"admin": {
diff --git a/messages/fr.json b/messages/fr.json
index d954c75d1..8013abee6 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -5478,6 +5478,38 @@
"connectionRevoke": "Déconnecter",
"connectionRevokeDescription": "Cela met fin immédiatement à l'accès de l'assistant et l'empêche de se renouveler. Une nouvelle autorisation sera nécessaire pour le reconnecter.",
"connectionRevokeFailed": "Impossible de déconnecter l'assistant."
+ },
+ "strava": "Strava",
+ "stravaDescription": "Connecte ton compte Strava pour synchroniser tes activités avec ton historique d'entraînement.",
+ "stravaDescriptionSecondary": "Les activités arrivent sur ton tableau de bord en arrière-plan. Nouveau dans la configuration ? Suis le",
+ "stravaViewData": "Voir tes entraînements",
+ "stravaCredentials": "Identifiants API",
+ "stravaClientId": "ID client",
+ "stravaClientSecret": "Secret client",
+ "stravaCredentialsSaved": "Identifiants enregistrés",
+ "stravaCredentialsSavedPlaceholder": "Enregistrés — saisis-en de nouveaux pour remplacer",
+ "stravaSaveCredentials": "Enregistrer",
+ "stravaUnavailable": "Strava n'est pas configuré sur ce serveur. Ajoute tes propres identifiants API Strava ci-dessus ou demande à ton administrateur de configurer l'application partagée.",
+ "stravaConnect": "Connecter Strava",
+ "stravaDisconnect": "Déconnecter",
+ "stravaDisconnectTitle": "Déconnecter Strava ?",
+ "stravaDisconnectDescription": "Les synchronisations futures s'arrêtent. Les activités déjà importées restent dans ton historique.",
+ "stravaOauthConnected": "Strava connecté — la première synchronisation démarre en arrière-plan.",
+ "stravaOauthFailed": "La connexion à Strava a échoué",
+ "stravaOauthError": {
+ "csrf1": "Le contrôle de sécurité a échoué car le cookie du navigateur était manquant. Relance la connexion dans le même navigateur.",
+ "state": "La réponse de Strava ne correspondait à aucune demande de connexion ouverte. Relance la connexion.",
+ "cross_user": "La réponse appartient à une demande d'un autre compte. Relance la connexion depuis ton propre compte.",
+ "nocode": "Strava n'a pas renvoyé de code d'autorisation. Réessaie et autorise l'accès dans la boîte de dialogue Strava.",
+ "nocreds": "Strava n'est pas configuré sur ce serveur. Demande à ton administrateur d'ajouter les identifiants API Strava.",
+ "token": "L'échange de jeton avec Strava a échoué. Relance la connexion.",
+ "rate_limited": "Trop de tentatives de connexion. Attends un instant et réessaie.",
+ "generic": "Erreur inconnue lors de la connexion. Relance la connexion."
+ },
+ "garminInfo": {
+ "title": "Garmin",
+ "body": "Garmin n'a pas de connexion directe ici. Connecte-le via Apple Health (iOS) ou Google Health Connect (Android). Les scores propriétaires de Garmin — Body Battery, Training Load, statut VFC, VO₂ max — ne sont partagés par Garmin avec aucune des deux plateformes.",
+ "docsLink": "Lire le guide"
}
},
"admin": {
diff --git a/messages/it.json b/messages/it.json
index 2d09de6cd..43fea9fed 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -5478,6 +5478,38 @@
"connectionRevoke": "Disconnetti",
"connectionRevokeDescription": "Questo termina immediatamente l'accesso dell'assistente e ne impedisce il rinnovo. Per riconnetterlo sarà necessaria una nuova autorizzazione.",
"connectionRevokeFailed": "Impossibile disconnettere l'assistente."
+ },
+ "strava": "Strava",
+ "stravaDescription": "Collega il tuo account Strava per sincronizzare le tue attività con la cronologia degli allenamenti.",
+ "stravaDescriptionSecondary": "Le attività arrivano nella tua dashboard in background. Nuovo alla configurazione? Segui la",
+ "stravaViewData": "Vedi i tuoi allenamenti",
+ "stravaCredentials": "Credenziali API",
+ "stravaClientId": "ID client",
+ "stravaClientSecret": "Secret client",
+ "stravaCredentialsSaved": "Credenziali salvate",
+ "stravaCredentialsSavedPlaceholder": "Salvate — inseriscine di nuove per sostituirle",
+ "stravaSaveCredentials": "Salva",
+ "stravaUnavailable": "Strava non è configurato su questo server. Aggiungi sopra le tue credenziali API di Strava o chiedi al tuo amministratore di configurare l'app condivisa.",
+ "stravaConnect": "Collega Strava",
+ "stravaDisconnect": "Disconnetti",
+ "stravaDisconnectTitle": "Disconnettere Strava?",
+ "stravaDisconnectDescription": "Le sincronizzazioni future si interrompono. Le attività già importate restano nella cronologia.",
+ "stravaOauthConnected": "Strava collegato — la prima sincronizzazione parte in background.",
+ "stravaOauthFailed": "Connessione a Strava non riuscita",
+ "stravaOauthError": {
+ "csrf1": "Il controllo di sicurezza non è riuscito perché mancava il cookie del browser. Riavvia la connessione nello stesso browser.",
+ "state": "La risposta di Strava non corrispondeva ad alcuna richiesta di connessione aperta. Riavvia la connessione.",
+ "cross_user": "La risposta appartiene a una richiesta di un altro account. Riavvia la connessione dal tuo account.",
+ "nocode": "Strava non ha restituito un codice di autorizzazione. Riprova e consenti l'accesso nella finestra di Strava.",
+ "nocreds": "Strava non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali API di Strava.",
+ "token": "Lo scambio del token con Strava non è riuscito. Riavvia la connessione.",
+ "rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.",
+ "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
+ },
+ "garminInfo": {
+ "title": "Garmin",
+ "body": "Garmin non ha una connessione diretta qui. Collegalo tramite Apple Health (iOS) o Google Health Connect (Android). I punteggi proprietari di Garmin — Body Battery, Training Load, stato HRV, VO₂ max — non vengono condivisi da Garmin con nessuna delle due piattaforme.",
+ "docsLink": "Leggi la guida"
}
},
"admin": {
diff --git a/messages/pl.json b/messages/pl.json
index 2d5c5995b..400253bd8 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -5478,6 +5478,38 @@
"connectionRevoke": "Rozłącz",
"connectionRevokeDescription": "To natychmiast kończy dostęp asystenta i uniemożliwia odnawianie. Ponowne połączenie będzie wymagać nowej autoryzacji.",
"connectionRevokeFailed": "Nie udało się rozłączyć asystenta."
+ },
+ "strava": "Strava",
+ "stravaDescription": "Połącz konto Strava, aby synchronizować aktywności z historią treningów.",
+ "stravaDescriptionSecondary": "Aktywności trafiają na pulpit w tle. Nowy w konfiguracji? Skorzystaj z",
+ "stravaViewData": "Zobacz treningi",
+ "stravaCredentials": "Dane API",
+ "stravaClientId": "Identyfikator klienta",
+ "stravaClientSecret": "Sekret klienta",
+ "stravaCredentialsSaved": "Dane zapisane",
+ "stravaCredentialsSavedPlaceholder": "Zapisane — wpisz nowe, aby zastąpić",
+ "stravaSaveCredentials": "Zapisz",
+ "stravaUnavailable": "Strava nie jest skonfigurowana na tym serwerze. Dodaj powyżej własne dane API Strava lub poproś administratora o skonfigurowanie wspólnej aplikacji.",
+ "stravaConnect": "Połącz Strava",
+ "stravaDisconnect": "Rozłącz",
+ "stravaDisconnectTitle": "Rozłączyć Strava?",
+ "stravaDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane aktywności pozostaną w historii.",
+ "stravaOauthConnected": "Strava połączona — pierwsza synchronizacja rusza w tle.",
+ "stravaOauthFailed": "Połączenie ze Strava nie powiodło się",
+ "stravaOauthError": {
+ "csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.",
+ "state": "Odpowiedź ze Strava nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.",
+ "cross_user": "Odpowiedź należy do żądania z innego konta. Rozpocznij połączenie ponownie z własnego konta.",
+ "nocode": "Strava nie zwróciła kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Strava.",
+ "nocreds": "Strava nie jest skonfigurowana na tym serwerze. Poproś administratora o dodanie danych API Strava.",
+ "token": "Wymiana tokenu ze Strava nie powiodła się. Rozpocznij połączenie ponownie.",
+ "rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.",
+ "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
+ },
+ "garminInfo": {
+ "title": "Garmin",
+ "body": "Garmin nie ma tutaj bezpośredniego połączenia. Połącz przez Apple Health (iOS) lub Google Health Connect (Android). Zastrzeżone wskaźniki Garmina — Body Battery, Training Load, status HRV, VO₂ max — nie są udostępniane przez Garmina żadnej z tych platform.",
+ "docsLink": "Przeczytaj przewodnik"
}
},
"admin": {
diff --git a/package.json b/package.json
index 3ab001050..5bf310940 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.28.10",
+ "version": "1.28.11",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
diff --git a/prisma/migrations/0233_strava_integration/migration.sql b/prisma/migrations/0233_strava_integration/migration.sql
new file mode 100644
index 000000000..f51db579d
--- /dev/null
+++ b/prisma/migrations/0233_strava_integration/migration.sql
@@ -0,0 +1,33 @@
+-- v1.28.x — Strava OAuth integration (schema + data layer).
+--
+-- Strava is a per-user BYO-OAuth workout source, mirroring WHOOP/Oura/Polar:
+-- 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.
+-- Strava feeds `Workout` rows only (it exposes no sleep / recovery / body
+-- metrics) and rotates its refresh token on every refresh, so it follows Oura's
+-- reactive-refresh + compare-and-set token model (tokens live on `users`, no
+-- token-expiry column).
+--
+-- 1. `measurement_source` += `STRAVA` for the server-side native ingest.
+-- 2. `users` += the encrypted BYO-app credentials + granted tokens, the
+-- plaintext athlete id, the incremental-sync cursor, and the backfill
+-- marker. Purely-additive: nullable columns, no backfill, no row touched.
+--
+-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only.
+--
+-- Reversibility: Postgres cannot remove an enum value, so `STRAVA` stays; with
+-- no rows carrying it it is inert. The columns drop with `DROP COLUMN IF EXISTS`.
+
+-- ── 1. measurement_source — append the Strava server-owned source ──────
+ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'STRAVA';
+
+-- ── 2. users — per-user Strava credentials, tokens, cursor, backfill marker ──
+ALTER TABLE "users"
+ ADD COLUMN IF NOT EXISTS "strava_client_id_encrypted" TEXT,
+ ADD COLUMN IF NOT EXISTS "strava_client_secret_encrypted" TEXT,
+ ADD COLUMN IF NOT EXISTS "strava_access_token_encrypted" TEXT,
+ ADD COLUMN IF NOT EXISTS "strava_refresh_token_encrypted" TEXT,
+ ADD COLUMN IF NOT EXISTS "strava_athlete_id" TEXT,
+ ADD COLUMN IF NOT EXISTS "strava_last_activity_at" TIMESTAMP(3),
+ ADD COLUMN IF NOT EXISTS "strava_backfill_completed_at" TIMESTAMP(3);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index ca3f42dad..119ebc129 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -187,6 +187,29 @@ model User {
ouraClientIdEncrypted String? @map("oura_client_id_encrypted")
ouraClientSecretEncrypted String? @map("oura_client_secret_encrypted")
+ // v1.28.x — Strava OAuth credentials (per-user BYO-app, encrypted at app
+ // level). 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, mirroring the WHOOP/Oura/Polar posture. Strava
+ // rotates its refresh token on EVERY refresh, so both tokens are persisted
+ // (Oura's reactive-refresh + compare-and-set model, no token-expiry column —
+ // the sync refreshes reactively on a 401).
+ stravaClientIdEncrypted String? @map("strava_client_id_encrypted")
+ stravaClientSecretEncrypted String? @map("strava_client_secret_encrypted")
+ stravaAccessTokenEncrypted String? @map("strava_access_token_encrypted")
+ stravaRefreshTokenEncrypted String? @map("strava_refresh_token_encrypted")
+ // Strava's numeric athlete id (plaintext opaque id): pins the granted
+ // account so a cross-account re-auth can be rejected, and is the fan-out key
+ // if webhooks are added later. Mirrors polarUserId / fitbitUserId intent.
+ stravaAthleteId String? @map("strava_athlete_id")
+ // Incremental-sync cursor: the start instant of the newest activity already
+ // imported. The poll walks `/athlete/activities?after=` from here.
+ stravaLastActivityAt DateTime? @map("strava_last_activity_at")
+ // First-connect full-history backfill marker (self-converging discovery
+ // predicate `strava_backfill_completed_at IS NULL`), mirroring WHOOP/Fitbit.
+ stravaBackfillCompletedAt DateTime? @map("strava_backfill_completed_at")
+
// moodLog integration (per-user, encrypted)
moodLogUrlEncrypted String? @map("mood_log_url_encrypted")
moodLogApiKeyEncrypted String? @map("mood_log_api_key_encrypted")
@@ -1504,6 +1527,12 @@ enum MeasurementSource {
// path); rejected on batch + single POST like WITHINGS/WHOOP/FITBIT. iOS DTO
// mirrors this exact spelling so /api/sync/changes decodes the rows.
GOOGLE_HEALTH
+ // v1.28.x — Strava OAuth integration. Native Strava activities ingest
+ // server-side as `source = STRAVA` Workout rows (no client write path);
+ // rejected on the batch + single POST like WITHINGS/WHOOP/FITBIT. iOS DTO
+ // mirrors this exact spelling so the `/api/sync/changes` delta feed decodes
+ // the rows. Feeds Workout rows only — Strava exposes no daily metrics.
+ STRAVA
@@map("measurement_source")
}
diff --git a/public/sw.js b/public/sw.js
index 396311c8f..d22d403c2 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
- /* @sw-version-fallback */ "v1.28.10";
+ /* @sw-version-fallback */ "v1.28.11";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts
index 2f6be9ad4..cf2dfc72f 100644
--- a/scripts/rotate-encryption-key.ts
+++ b/scripts/rotate-encryption-key.ts
@@ -168,7 +168,9 @@ async function main() {
// "polarUserIdEncrypted" "polarClientIdEncrypted"
// "polarClientSecretEncrypted" "ouraAccessTokenEncrypted"
// "ouraRefreshTokenEncrypted" "ouraClientIdEncrypted"
- // "ouraClientSecretEncrypted" "aiAnthropicKeyEncrypted"
+ // "ouraClientSecretEncrypted" "stravaClientIdEncrypted"
+ // "stravaClientSecretEncrypted" "stravaAccessTokenEncrypted"
+ // "stravaRefreshTokenEncrypted" "aiAnthropicKeyEncrypted"
// "aiLocalKeyEncrypted" "aiOpenaiKeyEncrypted" "insuranceNumberEncrypted"
const userFields = [
"codexAccessTokenEncrypted",
@@ -195,6 +197,10 @@ async function main() {
"ouraRefreshTokenEncrypted",
"ouraClientIdEncrypted",
"ouraClientSecretEncrypted",
+ "stravaClientIdEncrypted",
+ "stravaClientSecretEncrypted",
+ "stravaAccessTokenEncrypted",
+ "stravaRefreshTokenEncrypted",
"aiAnthropicKeyEncrypted",
"aiLocalKeyEncrypted",
"aiOpenaiKeyEncrypted",
diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts
index dfc84c48e..8fae96843 100644
--- a/src/app/api/integrations/status/route.ts
+++ b/src/app/api/integrations/status/route.ts
@@ -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//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,
],
});
});
diff --git a/src/app/api/strava/callback/route.ts b/src/app/api/strava/callback/route.ts
new file mode 100644
index 000000000..dd093e0d6
--- /dev/null
+++ b/src/app/api/strava/callback/route.ts
@@ -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");
+ }
+});
diff --git a/src/app/api/strava/connect/route.ts b/src/app/api/strava/connect/route.ts
new file mode 100644
index 000000000..24e9a62bc
--- /dev/null
+++ b/src/app/api/strava/connect/route.ts
@@ -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;
+});
diff --git a/src/app/api/strava/credentials/route.ts b/src/app/api/strava/credentials/route.ts
new file mode 100644
index 000000000..5fe23f168
--- /dev/null
+++ b/src/app/api/strava/credentials/route.ts
@@ -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 });
+});
diff --git a/src/app/api/strava/disconnect/route.ts b/src/app/api/strava/disconnect/route.ts
new file mode 100644
index 000000000..b41c4a75e
--- /dev/null
+++ b/src/app/api/strava/disconnect/route.ts
@@ -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 });
+});
diff --git a/src/app/api/strava/status/route.ts b/src/app/api/strava/status/route.ts
new file mode 100644
index 000000000..64df49fe4
--- /dev/null
+++ b/src/app/api/strava/status/route.ts
@@ -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,
+ });
+});
diff --git a/src/app/api/strava/test/route.ts b/src/app/api/strava/test/route.ts
new file mode 100644
index 000000000..56b5189e1
--- /dev/null
+++ b/src/app/api/strava/test/route.ts
@@ -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 },
+ );
+ }
+});
diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx
index e6795d39d..35c806dc6 100644
--- a/src/components/settings/__tests__/integrations-section.test.tsx
+++ b/src/components/settings/__tests__/integrations-section.test.tsx
@@ -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"`);
diff --git a/src/components/settings/__tests__/oauth-return-outcome.test.ts b/src/components/settings/__tests__/oauth-return-outcome.test.ts
index f0a16559f..5a607a881 100644
--- a/src/components/settings/__tests__/oauth-return-outcome.test.ts
+++ b/src/components/settings/__tests__/oauth-return-outcome.test.ts
@@ -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",
diff --git a/src/components/settings/integrations/connections-panel.tsx b/src/components/settings/integrations/connections-panel.tsx
index ea08f812c..3350d61c1 100644
--- a/src/components/settings/integrations/connections-panel.tsx
+++ b/src/components/settings/integrations/connections-panel.tsx
@@ -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//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 (
@@ -282,9 +291,18 @@ export function ConnectionsPanel() {
+
+
+
+ {/* 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). */}
+
+
+
);
}
diff --git a/src/components/settings/integrations/garmin-info-note.tsx b/src/components/settings/integrations/garmin-info-note.tsx
new file mode 100644
index 000000000..4d2986acc
--- /dev/null
+++ b/src/components/settings/integrations/garmin-info-note.tsx
@@ -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 (
+
+
+ {t("settings.garminInfo.body")}{" "}
+
+ {t("settings.garminInfo.docsLink")}
+
+
+
+ }
+ />
+
+ );
+}
diff --git a/src/components/settings/integrations/oauth-provider-card.tsx b/src/components/settings/integrations/oauth-provider-card.tsx
index 49ce5e618..bc9f1d58c 100644
--- a/src/components/settings/integrations/oauth-provider-card.tsx
+++ b/src/components/settings/integrations/oauth-provider-card.tsx
@@ -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`). */
diff --git a/src/components/settings/integrations/setup-guide-link.tsx b/src/components/settings/integrations/setup-guide-link.tsx
index 34319a9ce..862d2df20 100644
--- a/src/components/settings/integrations/setup-guide-link.tsx
+++ b/src/components/settings/integrations/setup-guide-link.tsx
@@ -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}`;
diff --git a/src/components/settings/integrations/shared.tsx b/src/components/settings/integrations/shared.tsx
index 32902d8cb..a33aefdf9 100644
--- a/src/components/settings/integrations/shared.tsx
+++ b/src/components/settings/integrations/shared.tsx
@@ -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";
diff --git a/src/components/settings/integrations/strava-card.tsx b/src/components/settings/integrations/strava-card.tsx
new file mode 100644
index 000000000..b6e54ad94
--- /dev/null
+++ b/src/components/settings/integrations/strava-card.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts
index c97a7bd91..46ffd9a4c 100644
--- a/src/lib/crypto/encrypted-columns.ts
+++ b/src/lib/crypto/encrypted-columns.ts
@@ -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" },
diff --git a/src/lib/integrations/status.ts b/src/lib/integrations/status.ts
index 8688e7010..953e0f4b9 100644
--- a/src/lib/integrations/status.ts
+++ b/src/lib/integrations/status.ts
@@ -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 = {
polar: "Polar",
oura: "Oura",
"google-health": "Google Health",
+ strava: "Strava",
moodlog: "moodLog",
};
diff --git a/src/lib/jobs/reminder/register-integration-sync.ts b/src/lib/jobs/reminder/register-integration-sync.ts
index 1724ae31b..3bbe3caa7 100644
--- a/src/lib/jobs/reminder/register-integration-sync.ts
+++ b/src/lib/jobs/reminder/register-integration-sync.ts
@@ -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(
+ 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(
+ 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(
MOODLOG_SYNC_QUEUE,
{ localConcurrency: 1 },
@@ -580,6 +625,26 @@ export async function enqueueIntegrationSyncBootDiscovery(): Promise {
);
}
+ // 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.
diff --git a/src/lib/jobs/reminder/strava-sync.ts b/src/lib/jobs/reminder/strava-sync.ts
new file mode 100644
index 000000000..b3e6e1b0d
--- /dev/null
+++ b/src/lib/jobs/reminder/strava-sync.ts
@@ -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),
+});
diff --git a/src/lib/jobs/strava-backfill.ts b/src/lib/jobs/strava-backfill.ts
new file mode 100644
index 000000000..cdceb0f8a
--- /dev/null
+++ b/src/lib/jobs/strava-backfill.ts
@@ -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),
+ };
+ }
+}
diff --git a/src/lib/oauth/signed-state.ts b/src/lib/oauth/signed-state.ts
index 2992afc7a..5f9051160 100644
--- a/src/lib/oauth/signed-state.ts
+++ b/src/lib/oauth/signed-state.ts
@@ -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
diff --git a/src/lib/query-keys/integrations.ts b/src/lib/query-keys/integrations.ts
index 7900b40bc..1817b3c64 100644
--- a/src/lib/query-keys/integrations.ts
+++ b/src/lib/query-keys/integrations.ts
@@ -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,
diff --git a/src/lib/sources/pick-canonical-workout.ts b/src/lib/sources/pick-canonical-workout.ts
index 8f1e69b4a..371765c3c 100644
--- a/src/lib/sources/pick-canonical-workout.ts
+++ b/src/lib/sources/pick-canonical-workout.ts
@@ -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;
diff --git a/src/lib/strava/__tests__/client.test.ts b/src/lib/strava/__tests__/client.test.ts
new file mode 100644
index 000000000..d94eeeab9
--- /dev/null
+++ b/src/lib/strava/__tests__/client.test.ts
@@ -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).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;
+ 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).name).toBeUndefined();
+ });
+
+ it("falls back to `type` then a generic label for the sport", () => {
+ const noSport: StravaSummaryActivity = { ...baseSummary };
+ delete (noSport as unknown as Record).sport_type;
+ expect(mapActivity(noSport)!.sportType).toBe("Run");
+
+ const noneAtAll: StravaSummaryActivity = { ...baseSummary };
+ delete (noneAtAll as unknown as Record).sport_type;
+ delete (noneAtAll as unknown as Record).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).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");
+ });
+});
diff --git a/src/lib/strava/__tests__/credentials.test.ts b/src/lib/strava/__tests__/credentials.test.ts
new file mode 100644
index 000000000..138e8a187
--- /dev/null
+++ b/src/lib/strava/__tests__/credentials.test.ts
@@ -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();
+ });
+});
diff --git a/src/lib/strava/__tests__/dedup.test.ts b/src/lib/strava/__tests__/dedup.test.ts
new file mode 100644
index 000000000..889c2e2a2
--- /dev/null
+++ b/src/lib/strava/__tests__/dedup.test.ts
@@ -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);
+ });
+});
diff --git a/src/lib/strava/client.ts b/src/lib/strava/client.ts
new file mode 100644
index 000000000..34b236be2
--- /dev/null
+++ b/src/lib/strava/client.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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(
+ path: string,
+ accessToken: string,
+ verb: string,
+): Promise {
+ 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 {
+ 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(
+ `/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 {
+ return stravaGet(
+ `/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"
+ );
+}
diff --git a/src/lib/strava/credentials.ts b/src/lib/strava/credentials.ts
new file mode 100644
index 000000000..ff14695d6
--- /dev/null
+++ b/src/lib/strava/credentials.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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;
+ },
+ });
+}
diff --git a/src/lib/strava/response-classifier.ts b/src/lib/strava/response-classifier.ts
new file mode 100644
index 000000000..57f5c81fc
--- /dev/null
+++ b/src/lib/strava/response-classifier.ts
@@ -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");
+}
diff --git a/src/lib/strava/sync.ts b/src/lib/strava/sync.ts
new file mode 100644
index 000000000..6a7cdc2d2
--- /dev/null
+++ b/src/lib/strava/sync.ts
@@ -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 {
+ 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 {
+ 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(fn: (token: string) => Promise): Promise {
+ 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 {
+ 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;
+}
diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts
index 5b5961e8d..7c9e3fa22 100644
--- a/src/lib/validations/measurement.ts
+++ b/src/lib/validations/measurement.ts
@@ -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",
]);
/**
diff --git a/src/lib/validations/source-priority.ts b/src/lib/validations/source-priority.ts
index 1c71cf700..6cc21050f 100644
--- a/src/lib/validations/source-priority.ts
+++ b/src/lib/validations/source-priority.ts
@@ -238,6 +238,11 @@ export const DEFAULT_SOURCE_PRIORITY: Required = {
// 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 = {
"GOOGLE_HEALTH",
"OURA",
"POLAR",
+ "STRAVA",
"MANUAL",
],
activeEnergy: [
diff --git a/src/lib/validations/strava.ts b/src/lib/validations/strava.ts
new file mode 100644
index 000000000..08ed58187
--- /dev/null
+++ b/src/lib/validations/strava.ts
@@ -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;
diff --git a/src/proxy.ts b/src/proxy.ts
index 07639ca09..175bab03e 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -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/`,
// 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