Merge pull request #455 from MBombeck/release/v1.28.11

release/v1.28.11 — Strava workouts + Garmin guidance
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-09 21:42:01 +02:00
commit e1debea02d
54 files changed
+2710 -18

No files matched your search

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