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>
4.8 KiB
Tasks — garmin-import
1. Provider scaffold + OAuth
- 1.1 Create
providers/garmin/manifest.ts:credential_kind = 'oauth', Garmin OAuth2 endpoints, scopes, env-gatedGARMIN_CLIENT_ID/GARMIN_CLIENT_SECRET; register viaproviders/index.ts - 1.2 PKCE support in the Garmin authorize/exchange flow: generate code verifier, carry it through
oauth-state.server.tsalongside the state nonce, sendcode_challenge/code_verifieron authorize/exchange (token storage and refresh stay on the existing oauthCredentialAdapter)Design correction during apply: the
stateparam is intent-encoding only (visible in redirects), not server-side storage — the verifier must never appear in a URL, so it rides a short-lived httpOnly cookie scoped to/api/sync/callbackinstead. Manifests opt in viapkce: true. - 1.3 Capture the Garmin user id at connect time and store as
provider_user_id - 1.4
/settings/connectionsshows Garmin connect/disconnect; row hidden/disabled when env vars are absent (mirror Wahoo's gating); i18n strings (en + de) - 1.5 Unit tests: manifest registration, PKCE parameter generation/verification, connect flow state round-trip
2. Webhook ingestion
- 2.1
WebhookReceiveradapter for/api/sync/webhook/garmin: accept POST, normalize ping (fetch callback URL) and push (inline payload) into one internal activity-notification shape, respond 200 fast - 2.2 Callback-URL host allowlist (Garmin API hosts only) — drop + log anything else; never fetch unvalidated URLs (SSRF guard)
- 2.3 pg-boss job
garmin-import-activity: resolve user viaprovider_user_id, fetch FIT viawithFreshCredentials, convert via sharedfit.ts, create activity (GPX + stats + geometry; stats-only when no FIT), recordsync_imports; idempotent on duplicate notifications; respect 429/Retry-After in retry policy - 2.4 Unknown-user notifications → 200, no processing; unknown notification types → log + 200
- 2.5 Tests with recorded notification + FIT fixtures: ping path, push path, dedupe, unknown user, SSRF rejection, FIT-less stats-only
Fixtures are doc-shaped (no live credentials yet — rollout 6.x); real recorded payloads replace them during the staging soak if shapes differ. The generic webhook route + Wahoo receiver moved to an array event contract (
parseWebhook(): WebhookEvent[]) since Garmin batches notifications.
3. Backfill (historical import)
- 3.1 Backfill request client: chunk a user-supplied date range into Garmin's per-request window; issue requests via
withFreshCredentials - 3.2 Import page
/settings/connections/garmin/import: date-range form, list of requested ranges, count of imported activities per range, honest async messaging; i18n (en + de) - 3.3 Persist backfill requests (range, requested_at, user) so progress survives reloads — smallest storage that works (reuse existing tables if possible; new table only if not)
Reused
import_batcheswith two new nullable columns (range_start,range_end) — a smaller footprint than a new table, and provider-agnostic for future ranged backfills. (Deviates from the proposal's "Schema: none"; additive only.) Progress counts sync_imports rows since the request — notifications carry no batch id, so per-range attribution is a proxy by design. - 3.4 Tests: chunking math, overlap re-request safety (dedupe via
sync_imports), progress counting
4. Deregistration + lifecycle
- 4.1 Handle Garmin deregistration notifications: set
connected_services.status = 'revoked', short-circuit subsequent Garmin calls, surface the standard re-connect prompt - 4.2 Tests: deregistration flips status; revoked connection blocks backfill/import paths
5. Provenance + docs
- 5.1 "Imported from garmin" badge works via the existing provenance path; delete-and-reimport clears
sync_imports - 5.2 Privacy manifest entry on
/legal/privacy(en legal-manifest + de legal section): what we pull from Garmin, what Garmin learns, deletion/revocation semantics - 5.3 Secrets wiring:
GARMIN_CLIENT_ID/GARMIN_CLIENT_SECRETin SOPSsecrets.app.env, compose env (prod + staging),.env.examplenotes
6. Rollout (gated on Garmin developer program approval)
- 6.1 Apply to the Garmin Connect Developer Program (operator); record approved scopes/limits and the confirmed backfill chunk window in design.md
- 6.2 Register staging webhook URL in the Garmin portal; soak on staging with a real device/account: connect → record → auto-import; backfill a month; revoke from Garmin side →
revoked - 6.3 Register production webhook URL; set production secrets; verify end-to-end on trails.cool
- 6.4 Resolve the design open question on FIT-less notifications (ingest stats-only vs skip) against real fixtures; update spec annotation if behavior differs