feat(garmin): sync Garmin Connect through the Personal API

Garmin Connect now offers a Personal API: a person creates a key for
their own account and any client can buy short-lived tokens with it.
HealthLog takes that key in a new Garmin card, checks it with Garmin
before storing it sealed on the user row, and polls every hour.

- Transport: key exchange with a per-process token cache keyed on the
  key, one re-exchange on a refused token and then reauth_required, the
  429 Retry-After carried to the caller, the China host tried when the
  global one does not know the key, answers read with a byte cap, all
  egress through safeFetch to the two fixed hosts.
- Sync: daily summaries, sleep summaries, HRV, weigh-ins and the
  activity search, chunked to each resource's documented range, read one
  after another and stopped at the first rate limit; today's menstrual
  cycle summary adds logged period days the person has not logged.
- Backfill: a resumable walk through the shared admission lane that
  learns where the history starts from the daily summaries and the
  activities, waits out Garmin's rate limit and stamps its marker.
- Types: GARMIN source; BODY_BATTERY and STRESS_LEVEL for Garmin's own
  day scores, kept apart from the computed STRESS_SCORE; TRAINING_READINESS
  and ACUTE_TRAINING_LOAD for figures a bridge pushes through the ingest
  token. All four chart on the recovery page and sit on the ladders,
  units, ranges, labels and report groups like the device-native scores.
- Key expiry: learned from a token capped short of a day, else estimated
  from the validity picked at connect; the card warns a week ahead and
  asks for a new key once Garmin refuses the old one.
- Ledger, health, freshness, watchdog, backup classes, wipe plan,
  encrypted-column registry, rotation script, OpenAPI and all ten
  locales carry the new integration. The old "no Garmin connector" note
  is gone.
This commit is contained in:
Marc-André Bombeck committed 2026-11-04 10:14:10 +01:00
1 parent 67909f2d9d
commit e2ba86b12f
119 files changed
+5798 -400

No files matched your search

@@ -0,0 +1,34 @@
-- Garmin Personal API (#1333): a per-user key pasted once, a new measurement
-- source for the rows the sync writes, Garmin's own day scores (Body Battery,
-- stress level), and two types a bridge pushes through the ingest token
-- because the Personal API does not carry them (Training Readiness, acute
-- training load).
--
-- One DO block, so the lock wait is bounded and the file is one statement
-- that rolls back whole. Adding an enum value inside a transaction is fine
-- since PostgreSQL 12 as long as the value is not used in it, and nothing
-- here uses them.
--
-- Purely additive: nullable columns, no default, no backfill. IF NOT EXISTS
-- keeps a replay harmless. Enum values cannot be dropped in PostgreSQL, so
-- there is no down path for them; dropping the six columns undoes the rest.
--
-- @retry-on-lock-timeout
DO $migration$
BEGIN
PERFORM set_config('lock_timeout', '5s', true);
ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'GARMIN';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BODY_BATTERY';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STRESS_LEVEL';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'TRAINING_READINESS';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'ACUTE_TRAINING_LOAD';
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "garmin_api_key_encrypted" TEXT,
ADD COLUMN IF NOT EXISTS "garmin_key_expires_at" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "garmin_connected_at" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "garmin_region" TEXT,
ADD COLUMN IF NOT EXISTS "garmin_backfill_progress" JSONB,
ADD COLUMN IF NOT EXISTS "garmin_backfill_completed_at" TIMESTAMP(3);
END
$migration$;
+29
View File
@@ -320,6 +320,23 @@ model User {
/// account another record already holds is refused
/// (`src/lib/integrations/oauth-kit/account-link.ts`). Cleared on disconnect.
ouraUserId String? @map("oura_user_id")
// v1.47 — Garmin Personal API (#1333, per-user, encrypted at app level). The
// person creates a key in Garmin Connect and pastes it once; the sync
// exchanges it for a short-lived token on every run, so the key is the only
// credential at rest. The token itself is never stored.
/// Sealed Garmin Personal API key.
garminApiKeyEncrypted String? @map("garmin_api_key_encrypted")
/// v1.47 — when the Garmin key stops working: learned from the token's own expiry once it is capped by the key's, else the validity the person picked at connect counted from then. Null when neither is known. Drives the card's expiry warning.
garminKeyExpiresAt DateTime? @map("garmin_key_expires_at")
/// v1.47 — when the current Garmin key was connected; the anchor the picked validity counts from.
garminConnectedAt DateTime? @map("garmin_connected_at")
/// v1.47 — which Garmin service accepted the key at connect: `global` (personal.connectapi.garmin.com) or `china` (…garmin.cn). Not a secret; the sync reads it to pick the host.
garminRegion String? @map("garmin_region")
/// v1.47 — how far the Garmin history backfill has got, per resource (see `src/lib/garmin/backfill-progress.ts`). Cleared on connect and disconnect.
garminBackfillProgress Json? @map("garmin_backfill_progress")
/// v1.47 — the Garmin history backfill finished (discovery predicate `garmin_backfill_completed_at IS NULL`). Cleared on connect.
garminBackfillCompletedAt DateTime? @map("garmin_backfill_completed_at")
/// v1.44 — which provider apps on this record were copied from a
/// guardian at connect, and from whom: `{ "<provider>": "<guardianUserId>" }`.
/// A managed profile has no settings page for its own app, so a guardian's is
@@ -1783,6 +1800,13 @@ enum MeasurementType {
INDOOR_TEMPERATURE // celsius — indoor air temperature. Distinct from every body temperature type.
INDOOR_HUMIDITY // percent (0–100) — indoor relative humidity.
AFIB_BURDEN // percent (0–100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `atrialFibrillationBurden` (iOS 16+, AFib History). The share of time the watch estimated in AFib over a week. AWARENESS ONLY — never a HealthLog diagnosis.
// v1.47 — Garmin Personal API (#1333). Garmin's own scores keep types of
// their own, so a device-native figure never shares a bucket with the
// COMPUTED proxies or another vendor's score of a different scale.
BODY_BATTERY // score (0–100) — Garmin Body Battery, the day's highest charge. Source GARMIN.
STRESS_LEVEL // score (0–100) — Garmin's all-day average stress level. Device-native and kept apart from the COMPUTED STRESS_SCORE, whose readers are single-source.
TRAINING_READINESS // score (0–100) — Garmin Training Readiness. Not in the Personal API; written by a bridge through the ingest token (source EXTERNAL).
ACUTE_TRAINING_LOAD // load (unit-less) — Garmin acute training load. Not in the Personal API; written by a bridge through the ingest token (source EXTERNAL).
@@map("measurement_type")
}
@@ -2018,6 +2042,11 @@ enum MeasurementSource {
// iOS DTO must mirror this spelling so the /api/sync/changes delta feed
// decodes the rows.
HEALTH_CONNECT
// v1.47 — Garmin Personal API (#1333). Server-owned: only the Garmin sync
// writes these rows, from the key the user pasted, so the batch +
// single-POST surfaces reject it like the other providers. iOS DTO must
// mirror this spelling so the /api/sync/changes delta feed decodes the rows.
GARMIN
@@map("measurement_source")
}