trails/apps/journal/app/lib/connected-services/providers/garmin/import.server.ts
Ullrich Schäfer 0360757ae8 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>
2026-06-07 17:47:22 +02:00

82 lines
3 KiB
TypeScript

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