/** * Garmin Personal API transport: the key-for-token exchange, the bearer GET, * and the date-range rules of each resource. * * The Personal API is pull-only and read-only. A person creates a key in * Garmin Connect (Settings, Personal API) and pastes it here. Every data call * needs a short-lived JWT the key buys at * `POST /personalapitoken-service/personal-api/personal-api-key/token` * (`Authorization: ApiKey `, the body is the bare JWT). A token lives 24 * hours, or less once the key itself is about to expire, and there is no * refresh token: a new one is simply bought again with the key. * * Tokens are cached in this process only, per account and per key (a changed * key never reuses the old key's token). A data call refused with 401 buys one * fresh token and retries once; a second refusal, or a refusal at the * exchange itself, means the key was revoked or has expired, and surfaces as * `reauth_required` so the card asks for a new one. * * Egress goes through `safeFetch` to two fixed hosts (global and China), so * there is no user-supplied host to pin. Answers are read with a byte cap. */ import { getEvent } from "@/lib/logging/context"; import { shiftDateKey } from "@/lib/tz/format"; import { readBodyCapped } from "@/lib/http/read-capped"; import { safeFetch } from "@/lib/safe-fetch"; import { retryAfterMsOf } from "@/lib/integrations/oauth-kit/retry-after"; import { classifyHttpStatus, IntegrationApiError, type IntegrationClassification, } from "@/lib/integrations/http-status-classifier"; export const GARMIN_HOSTS = { global: "https://personal.connectapi.garmin.com", china: "https://personal.connectapi.garmin.cn", } as const; export type GarminRegion = keyof typeof GARMIN_HOSTS; export function isGarminRegion(value: unknown): value is GarminRegion { return value === "global" || value === "china"; } export const GARMIN_TOKEN_PATH = "/personalapitoken-service/personal-api/personal-api-key/token"; export const GARMIN_PATHS = { dailySummaries: "/usersummary-service/v2/dailysummaries", sleepSummaries: "/sleep-service/sleep/dailySleepSummariesByDate", hrvDaily: "/hrv-service/hrv/daily", weightRange: "/weight-service/weight/range", activities: "/activitylist-service/activities/search/activities", cycleSummary: "/periodichealth-service/menstrualcycle/summary", } as const; /** * The widest request each resource accepts, in calendar days counting both * ends. Daily summaries, sleep and the body-battery report carry documented * maxima (365, 31, 31); HRV and weigh-ins document none, so they take the * sleep figure rather than find the limit by being refused. Activities are * paged and filtered by start date, so their window is only a slice size. */ export const GARMIN_RANGE_DAYS = { dailySummaries: 365, sleepSummaries: 31, hrvDaily: 31, weightRange: 31, activities: 90, } as const; /** Page size of the activity search (Garmin's own default is 99). */ export const GARMIN_ACTIVITY_PAGE = 99; /** Ceiling on one answer. A year of daily summaries is about a megabyte. */ export const GARMIN_MAX_BODY_BYTES = 8 * 1024 * 1024; /** A regular token lives 24 h; anything markedly shorter is capped by the key. */ const KEY_CAPPED_BELOW_MS = 23 * 60 * 60 * 1000; /** Replace a token an hour before it expires, never before half its life. */ const REFRESH_WINDOW_MS = 60 * 60 * 1000; /** Used only when a token carries no readable `exp`. */ const FALLBACK_LIFETIME_MS = 23 * 60 * 60 * 1000; const JWT_SHAPE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$/; export class GarminApiError extends IntegrationApiError { /** How long Garmin asked to be left alone (a 429's `Retry-After`). */ readonly retryAfterMs: number | undefined; /** The key itself was refused: revoked, deleted or expired. */ readonly keyRefused: boolean; constructor(opts: { verb: string; classification: IntegrationClassification; httpStatus: number | undefined; reason: string; retryAfterMs?: number; keyRefused?: boolean; }) { const { retryAfterMs, keyRefused, ...rest } = opts; super({ vendor: "Garmin", ...rest }); this.name = "GarminApiError"; this.retryAfterMs = retryAfterMs; this.keyRefused = keyRefused ?? false; } } /** True for a refusal that a new key, not a retry, resolves. */ export function isGarminKeyRefusal(err: unknown): boolean { return err instanceof GarminApiError && err.keyRefused; } /** True for Garmin's rate limit. */ export function isGarminRateLimit(err: unknown): err is GarminApiError { return err instanceof GarminApiError && err.httpStatus === 429; } /** The token text: the bare JWT, or one wrapped in JSON or quotes. */ export function extractGarminToken(body: string): string { const trimmed = body.trim(); if (!trimmed.startsWith("{")) return trimmed.replace(/^"|"$/g, ""); try { const json = JSON.parse(trimmed) as Record; const candidate = json.access_token ?? json.accessToken ?? json.token; return typeof candidate === "string" ? candidate.trim() : ""; } catch { return ""; } } /** A JWT's time claims (`exp`, `iat`) in epoch ms, read without verifying. */ export function garminTokenClaims(token: string): { exp: number | undefined; iat: number | undefined; } { const payload = token.split(".")[1]; if (!payload) return { exp: undefined, iat: undefined }; try { const claims = JSON.parse( Buffer.from(payload, "base64url").toString("utf8"), ) as { exp?: unknown; iat?: unknown }; const ms = (v: unknown) => typeof v === "number" && Number.isFinite(v) ? v * 1000 : undefined; return { exp: ms(claims.exp), iat: ms(claims.iat) }; } catch { return { exp: undefined, iat: undefined }; } } /** A JWT's `exp` claim in epoch ms, read without verifying the signature. */ export function garminTokenExpiry(token: string): number | undefined { return garminTokenClaims(token).exp; } export interface GarminToken { value: string; /** Epoch ms. */ expiresAt: number; /** Epoch ms after which a new token is bought. */ refreshAt: number; /** * The key's own expiry, when the token gave it away: a token Garmin caps * at the key's end lives markedly less than the regular day. */ keyExpiresAt: number | null; } function refreshTime(now: number, expiresAt: number): number { const lifetime = expiresAt - now; if (lifetime <= 0) return now; return now + Math.max(lifetime - REFRESH_WINDOW_MS, lifetime / 2); } export interface GarminTransport { fetch?: typeof safeFetch; now?: () => number; } /** * Buy a token with the key. Throws a `GarminApiError` whose `keyRefused` is * set when Garmin turned the key down (401 for a key it never issued, 404 for * a deleted one, 403 for good measure). */ export async function exchangeGarminKey( apiKey: string, region: GarminRegion, transport: GarminTransport = {}, ): Promise { const doFetch = transport.fetch ?? safeFetch; const now = transport.now ?? Date.now; const start = performance.now(); const res = await doFetch(`${GARMIN_HOSTS[region]}${GARMIN_TOKEN_PATH}`, { method: "POST", headers: { Accept: "*/*", Authorization: `ApiKey ${apiKey}` }, }); const status = res.status; getEvent()?.addExternalCall({ service: "garmin", method: "token", duration_ms: Math.round(performance.now() - start), status, error: status === 200 ? undefined : `http_${status}`, }); if (status === 401 || status === 403 || status === 404) { await res.body?.cancel().catch(() => {}); throw new GarminApiError({ verb: "token", classification: "reauth_required", httpStatus: status, reason: "key_refused", keyRefused: true, }); } if (status !== 200) { await res.body?.cancel().catch(() => {}); const verdict = classifyHttpStatus(status); throw new GarminApiError({ verb: "token", classification: verdict.classification, httpStatus: status, reason: verdict.reason, ...(status === 429 || status === 503 ? { retryAfterMs: retryAfterMsOf(res) } : {}), }); } const read = await readBodyCapped(res, 64 * 1024); const value = read.ok ? extractGarminToken(read.text) : ""; if (!JWT_SHAPE.test(value)) { // A maintenance page served with 200, say. Never cached, never echoed. throw new GarminApiError({ verb: "token", classification: "transient", httpStatus: status, reason: "token_not_jwt", }); } const issuedAt = now(); const { exp, iat } = garminTokenClaims(value); // The token's own lifetime, from its issue claim when it carries one: the // local clock may be off by minutes, the token's two claims are not. const lifetime = exp === undefined ? undefined : exp - (iat ?? issuedAt); const expiresAt = exp ?? issuedAt + FALLBACK_LIFETIME_MS; return { value, expiresAt, refreshAt: refreshTime(issuedAt, expiresAt), keyExpiresAt: exp !== undefined && lifetime !== undefined && lifetime < KEY_CAPPED_BELOW_MS ? exp : null, }; } interface CachedToken extends GarminToken { /** The key and service the token was bought with; a pasted key misses. */ keyRef: string; } /** Process-local token cache, per account. Never persisted. */ const tokenCache = new Map(); /** Forget cached tokens (one account's, or all). Disconnect and tests call it. */ export function clearGarminTokenCache(userId?: string): void { if (userId === undefined) tokenCache.clear(); else tokenCache.delete(userId); } // Compared in memory only, like the key the session already holds. A hash // would add nothing here and reads as a password digest to a scanner. function keyRefFor(apiKey: string, region: GarminRegion): string { return `${region}\n${apiKey}`; } export type GarminQuery = Record< string, string | number | boolean | undefined | null >; /** * One account's session against the Personal API. Holds the key only for as * long as the session object lives, buys tokens through the shared cache, and * remembers what a token said about the key's expiry so the caller can * persist it. */ export class GarminSession { private readonly userId: string; private readonly apiKey: string; private readonly region: GarminRegion; private readonly transport: GarminTransport; private readonly keyRef: string; /** The key's expiry, once a token revealed it. */ learnedKeyExpiresAt: number | null = null; /** Number of key exchanges this session made (for the record and tests). */ exchanges = 0; /** Garmin accepted the key in this session: a token bought or a read answered. */ proven = false; constructor(opts: { userId: string; apiKey: string; region: GarminRegion; transport?: GarminTransport; }) { this.userId = opts.userId; this.apiKey = opts.apiKey; this.region = opts.region; this.transport = opts.transport ?? {}; this.keyRef = keyRefFor(opts.apiKey, opts.region); } private now(): number { return (this.transport.now ?? Date.now)(); } /** A usable token: the cached one while fresh, else a new one. */ async token(force = false): Promise { const cached = tokenCache.get(this.userId); if ( !force && cached && cached.keyRef === this.keyRef && this.now() < cached.refreshAt ) { return cached.value; } const fresh = await exchangeGarminKey( this.apiKey, this.region, this.transport, ); this.exchanges += 1; this.proven = true; if (fresh.keyExpiresAt !== null) { this.learnedKeyExpiresAt = fresh.keyExpiresAt; } tokenCache.set(this.userId, { ...fresh, keyRef: this.keyRef }); return fresh.value; } /** * GET a resource. Returns the parsed body, or null for an empty answer * (204, or no body). A 401 buys one new token and retries; a second 401 * is the key's end. */ async get( verb: string, path: string, query: GarminQuery = {}, ): Promise { const params = new URLSearchParams(); for (const [k, v] of Object.entries(query)) { if (v !== undefined && v !== null) params.set(k, String(v)); } const qs = params.toString(); const url = `${GARMIN_HOSTS[this.region]}${path}${qs ? `?${qs}` : ""}`; let token = await this.token(); let res = await this.call(verb, url, token); if (res.status === 401) { await res.body?.cancel().catch(() => {}); token = await this.token(true); res = await this.call(verb, url, token); if (res.status === 401) { await res.body?.cancel().catch(() => {}); tokenCache.delete(this.userId); throw new GarminApiError({ verb, classification: "reauth_required", httpStatus: 401, reason: "key_refused", keyRefused: true, }); } } if (res.status >= 200 && res.status < 300) this.proven = true; if (res.status === 204) return null; if (res.status < 200 || res.status >= 300) { await res.body?.cancel().catch(() => {}); const verdict = classifyHttpStatus(res.status); throw new GarminApiError({ verb, // A 403 on a data call with a token the exchange just accepted is a // resource the account cannot read, not a dead key. classification: res.status === 403 ? "persistent" : verdict.classification, httpStatus: res.status, reason: verdict.reason, ...(res.status === 429 || res.status === 503 ? { retryAfterMs: retryAfterMsOf(res, this.now()) } : {}), }); } const read = await readBodyCapped(res, GARMIN_MAX_BODY_BYTES); if (!read.ok) { throw new GarminApiError({ verb, classification: "transient", httpStatus: res.status, reason: "body_too_large", }); } if (read.text.trim() === "") return null; try { return JSON.parse(read.text) as T; } catch { throw new GarminApiError({ verb, classification: "transient", httpStatus: res.status, reason: "body_not_json", }); } } private async call( verb: string, url: string, token: string, ): Promise { const doFetch = this.transport.fetch ?? safeFetch; const start = performance.now(); const res = await doFetch(url, { method: "GET", headers: { Accept: "application/json", Authorization: `Bearer ${token}` }, }); getEvent()?.addExternalCall({ service: "garmin", method: verb, duration_ms: Math.round(performance.now() - start), status: res.status, error: res.ok ? undefined : `http_${res.status}`, }); return res; } } // ── date windows ──────────────────────────────────────────────────── /** `day` moved by `delta` calendar days (pure key arithmetic). */ export function shiftGarminDay(day: string, delta: number): string { return shiftDateKey(day, delta); } /** * Split `[startDate, endDate]` (both inclusive) into windows of at most * `maxDays` calendar days, newest first. An inverted range yields nothing. */ export function chunkGarminRange( startDate: string, endDate: string, maxDays: number, ): Array<{ startDate: string; endDate: string }> { if (maxDays < 1) throw new Error("maxDays must be at least 1"); const out: Array<{ startDate: string; endDate: string }> = []; let end = endDate; while (end >= startDate) { const candidate = shiftGarminDay(end, -(maxDays - 1)); const start = candidate < startDate ? startDate : candidate; out.push({ startDate: start, endDate: end }); end = shiftGarminDay(start, -1); } return out; }