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

@@ -184,6 +184,7 @@ export async function loadConnectionFacts(
stravaAccessTokenEncrypted: true,
nightscoutUrlEncrypted: true,
dawarichUrlEncrypted: true,
garminApiKeyEncrypted: true,
healthKitLastSyncedAt: true,
},
}),
@@ -245,6 +246,7 @@ export async function loadConnectionFacts(
strava: !!dbUser?.stravaAccessTokenEncrypted,
nightscout: !!dbUser?.nightscoutUrlEncrypted,
dawarich: !!dbUser?.dawarichUrlEncrypted,
garmin: !!dbUser?.garminApiKeyEncrypted,
// Apple Health has nothing to connect; it is "connected" once the phone
// has delivered anything (a live batch, or rows from the export import).
"apple-health": false,
@@ -44,6 +44,7 @@ export const CONNECTION_KEYS: readonly ConnectionKey[] = [
"strava",
"nightscout",
"dawarich",
"garmin",
"apple-health",
];
+2
View File
@@ -54,6 +54,7 @@ export const INTEGRATION_MEASUREMENT_SOURCE: Partial<
polar: "POLAR",
oura: "OURA",
"google-health": "GOOGLE_HEALTH",
garmin: "GARMIN",
};
/**
@@ -72,6 +73,7 @@ export const INTEGRATION_WORKOUT_SOURCE: Partial<
whoop: "WHOOP",
fitbit: "FITBIT",
"google-health": "GOOGLE_HEALTH",
garmin: "GARMIN",
};
/**
+2
View File
@@ -49,6 +49,7 @@ export async function getSourcesInUse(
polarAccessTokenEncrypted: true,
ouraAccessTokenEncrypted: true,
stravaAccessTokenEncrypted: true,
garminApiKeyEncrypted: true,
},
}),
prisma.withingsConnection.findUnique({
@@ -79,6 +80,7 @@ export async function getSourcesInUse(
if (user?.polarAccessTokenEncrypted) inUse.add("POLAR");
if (user?.ouraAccessTokenEncrypted) inUse.add("OURA");
if (user?.stravaAccessTokenEncrypted) inUse.add("STRAVA");
if (user?.garminApiKeyEncrypted) inUse.add("GARMIN");
return RANKABLE_SOURCES.filter((s) => inUse.has(s));
}
+4 -1
View File
@@ -63,7 +63,9 @@ export type IntegrationKey =
// v1.28.x — Strava OAuth workout source.
| "strava"
// v1.43 — Dawarich movement context (daily values, nightly).
| "dawarich";
| "dawarich"
// Garmin Personal API (a pasted key, hourly poll).
| "garmin";
/**
* Failure kinds carried into `recordSyncFailure`.
@@ -1281,6 +1283,7 @@ const INTEGRATION_LABELS: Record<IntegrationKey, string> = {
"google-health": "Google Health",
strava: "Strava",
dawarich: "Dawarich",
garmin: "Garmin",
};
const FAILURE_KIND_COPY: Record<
+1
View File
@@ -126,6 +126,7 @@ export const INTEGRATION_CADENCE: Record<IntegrationKey, SyncCadence> = {
"google-health": POLLED_CADENCE,
strava: POLLED_CADENCE,
dawarich: NIGHTLY_CADENCE,
garmin: POLLED_CADENCE,
};
export interface SyncVerdictInput {
+4 -1
View File
@@ -62,7 +62,7 @@ export const INCIDENT_AFTER_MS = DAY_MS;
*
* - 24 h for wearables and CGMs whose cloud receives data on its own
* (Fitbit, Google Health, Polar, Nightscout).
* - 48 h for Oura and WHOOP. Their rings and straps upload only when the
* - 48 h for Oura, WHOOP and Garmin. Their rings and straps upload only when the
* phone app is opened, so a day without opening it is ordinary and must
* not page anyone.
* - 48 h for Apple Health. iOS defers background delivery when the phone is
@@ -86,6 +86,9 @@ export const SOURCE_PROFILES: Record<ConnectionKey, SourceProfile> = {
polar: { kind: "continuous", silenceWindowMs: DAY_MS },
nightscout: { kind: "continuous", silenceWindowMs: DAY_MS },
dawarich: { kind: "on_demand", silenceWindowMs: DAY_MS },
// A Garmin watch uploads through the phone app like the rings and straps,
// so a day without opening it is ordinary.
garmin: { kind: "continuous", silenceWindowMs: 2 * DAY_MS },
"apple-health": { kind: "continuous", silenceWindowMs: 2 * DAY_MS },
};
+2
View File
@@ -76,6 +76,7 @@ const LABELS: Record<ConnectionKey, string> = {
strava: "Strava",
nightscout: "Nightscout",
dawarich: "Dawarich",
garmin: "Garmin",
"apple-health": "Apple Health",
};
@@ -145,6 +146,7 @@ async function listWatchedUserIds(): Promise<string[]> {
{ stravaAccessTokenEncrypted: { not: null } },
{ nightscoutUrlEncrypted: { not: null } },
{ dawarichUrlEncrypted: { not: null } },
{ garminApiKeyEncrypted: { not: null } },
{ healthKitLastSyncedAt: { not: null } },
],
},