From 0360757ae86df1f30e43f8d171b043f18b75453d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 7 Jun 2026 17:47:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(journal):=20Garmin=20activity=20import=20?= =?UTF-8?q?=E2=80=94=20provider,=20webhook=20pipeline,=20backfill=20(?= =?UTF-8?q?=C2=A71=E2=80=935)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/journal/.env.example | 8 + .../app/jobs/garmin-import-activity.ts | 33 +++ .../app/lib/connected-services/manager.ts | 12 ++ .../connected-services/oauth-state.server.ts | 36 ++++ .../connected-services/oauth-state.test.ts | 61 ++++++ .../providers/garmin/backfill.test.ts | 42 ++++ .../providers/garmin/backfill.ts | 111 ++++++++++ .../providers/garmin/constants.ts | 8 + .../providers/garmin/import.server.ts | 82 ++++++++ .../providers/garmin/manifest.test.ts | 66 ++++++ .../providers/garmin/manifest.ts | 120 +++++++++++ .../providers/garmin/webhook.test.ts | 194 ++++++++++++++++++ .../providers/garmin/webhook.ts | 154 ++++++++++++++ .../lib/connected-services/providers/index.ts | 4 +- .../providers/wahoo/webhook.test.ts | 24 ++- .../providers/wahoo/webhook.ts | 22 +- .../app/lib/connected-services/registry.ts | 33 ++- apps/journal/app/lib/sync/imports.server.ts | 18 +- apps/journal/app/routes.ts | 1 + .../app/routes/api.sync.callback.$provider.ts | 20 +- .../app/routes/api.sync.connect.$provider.ts | 16 +- .../routes/api.sync.webhook.$provider.test.ts | 4 +- .../app/routes/api.sync.webhook.$provider.ts | 14 +- apps/journal/app/routes/legal.privacy.tsx | 23 ++- .../app/routes/settings.connections.server.ts | 25 ++- .../app/routes/settings.connections.tsx | 2 +- .../app/routes/sync.import.garmin.server.ts | 91 ++++++++ .../journal/app/routes/sync.import.garmin.tsx | 112 ++++++++++ apps/journal/server.ts | 3 +- infrastructure/docker-compose.staging.yml | 2 + infrastructure/docker-compose.yml | 4 + openspec/changes/garmin-import/design.md | 2 +- openspec/changes/garmin-import/tasks.md | 41 ++-- packages/db/src/schema/journal.ts | 5 + packages/i18n/src/locales/de.ts | 23 +++ packages/i18n/src/locales/en.ts | 23 +++ 36 files changed, 1368 insertions(+), 71 deletions(-) create mode 100644 apps/journal/app/jobs/garmin-import-activity.ts create mode 100644 apps/journal/app/lib/connected-services/oauth-state.test.ts create mode 100644 apps/journal/app/lib/connected-services/providers/garmin/backfill.test.ts create mode 100644 apps/journal/app/lib/connected-services/providers/garmin/backfill.ts create mode 100644 apps/journal/app/lib/connected-services/providers/garmin/constants.ts create mode 100644 apps/journal/app/lib/connected-services/providers/garmin/import.server.ts create mode 100644 apps/journal/app/lib/connected-services/providers/garmin/manifest.test.ts create mode 100644 apps/journal/app/lib/connected-services/providers/garmin/manifest.ts create mode 100644 apps/journal/app/lib/connected-services/providers/garmin/webhook.test.ts create mode 100644 apps/journal/app/lib/connected-services/providers/garmin/webhook.ts create mode 100644 apps/journal/app/routes/sync.import.garmin.server.ts create mode 100644 apps/journal/app/routes/sync.import.garmin.tsx diff --git a/apps/journal/.env.example b/apps/journal/.env.example index ef8718a..8a51ed0 100644 --- a/apps/journal/.env.example +++ b/apps/journal/.env.example @@ -49,6 +49,14 @@ # WAHOO_CLIENT_SECRET= # WAHOO_WEBHOOK_TOKEN= +# Garmin Connect Developer Program credentials (spec: garmin-import). +# Requires an approved program application; without these the Garmin +# provider is hidden on /settings/connections. The OAuth callback to +# register with Garmin is `/api/sync/callback/garmin`, the +# notification endpoint `/api/sync/webhook/garmin`. +# GARMIN_CLIENT_ID= +# GARMIN_CLIENT_SECRET= + # Integration test secret (only needed if running the integration # test suite that drives the API directly). Generate with # `openssl rand -hex 32`. diff --git a/apps/journal/app/jobs/garmin-import-activity.ts b/apps/journal/app/jobs/garmin-import-activity.ts new file mode 100644 index 0000000..edff614 --- /dev/null +++ b/apps/journal/app/jobs/garmin-import-activity.ts @@ -0,0 +1,33 @@ +import type { JobDefinition } from "@trails-cool/jobs"; +import { logger } from "../lib/logger.server.ts"; +import { + runGarminActivityImport, + type GarminImportData, +} from "../lib/connected-services/providers/garmin/import.server.ts"; + +// Garmin webhook notifications enqueue here (spec: garmin-import, +// "Push-notification activity import"): the webhook answers 200 +// immediately and this job does the slow part — authorized file +// download, FIT→GPX, activity creation. Backfill bursts deliver many +// notifications at once; the queue absorbs them and pg-boss retries +// transient download failures. +export const garminImportActivityJob: JobDefinition = { + name: "garmin-import-activity", + retryLimit: 3, + expireInSeconds: 300, + async handler(jobs) { + const batch = Array.isArray(jobs) ? jobs : [jobs]; + for (const job of batch) { + const data = job.data as GarminImportData; + try { + await runGarminActivityImport(data); + } catch (err) { + logger.warn( + { err, externalId: data.externalId }, + "garmin-import-activity failed (pg-boss will retry)", + ); + throw err; + } + } + }, +}; diff --git a/apps/journal/app/lib/connected-services/manager.ts b/apps/journal/app/lib/connected-services/manager.ts index 511e3ae..706795f 100644 --- a/apps/journal/app/lib/connected-services/manager.ts +++ b/apps/journal/app/lib/connected-services/manager.ts @@ -168,6 +168,18 @@ export async function markNeedsRelink( console.warn(`[connected-services] ${serviceId} flipped to needs_relink: ${reason}`); } +// Provider-side revocation (e.g. a Garmin deregistration notification): +// keep the row for audit, flip to 'revoked' so every subsequent +// withFreshCredentials short-circuits and the UI shows a re-connect +// prompt. Imported activities are untouched. +export async function markRevoked(serviceId: string): Promise { + const db = getDb(); + await db + .update(connectedServices) + .set({ status: "revoked" }) + .where(eq(connectedServices.id, serviceId)); +} + export async function updateGrantedScopes( serviceId: string, grantedScopes: string[], diff --git a/apps/journal/app/lib/connected-services/oauth-state.server.ts b/apps/journal/app/lib/connected-services/oauth-state.server.ts index fe886af..ae0fb8a 100644 --- a/apps/journal/app/lib/connected-services/oauth-state.server.ts +++ b/apps/journal/app/lib/connected-services/oauth-state.server.ts @@ -21,3 +21,39 @@ export function decodeOAuthState(raw: string | null | undefined): PushOAuthState return {}; } } + +// --- PKCE (RFC 7636) ---------------------------------------------------- +// +// Providers with `pkce: true` (Garmin) need a code verifier that survives +// the connect → provider → callback redirect without ever appearing in a +// URL (the whole point of PKCE is that the verifier stays out of the +// authorization response). The `state` param is visible in redirects, so +// the verifier rides a short-lived httpOnly cookie scoped to the callback +// path instead. + +import { createHash, randomBytes } from "node:crypto"; + +const PKCE_COOKIE = "__oauth_pkce"; +const PKCE_MAX_AGE_S = 600; + +export function generatePkcePair(): { verifier: string; challenge: string } { + // 32 random bytes → 43-char base64url verifier (within RFC 7636's 43–128). + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +export function pkceCookieHeader(verifier: string): string { + const secure = process.env.NODE_ENV === "production" ? "; Secure" : ""; + return `${PKCE_COOKIE}=${verifier}; Max-Age=${PKCE_MAX_AGE_S}; Path=/api/sync/callback; HttpOnly; SameSite=Lax${secure}`; +} + +export function clearPkceCookieHeader(): string { + return `${PKCE_COOKIE}=; Max-Age=0; Path=/api/sync/callback; HttpOnly; SameSite=Lax`; +} + +export function readPkceVerifier(request: Request): string | null { + const cookie = request.headers.get("Cookie") ?? ""; + const match = cookie.match(new RegExp(`(?:^|;\\s*)${PKCE_COOKIE}=([^;]+)`)); + return match?.[1] ?? null; +} diff --git a/apps/journal/app/lib/connected-services/oauth-state.test.ts b/apps/journal/app/lib/connected-services/oauth-state.test.ts new file mode 100644 index 0000000..36d9f90 --- /dev/null +++ b/apps/journal/app/lib/connected-services/oauth-state.test.ts @@ -0,0 +1,61 @@ +// PKCE helper tests (RFC 7636 S256) — spec: garmin-import, task 1.2. + +import { createHash } from "node:crypto"; +import { describe, it, expect } from "vitest"; +import { + generatePkcePair, + pkceCookieHeader, + readPkceVerifier, + clearPkceCookieHeader, + encodeOAuthState, + decodeOAuthState, +} from "./oauth-state.server.ts"; + +describe("generatePkcePair", () => { + it("produces an RFC 7636-compliant verifier and matching S256 challenge", () => { + const { verifier, challenge } = generatePkcePair(); + expect(verifier.length).toBeGreaterThanOrEqual(43); + expect(verifier.length).toBeLessThanOrEqual(128); + expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/); // base64url charset + const expected = createHash("sha256").update(verifier).digest("base64url"); + expect(challenge).toBe(expected); + }); + + it("is unique per call", () => { + expect(generatePkcePair().verifier).not.toBe(generatePkcePair().verifier); + }); +}); + +describe("PKCE cookie round trip", () => { + it("verifier set on connect is readable on callback", () => { + const { verifier } = generatePkcePair(); + const setCookie = pkceCookieHeader(verifier); + // Browser reflects the cookie value back on the callback request. + const cookieValue = setCookie.split(";")[0]!; + const request = new Request("https://x.example/api/sync/callback/garmin", { + headers: { Cookie: `other=1; ${cookieValue}` }, + }); + expect(readPkceVerifier(request)).toBe(verifier); + }); + + it("returns null without the cookie; clear header expires it", () => { + const request = new Request("https://x.example/", { headers: { Cookie: "other=1" } }); + expect(readPkceVerifier(request)).toBeNull(); + expect(clearPkceCookieHeader()).toContain("Max-Age=0"); + }); + + it("cookie is httpOnly and scoped to the callback path", () => { + const header = pkceCookieHeader("v"); + expect(header).toContain("HttpOnly"); + expect(header).toContain("Path=/api/sync/callback"); + }); +}); + +describe("oauth state encoding (pre-existing behavior)", () => { + it("round-trips and tolerates garbage", () => { + const encoded = encodeOAuthState({ returnTo: "/settings/connections" }); + expect(decodeOAuthState(encoded)).toEqual({ returnTo: "/settings/connections" }); + expect(decodeOAuthState("%%%not-base64%%%")).toEqual({}); + expect(decodeOAuthState(null)).toEqual({}); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/garmin/backfill.test.ts b/apps/journal/app/lib/connected-services/providers/garmin/backfill.test.ts new file mode 100644 index 0000000..9c1be9e --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/backfill.test.ts @@ -0,0 +1,42 @@ +// Unit tests for Garmin backfill chunking + request fan-out +// (spec: garmin-import, "Historical import via backfill"). + +import { describe, it, expect, vi } from "vitest"; + +vi.mock("../../manager.ts", () => ({ withFreshCredentials: vi.fn() })); +vi.mock("../../../db.ts", () => ({ getDb: () => ({}) })); + +const { chunkRange, BACKFILL_CHUNK_MS } = await import("./backfill.ts"); + +const DAY = 24 * 60 * 60 * 1000; + +describe("chunkRange", () => { + it("returns a single chunk for ranges within the cap", () => { + const chunks = chunkRange(0, 30 * DAY); + expect(chunks).toEqual([{ fromMs: 0, toMs: 30 * DAY }]); + }); + + it("splits ranges larger than the cap, last chunk clamped", () => { + const chunks = chunkRange(0, 200 * DAY); + expect(chunks).toHaveLength(3); + expect(chunks[0]).toEqual({ fromMs: 0, toMs: BACKFILL_CHUNK_MS }); + expect(chunks[2]!.toMs).toBe(200 * DAY); + // contiguous, no gaps or overlaps + expect(chunks[1]!.fromMs).toBe(chunks[0]!.toMs); + expect(chunks[2]!.fromMs).toBe(chunks[1]!.toMs); + }); + + it("returns [] for empty or inverted ranges", () => { + expect(chunkRange(5, 5)).toEqual([]); + expect(chunkRange(10, 5)).toEqual([]); + }); + + it("covers exactly the requested range at chunk boundaries", () => { + const chunks = chunkRange(0, 2 * BACKFILL_CHUNK_MS); + expect(chunks).toHaveLength(2); + expect(chunks[1]).toEqual({ + fromMs: BACKFILL_CHUNK_MS, + toMs: 2 * BACKFILL_CHUNK_MS, + }); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/garmin/backfill.ts b/apps/journal/app/lib/connected-services/providers/garmin/backfill.ts new file mode 100644 index 0000000..1055a78 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/backfill.ts @@ -0,0 +1,111 @@ +// Garmin historical import via the Activity API backfill endpoint +// (spec: garmin-import, "Historical import via backfill"). +// +// Garmin has no list-activities endpoint: you ask for a time range and +// Garmin re-delivers those activities asynchronously through the same +// notification pipeline the live webhook uses. Each accepted request +// returns 202; the data arrives whenever Garmin gets to it. + +import { randomUUID } from "node:crypto"; +import { fetchWithTimeout } from "../../../http.server.ts"; +import { withFreshCredentials } from "../../manager.ts"; +import type { OAuthCredentials } from "../../types.ts"; +import { GARMIN_API } from "./constants.ts"; + +// Garmin caps a single backfill request's window. 90 days per the +// Activity API docs; if program onboarding reveals a different cap for +// our key, this constant is the only thing to change (design.md, open +// questions). +export const BACKFILL_CHUNK_MS = 90 * 24 * 60 * 60 * 1000; + +const BACKFILL_URL = `${GARMIN_API}/wellness-api/rest/backfill/activities`; + +/** + * Split [from, to] into Garmin-sized chunks (inclusive bounds, ms). + * Returns [] for empty/inverted ranges. + */ +export function chunkRange( + fromMs: number, + toMs: number, + chunkMs: number = BACKFILL_CHUNK_MS, +): Array<{ fromMs: number; toMs: number }> { + if (!(fromMs < toMs) || chunkMs <= 0) return []; + const chunks: Array<{ fromMs: number; toMs: number }> = []; + for (let start = fromMs; start < toMs; start += chunkMs) { + chunks.push({ fromMs: start, toMs: Math.min(start + chunkMs, toMs) }); + } + return chunks; +} + +export interface BackfillDeps { + requestChunk( + serviceId: string, + fromSec: number, + toSec: number, + ): Promise; +} + +function defaultDeps(): BackfillDeps { + return { + async requestChunk(serviceId, fromSec, toSec) { + await withFreshCredentials(serviceId, async (credentials) => { + const creds = credentials as OAuthCredentials; + const url = `${BACKFILL_URL}?summaryStartTimeInSeconds=${fromSec}&summaryEndTimeInSeconds=${toSec}`; + const resp = await fetchWithTimeout(url, { + headers: { Authorization: `Bearer ${creds.access_token}` }, + }); + // 202 = accepted. 409 = an identical/overlapping request is + // already in flight — fine, the data will arrive either way + // and sync_imports dedupes. + if (!resp.ok && resp.status !== 409) { + const text = await resp.text().catch(() => ""); + throw new Error(`Garmin backfill request failed: ${resp.status} ${text}`); + } + }); + }, + }; +} + +/** + * Issue backfill requests covering [from, to] and persist one + * import_batches row describing the whole request (progress UX reads + * it back on the import page). + */ +export async function requestBackfill( + service: { id: string; userId: string }, + from: Date, + to: Date, + deps: BackfillDeps = defaultDeps(), +): Promise<{ batchId: string; chunks: number }> { + const chunks = chunkRange(from.getTime(), to.getTime()); + if (chunks.length === 0) throw new Error("Empty backfill range"); + + for (const chunk of chunks) { + await deps.requestChunk( + service.id, + Math.floor(chunk.fromMs / 1000), + Math.floor(chunk.toMs / 1000), + ); + } + + // Record the request for the import page. Lazy import keeps the DB + // out of this module's graph for pure-function tests (chunkRange). + const { getDb } = await import("../../../db.ts"); + const { importBatches } = await import("@trails-cool/db/schema/journal"); + const batchId = randomUUID(); + await getDb() + .insert(importBatches) + .values({ + id: batchId, + userId: service.userId, + connectionId: service.id, + provider: "garmin", + // Garmin delivers asynchronously — the batch is "running" from + // our perspective until the operator-facing page stops caring. + // totalFound is unknowable up front (no list endpoint). + status: "running", + rangeStart: from, + rangeEnd: to, + }); + return { batchId, chunks: chunks.length }; +} diff --git a/apps/journal/app/lib/connected-services/providers/garmin/constants.ts b/apps/journal/app/lib/connected-services/providers/garmin/constants.ts new file mode 100644 index 0000000..955a191 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/constants.ts @@ -0,0 +1,8 @@ +// Garmin endpoint constants — own module so manifest.ts, webhook.ts, +// import.server.ts, and backfill.ts can all use them without forming +// an import cycle (manifest → webhook → import.server must never loop +// back into manifest for a value needed at module-eval time). + +export const GARMIN_API = "https://apis.garmin.com"; +export const GARMIN_AUTHORIZE = "https://connect.garmin.com/oauth2Confirm"; +export const GARMIN_TOKEN = "https://diauth.garmin.com/di-oauth2-service/oauth/token"; diff --git a/apps/journal/app/lib/connected-services/providers/garmin/import.server.ts b/apps/journal/app/lib/connected-services/providers/garmin/import.server.ts new file mode 100644 index 0000000..6c9f439 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/import.server.ts @@ -0,0 +1,82 @@ +// Garmin activity import — the slow half of the webhook pipeline. +// Runs inside the `garmin-import-activity` pg-boss job: download the +// activity file from the notification's callback URL (Authorized via +// the user's token), convert to GPX, create the activity, record the +// dedupe row. Stats-only when there is no file. + +import { fitToGpx } from "../../fit.ts"; +import { fetchWithTimeout } from "../../../http.server.ts"; +import { isAlreadyImported, importActivity } from "../../../sync/imports.server.ts"; +import { getServiceById, withFreshCredentials } from "../../manager.ts"; +import type { OAuthCredentials } from "../../types.ts"; +import { logger } from "../../../logger.server.ts"; +import { GARMIN_API } from "./constants.ts"; + +// SSRF guard: notification callback URLs are attacker-controllable +// input until proven otherwise — only Garmin's API host is fetchable. +const ALLOWED_CALLBACK_HOSTS = new Set([new URL(GARMIN_API).host]); + +export function isAllowedGarminCallback(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "https:" && ALLOWED_CALLBACK_HOSTS.has(parsed.host); + } catch { + return false; + } +} + +export interface GarminImportData { + serviceId: string; + userId: string; + externalId: string; + callbackUrl: string | null; + fileType: string | null; + name: string | null; + startedAt: string | null; + duration: number | null; + distance: number | null; +} + +export async function runGarminActivityImport(data: GarminImportData): Promise { + // Connection may have been revoked/relinked between enqueue and run. + const service = await getServiceById(data.serviceId); + if (!service || service.status !== "active") { + logger.info({ serviceId: data.serviceId }, "garmin import: connection not active — skipped"); + return; + } + + if (await isAlreadyImported(data.userId, "garmin", data.externalId)) return; + + let gpx: string | undefined; + if (data.callbackUrl && isAllowedGarminCallback(data.callbackUrl)) { + const buffer = await withFreshCredentials(service.id, async (credentials) => { + const creds = credentials as OAuthCredentials; + const resp = await fetchWithTimeout(data.callbackUrl!, { + headers: { Authorization: `Bearer ${creds.access_token}` }, + }); + if (!resp.ok) { + throw new Error(`Garmin file download failed: ${resp.status}`); + } + return Buffer.from(await resp.arrayBuffer()); + }); + if (data.fileType === "GPX") { + // Garmin can serve GPX directly; createActivity validates it. + gpx = buffer.toString("utf8"); + } else { + // FIT (default) — shared provider-agnostic converter. + gpx = (await fitToGpx(buffer, data.name ?? "Garmin activity")) ?? undefined; + } + } + + await importActivity(data.userId, "garmin", data.externalId, { + name: data.name ?? "Garmin activity", + gpx, + distance: data.distance, + duration: data.duration, + startedAt: data.startedAt ? new Date(data.startedAt) : null, + }); + logger.info( + { externalId: data.externalId, hadFile: !!gpx }, + "garmin import: activity imported", + ); +} diff --git a/apps/journal/app/lib/connected-services/providers/garmin/manifest.test.ts b/apps/journal/app/lib/connected-services/providers/garmin/manifest.test.ts new file mode 100644 index 0000000..531f181 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/manifest.test.ts @@ -0,0 +1,66 @@ +// Manifest contract tests (spec: garmin-import, "Connect Garmin +// account" + PKCE parameters + env gating). + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +vi.mock("../../manager.ts", () => ({ + getServiceByProviderUser: vi.fn(), + markRevoked: vi.fn(), + getServiceById: vi.fn(), + withFreshCredentials: vi.fn(), +})); +vi.mock("../../../boss.server.ts", () => ({ enqueueOptional: vi.fn() })); +vi.mock("../../../sync/imports.server.ts", () => ({ + isAlreadyImported: vi.fn(), + importActivity: vi.fn(), +})); +vi.mock("../../../db.ts", () => ({ getDb: () => ({}) })); + +const { garminManifest } = await import("./manifest.ts"); + +const ENV_KEYS = ["GARMIN_CLIENT_ID", "GARMIN_CLIENT_SECRET"] as const; +const saved: Record = {}; + +beforeEach(() => { + for (const k of ENV_KEYS) saved[k] = process.env[k]; +}); +afterEach(() => { + for (const k of ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } +}); + +describe("garminManifest", () => { + it("declares oauth + PKCE, no pick-list importer, custom import page", () => { + expect(garminManifest.id).toBe("garmin"); + expect(garminManifest.credentialKind).toBe("oauth"); + expect(garminManifest.pkce).toBe(true); + expect(garminManifest.importer).toBeUndefined(); + expect(garminManifest.importUrl).toBe("/sync/import/garmin"); + expect(garminManifest.webhookReceiver).toBeDefined(); + }); + + it("is hidden without instance credentials, shown with them", () => { + delete process.env.GARMIN_CLIENT_ID; + expect(garminManifest.configured!()).toBe(false); + process.env.GARMIN_CLIENT_ID = "test-client"; + expect(garminManifest.configured!()).toBe(true); + }); + + it("buildAuthUrl carries the S256 code challenge", () => { + process.env.GARMIN_CLIENT_ID = "test-client"; + const url = new URL( + garminManifest.buildAuthUrl!( + "https://journal.example/api/sync/callback/garmin", + "state-123", + { codeChallenge: "challenge-abc" }, + ), + ); + expect(url.origin + url.pathname).toBe("https://connect.garmin.com/oauth2Confirm"); + expect(url.searchParams.get("client_id")).toBe("test-client"); + expect(url.searchParams.get("code_challenge")).toBe("challenge-abc"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("state")).toBe("state-123"); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/garmin/manifest.ts b/apps/journal/app/lib/connected-services/providers/garmin/manifest.ts new file mode 100644 index 0000000..c9a9398 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/manifest.ts @@ -0,0 +1,120 @@ +// Garmin provider manifest (spec: garmin-import). OAuth2 + PKCE on the +// shared oauth credential adapter — PKCE is a handshake detail (see +// design.md), the stored blob is plain OAuthCredentials. +// +// Garmin is push-first: there is no list-activities endpoint, so this +// manifest declares no `importer`. Ingestion happens via the webhook +// receiver (ping/push notifications) and history via backfill requests +// (see backfill.ts + the /sync/import/garmin page). +// +// Endpoint references: Garmin Connect Developer Program, Activity API. +// Exact notification shapes are normalized tolerantly in webhook.ts — +// Garmin's docs shift between API versions (design.md, Risks). + +import { oauthCredentialAdapter } from "../../credential-adapters/oauth.ts"; +import type { ProviderManifest } from "../../registry.ts"; +import type { OAuthCredentials, ProviderOAuthConfig } from "../../types.ts"; +import { garminWebhook } from "./webhook.ts"; +import { GARMIN_API, GARMIN_AUTHORIZE, GARMIN_TOKEN } from "./constants.ts"; + +function clientId(): string { + return process.env.GARMIN_CLIENT_ID ?? ""; +} +function clientSecret(): string { + return process.env.GARMIN_CLIENT_SECRET ?? ""; +} + +const oauthConfig: ProviderOAuthConfig = { + get tokenUrl() { + return GARMIN_TOKEN; + }, + get clientId() { + return clientId(); + }, + get clientSecret() { + return clientSecret(); + }, +}; + +export const garminManifest: ProviderManifest = { + id: "garmin", + displayName: "Garmin", + credentialKind: "oauth", + credentialAdapter: oauthCredentialAdapter as ProviderManifest["credentialAdapter"], + oauthConfig, + pkce: true, + // No instance credentials → no Garmin row on the connections page. + // (Garmin program keys are per-operator; self-hosted instances + // without one must not render a dead Connect button.) + configured: () => clientId().length > 0, + // Backfill requester, not a pick list — Garmin has no list endpoint. + importUrl: "/sync/import/garmin", + + buildAuthUrl(redirectUri, state, extras): string { + const params = new URLSearchParams({ + client_id: clientId(), + response_type: "code", + redirect_uri: redirectUri, + state, + }); + if (extras?.codeChallenge) { + params.set("code_challenge", extras.codeChallenge); + params.set("code_challenge_method", "S256"); + } + return `${GARMIN_AUTHORIZE}?${params}`; + }, + + async exchangeCode( + code, + redirectUri, + extras, + ): Promise<{ + credentials: OAuthCredentials; + providerUserId: string | null; + grantedScopes: string[]; + }> { + const resp = await fetch(GARMIN_TOKEN, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: clientId(), + client_secret: clientSecret(), + code, + code_verifier: extras?.codeVerifier ?? "", + redirect_uri: redirectUri, + }).toString(), + }); + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + throw new Error(`Garmin token exchange failed: ${resp.status} ${text}`); + } + const data = (await resp.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + scope?: string; + }; + + // Garmin user id — needed to route webhook notifications to the + // right local user. + const userResp = await fetch(`${GARMIN_API}/wellness-api/rest/user/id`, { + headers: { Authorization: `Bearer ${data.access_token}` }, + }); + const user = userResp.ok + ? ((await userResp.json()) as { userId?: string }) + : null; + + return { + credentials: { + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_at: new Date(Date.now() + data.expires_in * 1000).toISOString(), + }, + providerUserId: user?.userId ?? null, + grantedScopes: data.scope ? data.scope.split(" ") : [], + }; + }, + + webhookReceiver: garminWebhook, +}; diff --git a/apps/journal/app/lib/connected-services/providers/garmin/webhook.test.ts b/apps/journal/app/lib/connected-services/providers/garmin/webhook.test.ts new file mode 100644 index 0000000..c360717 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/webhook.test.ts @@ -0,0 +1,194 @@ +// Contract tests for the Garmin WebhookReceiver (spec: garmin-import). +// +// Seam: parseWebhook(body) -> WebhookEvent[] (Garmin batches notifications) +// handle(event) -> void (enqueues the import job; deregistration +// revokes; SSRF-suspicious callback URLs are dropped) + +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const mockGetServiceByProviderUser = vi.fn(); +const mockMarkRevoked = vi.fn(); +const mockEnqueueOptional = vi.fn(); + +vi.mock("../../manager.ts", () => ({ + getServiceByProviderUser: mockGetServiceByProviderUser, + markRevoked: mockMarkRevoked, + // imported transitively via import.server.ts + getServiceById: vi.fn(), + withFreshCredentials: vi.fn(), +})); +vi.mock("../../../boss.server.ts", () => ({ + enqueueOptional: mockEnqueueOptional, +})); +vi.mock("../../../sync/imports.server.ts", () => ({ + isAlreadyImported: vi.fn(), + importActivity: vi.fn(), +})); +vi.mock("../../../db.ts", () => ({ getDb: () => ({}) })); + +beforeEach(() => { + mockGetServiceByProviderUser.mockReset(); + mockMarkRevoked.mockReset(); + mockEnqueueOptional.mockReset(); +}); + +const { garminWebhook } = await import("./webhook.ts"); +const { isAllowedGarminCallback } = await import("./import.server.ts"); + +describe("garminWebhook.parseWebhook", () => { + it("parses a ping-style activityFiles batch into file events", () => { + const events = garminWebhook.parseWebhook({ + activityFiles: [ + { + userId: "g-user-1", + summaryId: "s-1", + activityId: 1001, + activityName: "Morning Run", + callbackURL: "https://apis.garmin.com/wellness-api/rest/activityFile?id=1001", + fileType: "FIT", + startTimeInSeconds: 1780000000, + durationInSeconds: 3600, + distanceInMeters: 10000, + }, + ], + }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + eventType: "garmin:activity-file", + providerUserId: "g-user-1", + workoutId: "1001", + fileUrl: expect.stringContaining("apis.garmin.com"), + name: "Morning Run", + duration: 3600, + distance: 10000, + fileType: "FIT", + }); + }); + + it("parses push-style activity summaries (FIT-less, stats-only path)", () => { + const events = garminWebhook.parseWebhook({ + activities: [ + { + userId: "g-user-1", + summaryId: "s-2", + activityId: 1002, + activityName: "Indoor Row", + durationInSeconds: 1800, + }, + ], + }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + eventType: "garmin:activity-summary", + workoutId: "1002", + duration: 1800, + }); + expect(events[0]!.fileUrl).toBeUndefined(); + }); + + it("parses deregistrations", () => { + const events = garminWebhook.parseWebhook({ + deregistrations: [{ userId: "g-user-1" }], + }); + expect(events).toEqual([ + { + eventType: "garmin:deregistration", + providerUserId: "g-user-1", + workoutId: "", + }, + ]); + }); + + it("handles mixed batches and skips malformed entries", () => { + const events = garminWebhook.parseWebhook({ + activityFiles: [ + { userId: "u1", activityId: 1 }, + { activityId: 2 }, // no userId — skipped + { userId: "u3" }, // no id — skipped + ], + deregistrations: [{}, { userId: "u4" }], + somethingGarminAddedLater: [{ userId: "u5" }], + }); + expect(events.map((e) => e.eventType)).toEqual([ + "garmin:activity-file", + "garmin:deregistration", + ]); + }); + + it("returns no events for non-object bodies", () => { + expect(garminWebhook.parseWebhook(null)).toEqual([]); + expect(garminWebhook.parseWebhook("x")).toEqual([]); + }); +}); + +describe("garminWebhook.handle", () => { + const service = { id: "svc-g1", userId: "u1", provider: "garmin" }; + + it("enqueues the import job for a known user's file event", async () => { + mockGetServiceByProviderUser.mockResolvedValue(service); + + await garminWebhook.handle({ + eventType: "garmin:activity-file", + providerUserId: "g-user-1", + workoutId: "1001", + fileUrl: "https://apis.garmin.com/wellness-api/rest/activityFile?id=1001", + fileType: "FIT", + name: "Morning Run", + }); + + expect(mockEnqueueOptional).toHaveBeenCalledWith( + "garmin-import-activity", + expect.objectContaining({ + serviceId: "svc-g1", + userId: "u1", + externalId: "1001", + callbackUrl: expect.stringContaining("apis.garmin.com"), + fileType: "FIT", + }), + expect.anything(), + ); + }); + + it("silently skips unknown users (no leak)", async () => { + mockGetServiceByProviderUser.mockResolvedValue(null); + await garminWebhook.handle({ + eventType: "garmin:activity-file", + providerUserId: "nobody", + workoutId: "1", + }); + expect(mockEnqueueOptional).not.toHaveBeenCalled(); + expect(mockMarkRevoked).not.toHaveBeenCalled(); + }); + + it("drops events whose callback URL is not Garmin's API host (SSRF guard)", async () => { + mockGetServiceByProviderUser.mockResolvedValue(service); + await garminWebhook.handle({ + eventType: "garmin:activity-file", + providerUserId: "g-user-1", + workoutId: "1001", + fileUrl: "https://attacker.example/steal?token=", + }); + expect(mockEnqueueOptional).not.toHaveBeenCalled(); + }); + + it("revokes the connection on deregistration", async () => { + mockGetServiceByProviderUser.mockResolvedValue(service); + await garminWebhook.handle({ + eventType: "garmin:deregistration", + providerUserId: "g-user-1", + workoutId: "", + }); + expect(mockMarkRevoked).toHaveBeenCalledWith("svc-g1"); + expect(mockEnqueueOptional).not.toHaveBeenCalled(); + }); +}); + +describe("isAllowedGarminCallback", () => { + it("allows only https URLs on Garmin's API host", () => { + expect(isAllowedGarminCallback("https://apis.garmin.com/wellness-api/rest/x")).toBe(true); + expect(isAllowedGarminCallback("http://apis.garmin.com/x")).toBe(false); + expect(isAllowedGarminCallback("https://apis.garmin.com.evil.example/x")).toBe(false); + expect(isAllowedGarminCallback("https://evil.example/apis.garmin.com")).toBe(false); + expect(isAllowedGarminCallback("not a url")).toBe(false); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/garmin/webhook.ts b/apps/journal/app/lib/connected-services/providers/garmin/webhook.ts new file mode 100644 index 0000000..aded436 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/webhook.ts @@ -0,0 +1,154 @@ +// Garmin WebhookReceiver capability adapter (spec: garmin-import, +// "Push-notification activity import" + "Deregistration handling"). +// +// Garmin POSTs notifications to /api/sync/webhook/garmin in batches: +// - `activityFiles`: ping-style entries with a callbackURL to a FIT/GPX +// file (the main import path) +// - `activities` / `activityDetails`: summary entries (stats; used for +// FIT-less activities) +// - `deregistrations`: the user revoked access on Garmin's side +// +// The webhook must answer fast (Garmin retries and throttles slow +// consumers), so `handle` only validates + enqueues; the download and +// FIT→GPX conversion happen in the `garmin-import-activity` pg-boss job +// (same lesson as federation's inbox: never do slow work in an inbound +// hook). Deregistrations are the exception — a single UPDATE is cheap +// and must not be lost to a queue hiccup. +// +// Notification shapes are under-documented and drift between Garmin API +// versions, so parsing is tolerant: unknown keys are ignored, malformed +// entries are skipped, and nothing here ever throws on bad input. + +import { logger } from "../../../logger.server.ts"; +import { enqueueOptional } from "../../../boss.server.ts"; +import { getServiceByProviderUser, markRevoked } from "../../manager.ts"; +import type { WebhookEvent, WebhookReceiver } from "../../registry.ts"; +import { isAllowedGarminCallback } from "./import.server.ts"; + +interface GarminNotificationEntry { + userId?: string; + summaryId?: string | number; + activityId?: string | number; + activityName?: string; + activityType?: string; + callbackURL?: string; + fileType?: string; + startTimeInSeconds?: number; + durationInSeconds?: number; + distanceInMeters?: number; +} + +interface GarminWebhookBody { + activityFiles?: GarminNotificationEntry[]; + activities?: GarminNotificationEntry[]; + activityDetails?: GarminNotificationEntry[]; + deregistrations?: { userId?: string }[]; +} + +const EVENT_FILE = "garmin:activity-file"; +const EVENT_SUMMARY = "garmin:activity-summary"; +const EVENT_DEREGISTRATION = "garmin:deregistration"; + +function externalId(entry: GarminNotificationEntry): string { + const id = entry.activityId ?? entry.summaryId; + return id == null ? "" : String(id); +} + +function entryToEvent( + entry: GarminNotificationEntry, + eventType: string, +): WebhookEvent | null { + if (!entry.userId || !externalId(entry)) return null; + return { + eventType, + providerUserId: entry.userId, + workoutId: externalId(entry), + fileUrl: entry.callbackURL, + name: entry.activityName, + startedAt: + entry.startTimeInSeconds != null + ? new Date(entry.startTimeInSeconds * 1000).toISOString() + : undefined, + duration: entry.durationInSeconds ?? null, + distance: entry.distanceInMeters ?? null, + fileType: entry.fileType, + }; +} + +export const garminWebhook: WebhookReceiver = { + parseWebhook(body: unknown): WebhookEvent[] { + if (typeof body !== "object" || body === null) return []; + const payload = body as GarminWebhookBody; + const events: WebhookEvent[] = []; + + for (const entry of payload.activityFiles ?? []) { + const event = entryToEvent(entry, EVENT_FILE); + if (event) events.push(event); + } + // Summaries cover FIT-less activities (stats-only import). A FIT + // notification for the same activityId wins via sync_imports dedupe + // ordering being first-come — both paths import once. + for (const entry of [...(payload.activities ?? []), ...(payload.activityDetails ?? [])]) { + const event = entryToEvent(entry, EVENT_SUMMARY); + if (event) events.push(event); + } + for (const dereg of payload.deregistrations ?? []) { + if (dereg.userId) { + events.push({ + eventType: EVENT_DEREGISTRATION, + providerUserId: dereg.userId, + workoutId: "", + }); + } + } + return events; + }, + + async handle(event: WebhookEvent): Promise { + // Unknown users: silent 200 — never reveal user existence. + const service = await getServiceByProviderUser("garmin", event.providerUserId); + if (!service) return; + + if (event.eventType === EVENT_DEREGISTRATION) { + // Spec: provider-side revocation keeps the row (audit) but stops + // every subsequent Garmin call for this user. + await markRevoked(service.id); + logger.info({ serviceId: service.id }, "garmin: deregistration — connection revoked"); + return; + } + + // SSRF guard: a callback URL that doesn't point at Garmin's API is + // dropped here, before it can ever be fetched (design.md, Risks). + if (event.fileUrl && !isAllowedGarminCallback(event.fileUrl)) { + logger.warn( + { host: safeHost(event.fileUrl) }, + "garmin: notification callback URL not on the Garmin allowlist — dropped", + ); + return; + } + + await enqueueOptional( + "garmin-import-activity", + { + serviceId: service.id, + userId: service.userId, + externalId: event.workoutId, + callbackUrl: event.fileUrl ?? null, + fileType: event.fileType ?? (event.eventType === EVENT_FILE ? "FIT" : null), + name: event.name ?? null, + startedAt: event.startedAt ?? null, + duration: event.duration ?? null, + distance: event.distance ?? null, + }, + { reason: "garmin webhook notification" }, + ); + }, +}; + +function safeHost(url: string): string { + try { + return new URL(url).host; + } catch { + return ""; + } +} diff --git a/apps/journal/app/lib/connected-services/providers/index.ts b/apps/journal/app/lib/connected-services/providers/index.ts index ff04735..ba54534 100644 --- a/apps/journal/app/lib/connected-services/providers/index.ts +++ b/apps/journal/app/lib/connected-services/providers/index.ts @@ -5,9 +5,11 @@ import { registerManifest } from "../registry.ts"; import { wahooManifest } from "./wahoo/manifest.ts"; import { komootManifest } from "./komoot/manifest.ts"; +import { garminManifest } from "./garmin/manifest.ts"; registerManifest(wahooManifest); registerManifest(komootManifest); +registerManifest(garminManifest); // Re-export so callers (mostly tests) can grab a manifest directly. -export { wahooManifest, komootManifest }; +export { wahooManifest, komootManifest, garminManifest }; diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/webhook.test.ts b/apps/journal/app/lib/connected-services/providers/wahoo/webhook.test.ts index 01d2acd..6d03966 100644 --- a/apps/journal/app/lib/connected-services/providers/wahoo/webhook.test.ts +++ b/apps/journal/app/lib/connected-services/providers/wahoo/webhook.test.ts @@ -1,6 +1,6 @@ // Contract tests for the Wahoo WebhookReceiver capability adapter. // -// Seam: parseWebhook(body) -> WebhookEvent | null +// Seam: parseWebhook(body) -> WebhookEvent[] (empty = nothing actionable) // handle(event) -> void (creates an activity if file present, dedups via sync_imports) import { describe, it, expect, beforeEach, vi } from "vitest"; @@ -41,22 +41,24 @@ describe("wahooWebhook.parseWebhook", () => { file: { url: "https://cdn.example/42.fit" }, }, }); - expect(event).toEqual({ - eventType: "workout_summary", - providerUserId: "7", - workoutId: "42", - fileUrl: "https://cdn.example/42.fit", - }); + expect(event).toEqual([ + { + eventType: "workout_summary", + providerUserId: "7", + workoutId: "42", + fileUrl: "https://cdn.example/42.fit", + }, + ]); }); - it("returns null for unrecognized event types", () => { - expect(wahooWebhook.parseWebhook({ event_type: "other" })).toBeNull(); + it("returns no events for unrecognized event types", () => { + expect(wahooWebhook.parseWebhook({ event_type: "other" })).toEqual([]); }); - it("returns null when user.id is missing", () => { + it("returns no events when user.id is missing", () => { expect( wahooWebhook.parseWebhook({ event_type: "workout_summary", user: {} }), - ).toBeNull(); + ).toEqual([]); }); }); diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/webhook.ts b/apps/journal/app/lib/connected-services/providers/wahoo/webhook.ts index 6473b0b..8b77f58 100644 --- a/apps/journal/app/lib/connected-services/providers/wahoo/webhook.ts +++ b/apps/journal/app/lib/connected-services/providers/wahoo/webhook.ts @@ -23,18 +23,20 @@ interface WahooWebhookBody { export const wahooWebhook: WebhookReceiver = { - parseWebhook(body: unknown): WebhookEvent | null { + parseWebhook(body: unknown): WebhookEvent[] { const payload = body as WahooWebhookBody; - if (payload.event_type !== "workout_summary" || !payload.user?.id) return null; + if (payload.event_type !== "workout_summary" || !payload.user?.id) return []; - return { - eventType: payload.event_type, - providerUserId: String(payload.user.id), - workoutId: String( - payload.workout_summary?.workout?.id ?? payload.workout_summary?.id ?? "", - ), - fileUrl: payload.workout_summary?.file?.url, - }; + return [ + { + eventType: payload.event_type, + providerUserId: String(payload.user.id), + workoutId: String( + payload.workout_summary?.workout?.id ?? payload.workout_summary?.id ?? "", + ), + fileUrl: payload.workout_summary?.file?.url, + }, + ]; }, async handle(event: WebhookEvent): Promise { diff --git a/apps/journal/app/lib/connected-services/registry.ts b/apps/journal/app/lib/connected-services/registry.ts index 457a79b..3bac1c7 100644 --- a/apps/journal/app/lib/connected-services/registry.ts +++ b/apps/journal/app/lib/connected-services/registry.ts @@ -59,6 +59,15 @@ export interface WebhookEvent { providerUserId: string; workoutId: string; fileUrl?: string; + // Optional summary stats carried by providers whose notifications + // include them (Garmin pushes summaries; a FIT-less activity is still + // importable stats-only). Providers without summaries leave these out. + name?: string; + startedAt?: string; + duration?: number | null; + distance?: number | null; + // File format behind fileUrl when the provider says (FIT | GPX | TCX). + fileType?: string; } // CapabilityContext gives capability adapters the tools they need without @@ -81,7 +90,10 @@ export interface RoutePusher { } export interface WebhookReceiver { - parseWebhook(body: unknown): WebhookEvent | null; + // One provider POST can carry many events (Garmin batches + // notifications). Single-event providers return a one-element array; + // an empty array means "nothing actionable" and the route 200s. + parseWebhook(body: unknown): WebhookEvent[]; handle(event: WebhookEvent): Promise; } @@ -99,13 +111,30 @@ export interface ProviderManifest { // Custom connect page URL. When set, the connections settings page links // here instead of the default OAuth connect endpoint. connectUrl?: string; + // Custom import page URL. When set, the connections settings page links + // here instead of the generic /sync/import/ pick-list page (Garmin + // has no list endpoint — its import page is a backfill requester). + importUrl?: string; + // When defined and returning false, the provider is hidden from the + // connections settings page (e.g. instance has no API credentials for + // it). Undefined = always shown. + configured?: () => boolean; + // OAuth2 PKCE: when true, the connect route generates a code verifier + // (carried in an httpOnly cookie across the redirect) and passes the + // S256 challenge to buildAuthUrl / the verifier to exchangeCode. + pkce?: boolean; // OAuth authorization URL builder (for the connect flow). - buildAuthUrl?: (redirectUri: string, state: string) => string; + buildAuthUrl?: ( + redirectUri: string, + state: string, + extras?: { codeChallenge?: string }, + ) => string; // OAuth code exchange (for the callback). Returns the credential blob to // store and the granted scopes. exchangeCode?: ( code: string, redirectUri: string, + extras?: { codeVerifier?: string }, ) => Promise<{ credentials: unknown; providerUserId: string | null; diff --git a/apps/journal/app/lib/sync/imports.server.ts b/apps/journal/app/lib/sync/imports.server.ts index 757a201..aa5f3cb 100644 --- a/apps/journal/app/lib/sync/imports.server.ts +++ b/apps/journal/app/lib/sync/imports.server.ts @@ -51,9 +51,23 @@ export async function importActivity( userId: string, provider: string, externalWorkoutId: string, - input: { name: string; gpx?: string }, + input: { + name: string; + gpx?: string; + // Stats-only imports (no GPS file — e.g. Garmin notifications for + // FIT-less activities) carry whatever the provider's summary had. + distance?: number | null; + duration?: number | null; + startedAt?: Date | null; + }, ): Promise<{ activityId: string }> { - const activityId = await createActivity(userId, { name: input.name, gpx: input.gpx }); + const activityId = await createActivity(userId, { + name: input.name, + gpx: input.gpx, + distance: input.distance, + duration: input.duration, + startedAt: input.startedAt, + }); await recordImport(userId, provider, externalWorkoutId, activityId); return { activityId }; } diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index 77077c8..19c02e8 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -58,6 +58,7 @@ export default [ route("api/settings/passkey/delete", "routes/api.settings.passkey.delete.ts"), route("api/settings/delete-account", "routes/api.settings.delete-account.ts"), route("sync/import/komoot", "routes/sync.import.komoot.tsx"), + route("sync/import/garmin", "routes/sync.import.garmin.tsx"), route("sync/import/:provider", "routes/sync.import.$provider.tsx"), route("api/sync/komoot/verify", "routes/api.sync.komoot.verify.ts"), route("api/sync/komoot/connect", "routes/api.sync.komoot.connect.ts"), diff --git a/apps/journal/app/routes/api.sync.callback.$provider.ts b/apps/journal/app/routes/api.sync.callback.$provider.ts index 800db0c..e9c0db0 100644 --- a/apps/journal/app/routes/api.sync.callback.$provider.ts +++ b/apps/journal/app/routes/api.sync.callback.$provider.ts @@ -5,6 +5,8 @@ import { requireSessionUser } from "~/lib/auth/session.server"; import { getManifest, link } from "~/lib/connected-services"; import { decodeOAuthState, + readPkceVerifier, + clearPkceCookieHeader, } from "~/lib/connected-services/oauth-state.server"; import { pushRouteToProvider } from "~/lib/connected-services/push-action.server"; @@ -32,8 +34,18 @@ export async function loader({ params, request }: Route.LoaderArgs) { const origin = getOrigin(); const redirectUri = `${origin}/api/sync/callback/${params.provider}`; + // PKCE providers: recover the verifier from the connect-time cookie. + const codeVerifier = manifest.pkce ? readPkceVerifier(request) : null; + if (manifest.pkce && !codeVerifier) { + return redirect(`${fallbackReturn}?error=sync_failed`); + } + try { - const exchange = await manifest.exchangeCode(code, redirectUri); + const exchange = await manifest.exchangeCode( + code, + redirectUri, + codeVerifier ? { codeVerifier } : undefined, + ); await link({ userId: user.id, provider: manifest.id, @@ -65,5 +77,9 @@ export async function loader({ params, request }: Route.LoaderArgs) { return redirect(`${target}?push=${outcome.status}`); } - return redirect(state.returnTo ?? "/settings"); + return redirect(state.returnTo ?? "/settings", { + // Spent verifier — clear it regardless of which provider this was + // (harmless no-op for non-PKCE providers without the cookie). + headers: { "Set-Cookie": clearPkceCookieHeader() }, + }); } diff --git a/apps/journal/app/routes/api.sync.connect.$provider.ts b/apps/journal/app/routes/api.sync.connect.$provider.ts index cfbc2ca..6e27d09 100644 --- a/apps/journal/app/routes/api.sync.connect.$provider.ts +++ b/apps/journal/app/routes/api.sync.connect.$provider.ts @@ -3,7 +3,11 @@ import { getOrigin } from "~/lib/config.server"; import type { Route } from "./+types/api.sync.connect.$provider"; import { requireSessionUser } from "~/lib/auth/session.server"; import { getManifest } from "~/lib/connected-services"; -import { encodeOAuthState } from "~/lib/connected-services/oauth-state.server"; +import { + encodeOAuthState, + generatePkcePair, + pkceCookieHeader, +} from "~/lib/connected-services/oauth-state.server"; export async function loader({ params, request }: Route.LoaderArgs) { await requireSessionUser(request); @@ -17,5 +21,15 @@ export async function loader({ params, request }: Route.LoaderArgs) { const redirectUri = `${origin}/api/sync/callback/${params.provider}`; const state = encodeOAuthState({ returnTo: "/settings/connections" }); + // PKCE providers (Garmin): the verifier crosses the redirect in an + // httpOnly cookie; only the S256 challenge goes to the provider. + if (manifest.pkce) { + const { verifier, challenge } = generatePkcePair(); + return redirect( + manifest.buildAuthUrl(redirectUri, state, { codeChallenge: challenge }), + { headers: { "Set-Cookie": pkceCookieHeader(verifier) } }, + ); + } + return redirect(manifest.buildAuthUrl(redirectUri, state)); } diff --git a/apps/journal/app/routes/api.sync.webhook.$provider.test.ts b/apps/journal/app/routes/api.sync.webhook.$provider.test.ts index 7c57b2f..0ba4804 100644 --- a/apps/journal/app/routes/api.sync.webhook.$provider.test.ts +++ b/apps/journal/app/routes/api.sync.webhook.$provider.test.ts @@ -30,7 +30,7 @@ describe("POST /api/sync/webhook/:provider", () => { beforeEach(() => { parseWebhook.mockReset(); handle.mockReset(); - parseWebhook.mockReturnValue(null); + parseWebhook.mockReturnValue([]); vi.unstubAllEnvs(); }); @@ -83,7 +83,7 @@ describe("POST /api/sync/webhook/:provider", () => { }); it("invokes parseWebhook with a valid object body", async () => { - parseWebhook.mockReturnValue({ eventType: "x", providerUserId: "1", workoutId: "9" }); + parseWebhook.mockReturnValue([{ eventType: "x", providerUserId: "1", workoutId: "9" }]); handle.mockResolvedValue(undefined); const res = await call({ event_type: "x" }); expect(statusOf(res)).toBe(200); diff --git a/apps/journal/app/routes/api.sync.webhook.$provider.ts b/apps/journal/app/routes/api.sync.webhook.$provider.ts index 1cff459..a78c647 100644 --- a/apps/journal/app/routes/api.sync.webhook.$provider.ts +++ b/apps/journal/app/routes/api.sync.webhook.$provider.ts @@ -37,13 +37,13 @@ export async function action({ params, request }: Route.ActionArgs) { return data({ ok: true }); } - const event = manifest.webhookReceiver.parseWebhook(body); - if (!event) return data({ ok: true }); - - try { - await manifest.webhookReceiver.handle(event); - } catch (e) { - console.error(`Webhook import failed for ${manifest.id}/${event.workoutId}:`, e); + const events = manifest.webhookReceiver.parseWebhook(body); + for (const event of events) { + try { + await manifest.webhookReceiver.handle(event); + } catch (e) { + console.error(`Webhook import failed for ${manifest.id}/${event.workoutId}:`, e); + } } return data({ ok: true }); diff --git a/apps/journal/app/routes/legal.privacy.tsx b/apps/journal/app/routes/legal.privacy.tsx index 303f26c..21518be 100644 --- a/apps/journal/app/routes/legal.privacy.tsx +++ b/apps/journal/app/routes/legal.privacy.tsx @@ -309,6 +309,21 @@ export default function PrivacyPage() { importieren. Die Verbindung kann jederzeit in den Einstellungen getrennt werden. +
  • + Garmin (Garmin Ltd.) – Optionaler Import von + Aktivitäten aus Garmin Connect. Beim Verbinden werden + OAuth-Tokens ausgetauscht und die Garmin-Nutzer-ID gespeichert. + Garmin übermittelt anschließend neue Aktivitäten (inkl. + GPS-Aufzeichnung als FIT-Datei) automatisch an diese Instanz; + ältere Aktivitäten nur auf Ihre ausdrückliche Anforderung + (Zeitraum-Import). Es werden keine Gesundheitsdaten (Schlaf, + Herzfrequenz-Tageswerte o. ä.) abgerufen — nur Aktivitäten. + Widerrufen Sie den Zugriff bei Garmin oder trennen Sie die + Verbindung in den Einstellungen, endet die Übermittlung sofort; + bereits importierte Aktivitäten bleiben in Ihrem Journal und + können dort gelöscht werden. Es gilt die Datenschutzerklärung + von Garmin. +
  • Hosting – Die Dienste werden in Rechenzentren innerhalb der EU betrieben. Ein Auftragsverarbeitungsvertrag mit @@ -327,7 +342,13 @@ export default function PrivacyPage() { Wahoo” on a route — sent so the route appears on your ELEMNT/BOLT/ROAM); Komoot (only when you opt in: public mode stores your Komoot user ID only; authenticated mode stores your - encrypted Komoot password); hosting provider in the EU under a DPA. + encrypted Komoot password); Garmin (only when you opt in: OAuth + tokens and your Garmin user ID; Garmin then pushes new activities + including GPS files to this instance automatically, and older + activities only when you request a date range — activities only, + never health metrics; revoking at Garmin or disconnecting here + stops the flow immediately, imported activities stay yours); + hosting provider in the EU under a DPA.

    diff --git a/apps/journal/app/routes/settings.connections.server.ts b/apps/journal/app/routes/settings.connections.server.ts index 41b14f9..c89c3a0 100644 --- a/apps/journal/app/routes/settings.connections.server.ts +++ b/apps/journal/app/routes/settings.connections.server.ts @@ -21,16 +21,21 @@ export async function loadConnectionsSettings(request: Request) { .from(connectedServices) .where(eq(connectedServices.userId, user.id)); - const providers = getAllManifests().map((m) => { - const conn = connections.find((c) => c.provider === m.id); - return { - id: m.id, - name: m.displayName, - connected: !!conn, - providerUserId: conn?.providerUserId, - connectUrl: m.connectUrl ?? null, - }; - }); + const providers = getAllManifests() + // Providers can hide themselves when the instance lacks their API + // credentials (Garmin: program keys are per-operator). + .filter((m) => m.configured?.() ?? true) + .map((m) => { + const conn = connections.find((c) => c.provider === m.id); + return { + id: m.id, + name: m.displayName, + connected: !!conn, + providerUserId: conn?.providerUserId, + connectUrl: m.connectUrl ?? null, + importUrl: m.importUrl ?? null, + }; + }); return { providers }; } diff --git a/apps/journal/app/routes/settings.connections.tsx b/apps/journal/app/routes/settings.connections.tsx index 68ada37..9b255e3 100644 --- a/apps/journal/app/routes/settings.connections.tsx +++ b/apps/journal/app/routes/settings.connections.tsx @@ -52,7 +52,7 @@ export default function ConnectionsSettings({ loaderData }: Route.ComponentProps {p.connected ? (
    {t("sync.import")} diff --git a/apps/journal/app/routes/sync.import.garmin.server.ts b/apps/journal/app/routes/sync.import.garmin.server.ts new file mode 100644 index 0000000..275a40f --- /dev/null +++ b/apps/journal/app/routes/sync.import.garmin.server.ts @@ -0,0 +1,91 @@ +// Server logic for the Garmin import page (spec: garmin-import, +// "Historical import via backfill"). Garmin has no list endpoint, so +// this page is a date-range backfill requester with honest async +// progress — not a pick list. + +import { and, desc, eq, gte, count } from "drizzle-orm"; +import { requireSessionUser } from "~/lib/auth/session.server"; +import { getDb } from "~/lib/db"; +import { importBatches, syncImports } from "@trails-cool/db/schema/journal"; +import { getService } from "~/lib/connected-services/manager"; +import { requestBackfill } from "~/lib/connected-services/providers/garmin/backfill"; + +export interface GarminBackfillRow { + id: string; + rangeStart: string | null; + rangeEnd: string | null; + requestedAt: string; + // Garmin activities imported since this request was made. Activities + // arrive asynchronously and notifications don't carry a batch id, so + // "imports since the request" is the honest measurable proxy for + // range progress (overlapping ranges share arrivals). + importedSince: number; +} + +export async function loadGarminImportPage(request: Request) { + const user = await requireSessionUser(request); + const service = await getService(user.id, "garmin"); + + if (!service) { + return { connected: false as const, status: null, batches: [] as GarminBackfillRow[] }; + } + + const db = getDb(); + const rows = await db + .select() + .from(importBatches) + .where(and(eq(importBatches.userId, user.id), eq(importBatches.provider, "garmin"))) + .orderBy(desc(importBatches.startedAt)) + .limit(20); + + const batches: GarminBackfillRow[] = []; + for (const row of rows) { + const [imported] = await db + .select({ n: count() }) + .from(syncImports) + .where( + and( + eq(syncImports.userId, user.id), + eq(syncImports.provider, "garmin"), + gte(syncImports.importedAt, row.startedAt), + ), + ); + batches.push({ + id: row.id, + rangeStart: row.rangeStart?.toISOString() ?? null, + rangeEnd: row.rangeEnd?.toISOString() ?? null, + requestedAt: row.startedAt.toISOString(), + importedSince: imported?.n ?? 0, + }); + } + + return { connected: true as const, status: service.status, batches }; +} + +export type GarminBackfillActionResult = + | { ok: true; chunks: number } + | { ok: false; error: "not_connected" | "needs_relink" | "invalid_range" | "request_failed" }; + +export async function handleGarminBackfillAction( + request: Request, +): Promise { + const user = await requireSessionUser(request); + const service = await getService(user.id, "garmin"); + if (!service) return { ok: false, error: "not_connected" }; + if (service.status !== "active") return { ok: false, error: "needs_relink" }; + + const form = await request.formData(); + const from = new Date(String(form.get("from") ?? "")); + const to = new Date(String(form.get("to") ?? "")); + if (isNaN(from.getTime()) || isNaN(to.getTime()) || from >= to || to > new Date()) { + return { ok: false, error: "invalid_range" }; + } + + try { + const { chunks } = await requestBackfill({ id: service.id, userId: user.id }, from, to); + return { ok: true, chunks }; + } catch (e) { + console.error("garmin backfill request failed:", e); + return { ok: false, error: "request_failed" }; + } +} diff --git a/apps/journal/app/routes/sync.import.garmin.tsx b/apps/journal/app/routes/sync.import.garmin.tsx new file mode 100644 index 0000000..7642647 --- /dev/null +++ b/apps/journal/app/routes/sync.import.garmin.tsx @@ -0,0 +1,112 @@ +import { data, Form, Link, useNavigation } from "react-router"; +import { useTranslation } from "react-i18next"; +import type { Route } from "./+types/sync.import.garmin"; +import { ClientDate } from "~/components/ClientDate"; + +export function meta() { + return [{ title: "Garmin import — trails.cool" }]; +} + +export async function loader({ request }: Route.LoaderArgs) { + const { loadGarminImportPage } = await import("./sync.import.garmin.server"); + return data(await loadGarminImportPage(request)); +} + +export async function action({ request }: Route.ActionArgs) { + const { handleGarminBackfillAction } = await import("./sync.import.garmin.server"); + return data(await handleGarminBackfillAction(request)); +} + +export default function GarminImport({ loaderData, actionData }: Route.ComponentProps) { + const { t } = useTranslation(["journal"]); + const navigation = useNavigation(); + const busy = navigation.state !== "idle"; + + if (!loaderData.connected) { + return ( +
    +

    {t("sync.garmin.title")}

    +

    {t("sync.garmin.notConnected")}

    + + {t("sync.garmin.goConnect")} + +
    + ); + } + + return ( +
    +

    {t("sync.garmin.title")}

    +

    {t("sync.garmin.subtitle")}

    + + {loaderData.status !== "active" && ( +
    + {t("sync.garmin.needsRelink")} +
    + )} + +
    + + + +
    + + {actionData && "ok" in actionData && actionData.ok && ( +

    {t("sync.garmin.requested")}

    + )} + {actionData && "ok" in actionData && !actionData.ok && ( +

    + {t(`sync.garmin.errors.${actionData.error}`)} +

    + )} + +

    {t("sync.garmin.asyncNote")}

    + + {loaderData.batches.length > 0 && ( +
      + {loaderData.batches.map((b) => ( +
    • + + {b.rangeStart && b.rangeEnd ? ( + <> + + + ) : ( + + )} + + + {t("sync.garmin.importedSince", { count: b.importedSince })} + +
    • + ))} +
    + )} +
    + ); +} diff --git a/apps/journal/server.ts b/apps/journal/server.ts index 93342ac..682d93f 100644 --- a/apps/journal/server.ts +++ b/apps/journal/server.ts @@ -177,7 +177,8 @@ server.listen(port, async () => { const { importBatchesSweepJob } = await import("./app/jobs/import-batches-sweep.ts"); const { sendWelcomeEmailJob } = await import("./app/jobs/send-welcome-email.ts"); const { consumedJtiSweepJob } = await import("./app/jobs/consumed-jti-sweep.ts"); - jobs.push(notificationsFanoutJob, notificationsPurgeJob, komootBulkImportJob, importBatchesSweepJob, sendWelcomeEmailJob, consumedJtiSweepJob); + const { garminImportActivityJob } = await import("./app/jobs/garmin-import-activity.ts"); + jobs.push(notificationsFanoutJob, notificationsPurgeJob, komootBulkImportJob, importBatchesSweepJob, sendWelcomeEmailJob, consumedJtiSweepJob, garminImportActivityJob); // Federation jobs — registered only when federation is on. if (process.env.FEDERATION_ENABLED === "true") { const { backfillUserKeypairsJob } = await import("./app/jobs/backfill-user-keypairs.ts"); diff --git a/infrastructure/docker-compose.staging.yml b/infrastructure/docker-compose.staging.yml index 1a09695..4e53d26 100644 --- a/infrastructure/docker-compose.staging.yml +++ b/infrastructure/docker-compose.staging.yml @@ -60,6 +60,8 @@ services: WAHOO_CLIENT_ID: "" WAHOO_CLIENT_SECRET: "" WAHOO_WEBHOOK_TOKEN: "" + GARMIN_CLIENT_ID: ${GARMIN_CLIENT_ID:-} + GARMIN_CLIENT_SECRET: ${GARMIN_CLIENT_SECRET:-} DEMO_BOT_ENABLED: "" # Federation (social-federation rollout). Enabled by cd-staging.yml # for persistent staging (12.2) and for PR previews (12.4 — every diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml index dd2403b..1a7c623 100644 --- a/infrastructure/docker-compose.yml +++ b/infrastructure/docker-compose.yml @@ -53,6 +53,10 @@ services: WAHOO_CLIENT_ID: ${WAHOO_CLIENT_ID:-} WAHOO_CLIENT_SECRET: ${WAHOO_CLIENT_SECRET:-} WAHOO_WEBHOOK_TOKEN: ${WAHOO_WEBHOOK_TOKEN:-} + # Garmin Connect Developer Program keys (spec: garmin-import). + # Empty = the Garmin provider is hidden on /settings/connections. + GARMIN_CLIENT_ID: ${GARMIN_CLIENT_ID:-} + GARMIN_CLIENT_SECRET: ${GARMIN_CLIENT_SECRET:-} # Federation (ActivityPub, social-federation rollout 12.5). Empty # = off: every federation surface 404s — the safe self-host # default. The flagship turns it on via cd-apps.yml (same pattern diff --git a/openspec/changes/garmin-import/design.md b/openspec/changes/garmin-import/design.md index 79bc84a..a6eb181 100644 --- a/openspec/changes/garmin-import/design.md +++ b/openspec/changes/garmin-import/design.md @@ -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. diff --git a/openspec/changes/garmin-import/tasks.md b/openspec/changes/garmin-import/tasks.md index ba042f4..77cabf3 100644 --- a/openspec/changes/garmin-import/tasks.md +++ b/openspec/changes/garmin-import/tasks.md @@ -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) diff --git a/packages/db/src/schema/journal.ts b/packages/db/src/schema/journal.ts index 7bf9bce..7167368 100644 --- a/packages/db/src/schema/journal.ts +++ b/packages/db/src/schema/journal.ts @@ -340,6 +340,11 @@ export const importBatches = journalSchema.table("import_batches", { importedCount: integer("imported_count").notNull().default(0), duplicateCount: integer("duplicate_count").notNull().default(0), errorMessage: text("error_message"), + // Ranged backfill requests (Garmin): the activity time window this + // batch asked the provider to re-deliver. NULL for pick-list style + // imports (komoot bulk) that aren't range-shaped. + rangeStart: timestamp("range_start", { withTimezone: true }), + rangeEnd: timestamp("range_end", { withTimezone: true }), startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(), completedAt: timestamp("completed_at", { withTimezone: true }), }); diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index a1c8d84..8c9255c 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -478,6 +478,29 @@ export default { }, }, sync: { + garmin: { + title: "Von Garmin importieren", + subtitle: + "Garmin liefert Aktivitäten automatisch an trails.cool, sobald sie entstehen. Für ältere Aktivitäten fordere unten einen Zeitraum an — Garmin liefert sie asynchron nach.", + notConnected: "Dein Garmin-Konto ist noch nicht verbunden.", + goConnect: "Garmin in den Einstellungen verbinden", + needsRelink: "Deine Garmin-Verbindung muss neu verknüpft werden, bevor du Importe anfordern kannst.", + from: "Von", + to: "Bis", + request: "Import anfordern", + requesting: "Wird angefordert…", + requested: "Angefordert! Aktivitäten erscheinen, sobald Garmin sie liefert.", + asyncNote: + "Garmin verarbeitet Verlaufs-Anfragen auf ihrer Seite — große Zeiträume können dauern. Überlappende Zeiträume erneut anzufordern ist unproblematisch; nichts wird doppelt importiert.", + importedSince_one: "{{count}} Aktivität seit dieser Anfrage importiert", + importedSince_other: "{{count}} Aktivitäten seit dieser Anfrage importiert", + errors: { + not_connected: "Dein Garmin-Konto ist nicht verbunden.", + needs_relink: "Deine Garmin-Verbindung muss zuerst neu verknüpft werden.", + invalid_range: "Wähle einen gültigen Zeitraum in der Vergangenheit (Start vor Ende).", + request_failed: "Garmin hat die Anfrage abgelehnt — versuche es in ein paar Minuten erneut.", + }, + }, import: "Importieren", importFrom: "Import von {{provider}}", imported: "Importiert", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index f6ee07f..72f4d6e 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -478,6 +478,29 @@ export default { }, }, sync: { + garmin: { + title: "Import from Garmin", + subtitle: + "Garmin delivers activities to trails.cool as they happen. To pull in your history, request a date range below — Garmin sends those activities over asynchronously.", + notConnected: "Your Garmin account isn't connected yet.", + goConnect: "Connect Garmin in settings", + needsRelink: "Your Garmin connection needs to be re-linked before requesting imports.", + from: "From", + to: "To", + request: "Request import", + requesting: "Requesting…", + requested: "Requested! Activities will appear as Garmin delivers them.", + asyncNote: + "Garmin processes history requests on their side — large ranges can take a while to arrive. Re-requesting an overlapping range is safe; nothing gets imported twice.", + importedSince_one: "{{count}} activity imported since this request", + importedSince_other: "{{count}} activities imported since this request", + errors: { + not_connected: "Your Garmin account isn't connected.", + needs_relink: "Your Garmin connection needs to be re-linked first.", + invalid_range: "Pick a valid date range in the past (start before end).", + request_failed: "Garmin rejected the request — try again in a few minutes.", + }, + }, import: "Import", importFrom: "Import from {{provider}}", imported: "Imported",