Garmin Connect as the third connected-services provider (spec: garmin-import). The interesting parts: - Push-first ingestion: Garmin has no list endpoint. The webhook normalizes ping (callbackURL) and push (inline) notification batches into events; the slow work (authorized FIT download, FIT→GPX via the shared converter, activity creation) runs in a garmin-import-activity pg-boss job so the webhook answers fast. Callback URLs are validated against Garmin's API host before any fetch (SSRF guard). - History via backfill requests: /sync/import/garmin is a date-range requester with honest async progress (no pick list — the concept doesn't exist in a push model). Ranges chunk to Garmin's 90-day cap; overlaps are free via sync_imports dedupe. Requests persist in import_batches via two new nullable columns (range_start/range_end). - OAuth2 + PKCE on the existing oauth credential kind. Design correction from apply: the verifier rides a short-lived httpOnly cookie scoped to the callback path — the state param is visible in redirect URLs and must never carry it. Manifests opt in via pkce:true. - Deregistration notifications flip the connection to 'revoked' (row kept for audit, imports retained, re-connect prompt shown). - Framework evolutions, all additive: parseWebhook returns WebhookEvent[] (Garmin batches; Wahoo adapted), manifest gains configured()/importUrl/pkce, importActivity accepts summary stats for FIT-less imports, manager gains markRevoked. - Env-gated: no GARMIN_CLIENT_ID → provider hidden on /settings/connections. Privacy manifest entry (DE+EN). i18n en+de. Rollout (§6) stays gated on the Garmin Developer Program application (submitted 2026-06-07). Fixtures are doc-shaped; the staging soak swaps in recorded payloads if shapes differ. Gate: typecheck ✓ lint ✓ unit+integration ✓ e2e 70/72 + both known flakes green isolated ✓ openspec validate ✓ Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.2 KiB
Context
The connected-services framework (manifest + capability adapters + ConnectedServiceManager) shipped with Wahoo and was stress-tested by Komoot (a web-login/public provider). Garmin is the third provider and the first whose API is push-first: there is no "list my activities" REST endpoint to paginate. Garmin delivers data via webhook notifications — both for new activities and for requested historical backfills — which inverts the import flow the Wahoo UI assumes.
Garmin specifics that shape everything below (Garmin Connect Developer Program, Activity API):
- Auth: OAuth 2.0 with PKCE (their current scheme; OAuth 1.0a is legacy). Confidential client: token exchange and refresh use
client_id/client_secretplus the PKCE verifier on the initial exchange. Access tokens are short-lived (~24 h), refresh tokens long-lived (~3 months). - Data delivery: ping/push notifications POSTed to endpoints registered in the developer portal (not via API). Ping notifications carry a
callbackURLfrom which we pull the payload; push notifications embed it. FIT files are fetched from a callback URL with the user's token. - History: a backfill endpoint accepts a time range (chunked, bounded per request) and triggers asynchronous re-delivery of historical activities through the same notification pipeline.
- Deregistration: when a user revokes access on Garmin's side, Garmin sends a deregistration notification; partner terms require us to act on it.
- Access: requires an approved developer-program application; evaluation keys are rate-limited; production requires Garmin review.
Goals / Non-Goals
Goals:
- Garmin connect/disconnect from
/settings/connectionswith zero framework changes — the manifest/registry seams prove out on a third provider. - New Garmin activities appear in the journal automatically (push pipeline → shared FIT→GPX → activity with stats + PostGIS geometry).
- Users can pull in their Garmin history via backfill requests with honest async progress UX.
- Garmin-side revocation degrades cleanly (
revokedstatus, re-connect prompt, no orphaned polling).
Non-Goals:
- Route push to Garmin devices (Courses API) — a follow-up
RoutePusherchange once import has soaked, likewahoo-route-pushafterwahoo-import. - Wellness/health data (Health API): steps, sleep, HR — out of scope; we import activities only.
- Real-time/live tracking.
- Supporting Garmin's legacy OAuth 1.0a.
Decisions
Decision: OAuth2 + PKCE rides the existing oauth credential kind
Garmin's token blob is shape-compatible with OAuthCredentials (access_token, refresh_token, expires_at), so credential_kind = 'oauth' and the existing OAuth CredentialAdapter handle storage and refresh. PKCE only affects the authorize/exchange steps, which are already per-provider in the manifest's OAuth config — the manifest gains a code-verifier generation + code_challenge parameter. Corrected during apply: the existing state param is intent-encoding reflected through redirects (visible in URLs), not server-side storage — and the verifier must never appear in a URL. It rides a short-lived httpOnly cookie scoped to the callback path instead (oauth-state.server.ts PKCE helpers; manifests opt in via pkce: true).
Alternative considered: a new oauth-pkce credential kind. Rejected — the stored credential is identical; PKCE is a handshake detail, not a credential shape. A new kind would fork the adapter for zero storage benefit.
Decision: webhook-first ingestion; ping and push handled by one endpoint
/api/sync/webhook/garmin (generic provider webhook routing) accepts Garmin's notification POSTs. The handler normalizes both delivery styles — ping (fetch callbackURL for the payload) and push (payload inline) — into one internal "activity notification" shape, then: resolve user via provider_user_id (Garmin user id captured at connect time), fetch the FIT file, convert via shared fit.ts, create the activity, record sync_imports. Unknown users get a 200 (same don't-reveal-existence behavior as Wahoo). The endpoint must return 200 quickly — Garmin retries on failure and slow consumers get throttled — so FIT download + conversion runs as a pg-boss job (garmin-import-activity), mirroring the federation lesson that synchronous work in inbound webhooks is a trap.
Alternative considered: synchronous processing in the webhook (Wahoo does this today). Rejected for Garmin: backfill bursts deliver many notifications at once; a queue absorbs the burst and gives retries for transient FIT-download failures.
Decision: backfill-request UX instead of a Wahoo-style pick list
Garmin has no list endpoint, so the import page (/settings/connections/garmin/import) is a date-range backfill requester: pick a range (chunked into Garmin's per-request window under the hood), submit, and watch activities arrive. Progress is honest-async: the page shows requested ranges and the count of activities imported so far (rows in sync_imports attributed to the backfill window), with a note that Garmin delivers asynchronously and large histories take time. No per-activity pick list, no "Import all" button — those concepts don't exist in a push model. Per-activity dedupe stays in sync_imports, so re-requesting an overlapping range is safe and cheap.
Alternative considered: caching a local index of summaries first and offering selection before FIT download. Rejected for v1 — it doubles the moving parts for marginal value (users overwhelmingly want "import everything since X"); selective deletion after import already exists.
Decision: deregistration notifications flip status to revoked
Garmin's deregistration notification deletes nothing locally except the live link: we set status = 'revoked' on the connected_services row (the existing audit-retaining state), surface the standard re-connect prompt, and stop all Garmin API calls for that user. Imported activities stay — they're the user's data in their journal, consistent with the disconnect semantics in the connected-services spec. User-initiated deletion of imported content remains the existing per-activity/account deletion paths.
Decision: fixtures-first development; Garmin program approval gates rollout, not build
The importer/webhook are built and tested against recorded notification + FIT fixtures (same pattern as Wahoo's tests), with the live integration soaked once program credentials exist. Webhook endpoint registration in Garmin's portal targets staging first (staging.trails.cool), then production — both registrable under one app. Secrets follow the SOPS + compose pattern (GARMIN_CLIENT_ID, GARMIN_CLIENT_SECRET).
Risks / Trade-offs
- Garmin program approval is external and slow → fixtures-first build; the change is implementable and testable end-to-end without live credentials; rollout tasks are explicitly separated and can wait on Garmin without blocking merge (everything ships dark behind missing env vars, same as Wahoo on self-hosted instances).
- Webhook delivery is best-effort; missed notifications mean missed activities → backfill request doubles as a manual repair tool ("re-request last 7 days");
sync_importsdedupe makes overlap free. - Backfill bursts can spike load → pg-boss queue with bounded concurrency for FIT download/conversion; webhook handler itself stays O(1).
- Garmin rate limits (especially evaluation keys) → respect 429/Retry-After in the job's retry policy; keep per-user backfill ranges chunked.
- Ping
callbackURLis attacker-controllable input if we blindly fetch it → validate the callback host against Garmin's API host allowlist before fetching (SSRF guard — the federation work demonstrated exactly why). - Notification payload shapes are under-documented and shift between API versions → normalize early into one internal shape with tolerant parsing; unknown notification types are logged and dropped (200), never 5xx.
Migration Plan
- Everything lands dark: no env vars → Garmin row hidden/disabled on
/settings/connections(same gating as Wahoo's missing-client-id state). - Garmin developer program application (operator task, can run in parallel with implementation).
- Staging: set secrets, register staging webhook URL in the Garmin portal, soak with a real Garmin account (connect → record activity → auto-import; backfill a month; revoke from Garmin side →
revoked). - Production: same flag/secret pattern; privacy manifest entry ships with the code.
- Rollback: remove secrets (provider hides), or disconnect-only — schema is untouched, so nothing to unwind.
Open Questions
- Does our Garmin app get approved for the Activity API at production scale, and on what timeline? (Gates rollout tasks only.)
- Exact backfill chunk limit and burst rate for evaluation vs production keys — confirm against the current Activity API docs when credentials arrive; the chunking constant is one number in the manifest.
- Whether Garmin's notification includes enough summary data (duration/distance/type) to create stat-only activities for FIT-less entries (e.g. manually-logged workouts) — decide ingest-or-skip when fixtures are in hand; spec says ingest stats-only if the data is present, mirroring Wahoo's no-file behavior.