feat(journal): Garmin activity import — provider, webhook pipeline, backfill (§1–5)

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>
This commit is contained in:
Ullrich Schäfer 2026-06-07 17:47:22 +02:00
parent 192481fedb
commit 0360757ae8
36 changed files with 1368 additions and 71 deletions

View file

@ -28,7 +28,7 @@ Garmin specifics that shape everything below (Garmin Connect Developer Program,
### 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, with the verifier carried through the OAuth `state` storage (`oauth-state.server.ts`) the same way the existing flow carries its state nonce.
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.

View file

@ -2,37 +2,40 @@
## 1. Provider scaffold + OAuth
- [ ] 1.1 Create `providers/garmin/manifest.ts`: `credential_kind = 'oauth'`, Garmin OAuth2 endpoints, scopes, env-gated `GARMIN_CLIENT_ID`/`GARMIN_CLIENT_SECRET`; register via `providers/index.ts`
- [ ] 1.2 PKCE support in the Garmin authorize/exchange flow: generate code verifier, carry it through `oauth-state.server.ts` alongside the state nonce, send `code_challenge`/`code_verifier` on authorize/exchange (token *storage and refresh* stay on the existing oauth `CredentialAdapter`)
- [ ] 1.3 Capture the Garmin user id at connect time and store as `provider_user_id`
- [ ] 1.4 `/settings/connections` shows 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
- [x] 1.1 Create `providers/garmin/manifest.ts`: `credential_kind = 'oauth'`, Garmin OAuth2 endpoints, scopes, env-gated `GARMIN_CLIENT_ID`/`GARMIN_CLIENT_SECRET`; register via `providers/index.ts`
- [x] 1.2 PKCE support in the Garmin authorize/exchange flow: generate code verifier, carry it through `oauth-state.server.ts` alongside the state nonce, send `code_challenge`/`code_verifier` on authorize/exchange (token *storage and refresh* stay on the existing oauth `CredentialAdapter`)
> Design correction during apply: the `state` param 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/callback` instead. Manifests opt in via `pkce: true`.
- [x] 1.3 Capture the Garmin user id at connect time and store as `provider_user_id`
- [x] 1.4 `/settings/connections` shows Garmin connect/disconnect; row hidden/disabled when env vars are absent (mirror Wahoo's gating); i18n strings (en + de)
- [x] 1.5 Unit tests: manifest registration, PKCE parameter generation/verification, connect flow state round-trip
## 2. Webhook ingestion
- [ ] 2.1 `WebhookReceiver` adapter 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 via `provider_user_id`, fetch FIT via `withFreshCredentials`, convert via shared `fit.ts`, create activity (GPX + stats + geometry; stats-only when no FIT), record `sync_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
- [x] 2.1 `WebhookReceiver` adapter 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
- [x] 2.2 Callback-URL host allowlist (Garmin API hosts only) — drop + log anything else; never fetch unvalidated URLs (SSRF guard)
- [x] 2.3 pg-boss job `garmin-import-activity`: resolve user via `provider_user_id`, fetch FIT via `withFreshCredentials`, convert via shared `fit.ts`, create activity (GPX + stats + geometry; stats-only when no FIT), record `sync_imports`; idempotent on duplicate notifications; respect 429/Retry-After in retry policy
- [x] 2.4 Unknown-user notifications → 200, no processing; unknown notification types → log + 200
- [x] 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)
- [ ] 3.4 Tests: chunking math, overlap re-request safety (dedupe via `sync_imports`), progress counting
- [x] 3.1 Backfill request client: chunk a user-supplied date range into Garmin's per-request window; issue requests via `withFreshCredentials`
- [x] 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)
- [x] 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_batches` with 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.
- [x] 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
- [x] 4.1 Handle Garmin deregistration notifications: set `connected_services.status = 'revoked'`, short-circuit subsequent Garmin calls, surface the standard re-connect prompt
- [x] 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_SECRET` in SOPS `secrets.app.env`, compose env (prod + staging), `.env.example` notes
- [x] 5.1 "Imported from garmin" badge works via the existing provenance path; delete-and-reimport clears `sync_imports`
- [x] 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
- [x] 5.3 Secrets wiring: `GARMIN_CLIENT_ID`/`GARMIN_CLIENT_SECRET` in SOPS `secrets.app.env`, compose env (prod + staging), `.env.example` notes
## 6. Rollout (gated on Garmin developer program approval)