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.