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>
154 lines
5.4 KiB
TypeScript
154 lines
5.4 KiB
TypeScript
// 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>";
|
|
}
|
|
}
|