Merge pull request #497 from trails-cool/garmin/provider-import

feat(journal): Garmin activity import — provider, webhook pipeline, backfill (garmin-import §1–5)
This commit is contained in:
Ullrich Schäfer 2026-06-07 17:51:13 +02:00 committed by GitHub
commit 001c53294a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1368 additions and 71 deletions

View file

@ -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 `<origin>/api/sync/callback/garmin`, the
# notification endpoint `<origin>/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`.

View file

@ -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;
}
}
},
};

View file

@ -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<void> {
const db = getDb();
await db
.update(connectedServices)
.set({ status: "revoked" })
.where(eq(connectedServices.id, serviceId));
}
export async function updateGrantedScopes(
serviceId: string,
grantedScopes: string[],

View file

@ -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 43128).
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;
}

View file

@ -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({});
});
});

View file

@ -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,
});
});
});

View file

@ -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<void>;
}
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 };
}

View file

@ -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";

View file

@ -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<void> {
// 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",
);
}

View file

@ -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<string, string | undefined> = {};
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");
});
});

View file

@ -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,
};

View file

@ -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);
});
});

View file

@ -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<void> {
// 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 "<unparseable>";
}
}

View file

@ -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 };

View file

@ -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([]);
});
});

View file

@ -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<void> {

View file

@ -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<void>;
}
@ -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/<id> 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;

View file

@ -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 };
}

View file

@ -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"),

View file

@ -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() },
});
}

View file

@ -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));
}

View file

@ -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);

View file

@ -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 });

View file

@ -309,6 +309,21 @@ export default function PrivacyPage() {
importieren. Die Verbindung kann jederzeit in den Einstellungen
getrennt werden.
</li>
<li>
<strong>Garmin</strong> (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.&nbsp;ä.) 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.
</li>
<li>
<strong>Hosting</strong> Die Dienste werden in Rechenzentren
innerhalb der EU betrieben. Ein Auftragsverarbeitungsvertrag mit
@ -327,7 +342,13 @@ export default function PrivacyPage() {
Wahoo&rdquo; 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.
</p>
</section>

View file

@ -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 };
}

View file

@ -52,7 +52,7 @@ export default function ConnectionsSettings({ loaderData }: Route.ComponentProps
{p.connected ? (
<div className="flex items-center gap-3">
<a
href={`/sync/import/${p.id}`}
href={p.importUrl ?? `/sync/import/${p.id}`}
className="text-sm text-blue-600 hover:underline"
>
{t("sync.import")}

View file

@ -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<GarminBackfillActionResult> {
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" };
}
}

View file

@ -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 (
<div className="mx-auto max-w-2xl px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900">{t("sync.garmin.title")}</h1>
<p className="mt-4 text-sm text-gray-600">{t("sync.garmin.notConnected")}</p>
<Link to="/settings/connections" className="mt-2 inline-block text-sm text-blue-600 hover:underline">
{t("sync.garmin.goConnect")}
</Link>
</div>
);
}
return (
<div className="mx-auto max-w-2xl px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900">{t("sync.garmin.title")}</h1>
<p className="mt-2 text-sm text-gray-600">{t("sync.garmin.subtitle")}</p>
{loaderData.status !== "active" && (
<div role="alert" className="mt-4 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
{t("sync.garmin.needsRelink")}
</div>
)}
<Form method="post" className="mt-6 flex flex-wrap items-end gap-3">
<label className="block text-sm">
<span className="text-gray-700">{t("sync.garmin.from")}</span>
<input
type="date"
name="from"
required
className="mt-1 block rounded-md border border-gray-300 px-3 py-2 text-sm"
/>
</label>
<label className="block text-sm">
<span className="text-gray-700">{t("sync.garmin.to")}</span>
<input
type="date"
name="to"
required
className="mt-1 block rounded-md border border-gray-300 px-3 py-2 text-sm"
/>
</label>
<button
type="submit"
disabled={busy || loaderData.status !== "active"}
className="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
{busy ? t("sync.garmin.requesting") : t("sync.garmin.request")}
</button>
</Form>
{actionData && "ok" in actionData && actionData.ok && (
<p className="mt-3 text-sm text-green-700">{t("sync.garmin.requested")}</p>
)}
{actionData && "ok" in actionData && !actionData.ok && (
<p role="alert" className="mt-3 text-sm text-red-700">
{t(`sync.garmin.errors.${actionData.error}`)}
</p>
)}
<p className="mt-6 text-xs text-gray-500">{t("sync.garmin.asyncNote")}</p>
{loaderData.batches.length > 0 && (
<ul className="mt-4 space-y-2">
{loaderData.batches.map((b) => (
<li
key={b.id}
className="flex items-center justify-between rounded-md border border-gray-200 px-4 py-3 text-sm"
>
<span className="text-gray-900">
{b.rangeStart && b.rangeEnd ? (
<>
<ClientDate iso={b.rangeStart} /> <ClientDate iso={b.rangeEnd} />
</>
) : (
<ClientDate iso={b.requestedAt} />
)}
</span>
<span className="text-gray-500">
{t("sync.garmin.importedSince", { count: b.importedSince })}
</span>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -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");

View file

@ -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

View file

@ -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

View file

@ -28,7 +28,7 @@ Garmin specifics that shape everything below (Garmin Connect Developer Program,
### Decision: OAuth2 + PKCE rides the existing `oauth` credential kind
Garmin's token blob is shape-compatible with `OAuthCredentials` (`access_token`, `refresh_token`, `expires_at`), so `credential_kind = 'oauth'` and the existing OAuth `CredentialAdapter` handle storage and refresh. PKCE only affects the **authorize/exchange** steps, which are already per-provider in the manifest's OAuth config — the manifest gains a code-verifier generation + `code_challenge` parameter, with the verifier carried through the OAuth `state` storage (`oauth-state.server.ts`) the same way the existing flow carries its state nonce.
Garmin's token blob is shape-compatible with `OAuthCredentials` (`access_token`, `refresh_token`, `expires_at`), so `credential_kind = 'oauth'` and the existing OAuth `CredentialAdapter` handle storage and refresh. PKCE only affects the **authorize/exchange** steps, which are already per-provider in the manifest's OAuth config — the manifest gains a code-verifier generation + `code_challenge` parameter. *Corrected during apply:* the existing `state` param is intent-encoding reflected through redirects (visible in URLs), not server-side storage — and the verifier must never appear in a URL. It rides a short-lived httpOnly cookie scoped to the callback path instead (`oauth-state.server.ts` PKCE helpers; manifests opt in via `pkce: true`).
**Alternative considered:** a new `oauth-pkce` credential kind. Rejected — the *stored* credential is identical; PKCE is a handshake detail, not a credential shape. A new kind would fork the adapter for zero storage benefit.

View file

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

View file

@ -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 }),
});

View file

@ -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",

View file

@ -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",