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>
82 lines
3 KiB
TypeScript
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",
|
|
);
|
|
}
|