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>
59 lines
2.3 KiB
TypeScript
59 lines
2.3 KiB
TypeScript
// OAuth state encoding for the connect/callback flow. The state param is
|
||
// reflected back to the callback unchanged, so we use it to carry
|
||
// post-callback intent (where to return to, whether a push should resume).
|
||
|
||
export interface PushOAuthState {
|
||
pushAfter?: { routeId: string };
|
||
returnTo?: string;
|
||
}
|
||
|
||
export function encodeOAuthState(state: PushOAuthState): string {
|
||
return Buffer.from(JSON.stringify(state), "utf8").toString("base64url");
|
||
}
|
||
|
||
export function decodeOAuthState(raw: string | null | undefined): PushOAuthState {
|
||
if (!raw) return {};
|
||
try {
|
||
const json = Buffer.from(raw, "base64url").toString("utf8");
|
||
const parsed = JSON.parse(json) as PushOAuthState;
|
||
return typeof parsed === "object" && parsed != null ? parsed : {};
|
||
} catch {
|
||
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;
|
||
}
|