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