feat(journal): Garmin activity import — provider, webhook pipeline, backfill (§1–5)
Garmin Connect as the third connected-services provider (spec: garmin-import). The interesting parts: - Push-first ingestion: Garmin has no list endpoint. The webhook normalizes ping (callbackURL) and push (inline) notification batches into events; the slow work (authorized FIT download, FIT→GPX via the shared converter, activity creation) runs in a garmin-import-activity pg-boss job so the webhook answers fast. Callback URLs are validated against Garmin's API host before any fetch (SSRF guard). - History via backfill requests: /sync/import/garmin is a date-range requester with honest async progress (no pick list — the concept doesn't exist in a push model). Ranges chunk to Garmin's 90-day cap; overlaps are free via sync_imports dedupe. Requests persist in import_batches via two new nullable columns (range_start/range_end). - OAuth2 + PKCE on the existing oauth credential kind. Design correction from apply: the verifier rides a short-lived httpOnly cookie scoped to the callback path — the state param is visible in redirect URLs and must never carry it. Manifests opt in via pkce:true. - Deregistration notifications flip the connection to 'revoked' (row kept for audit, imports retained, re-connect prompt shown). - Framework evolutions, all additive: parseWebhook returns WebhookEvent[] (Garmin batches; Wahoo adapted), manifest gains configured()/importUrl/pkce, importActivity accepts summary stats for FIT-less imports, manager gains markRevoked. - Env-gated: no GARMIN_CLIENT_ID → provider hidden on /settings/connections. Privacy manifest entry (DE+EN). i18n en+de. Rollout (§6) stays gated on the Garmin Developer Program application (submitted 2026-06-07). Fixtures are doc-shaped; the staging soak swaps in recorded payloads if shapes differ. Gate: typecheck ✓ lint ✓ unit+integration ✓ e2e 70/72 + both known flakes green isolated ✓ openspec validate ✓ Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
192481fedb
commit
0360757ae8
36 changed files with 1368 additions and 71 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
33
apps/journal/app/jobs/garmin-import-activity.ts
Normal file
33
apps/journal/app/jobs/garmin-import-activity.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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[],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
61
apps/journal/app/lib/connected-services/oauth-state.test.ts
Normal file
61
apps/journal/app/lib/connected-services/oauth-state.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>";
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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. ä.) 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” 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
91
apps/journal/app/routes/sync.import.garmin.server.ts
Normal file
91
apps/journal/app/routes/sync.import.garmin.server.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
112
apps/journal/app/routes/sync.import.garmin.tsx
Normal file
112
apps/journal/app/routes/sync.import.garmin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue