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

@@ -68,7 +68,7 @@
"backupId": "<id:1>",
"ownerId": "round-trip-owner",
"reason": "schema_invalid",
"message": "measurements.0.type: Invalid option: expected one of \"WEIGHT\"|\"BLOOD_PRESSURE_SYS\"|\"BLOOD_PRESSURE_DIA\"|\"PULSE\"|\"BODY_FAT\"|\"SLEEP_DURATION\"|\"ACTIVITY_STEPS\"|\"BLOOD_GLUCOSE\"|\"TOTAL_BODY_WATER\"|\"BONE_MASS\"|\"OXYGEN_SATURATION\"|\"HEART_RATE_VARIABILITY\"|\"RESTING_HEART_RATE\"|\"ACTIVE_ENERGY_BURNED\"|\"FLIGHTS_CLIMBED\"|\"WALKING_RUNNING_DISTANCE\"|\"VO2_MAX\"|\"BODY_TEMPERATURE\"|\"FAT_FREE_MASS\"|\"FAT_MASS\"|\"MUSCLE_MASS\"|\"SKIN_TEMPERATURE\"|\"PULSE_WAVE_VELOCITY\"|\"VASCULAR_AGE\"|\"VISCERAL_FAT\"|\"AUDIO_EXPOSURE_ENV\"|\"AUDIO_EXPOSURE_HEADPHONE\"|\"TIME_IN_DAYLIGHT\"|\"WALKING_STEADINESS\"|\"AUDIO_EXPOSURE_EVENT\"|\"RESPIRATORY_RATE\"|\"BODY_MASS_INDEX\"|\"LEAN_BODY_MASS\"|\"WALKING_HEART_RATE_AVERAGE\"|\"WALKING_ASYMMETRY\"|\"WALKING_DOUBLE_SUPPORT\"|\"WALKING_STEP_LENGTH\"|\"WALKING_SPEED\"|\"CARDIO_RECOVERY\"|\"WRIST_TEMPERATURE\"|\"FALL_COUNT\"|\"SIX_MINUTE_WALK_DISTANCE\"|\"STAIR_ASCENT_SPEED\"|\"STAIR_DESCENT_SPEED\"|\"BREATHING_DISTURBANCES\"|\"IRREGULAR_RHYTHM_NOTIFICATION\"|\"HIGH_HEART_RATE_EVENT\"|\"LOW_HEART_RATE_EVENT\"|\"WALKING_STEADINESS_EVENT\"|\"BREATHING_DISTURBANCE_EVENT\"|\"RECOVERY_SCORE\"|\"STRESS_SCORE\"|\"STRAIN_SCORE\"|\"HRV_RMSSD\"|\"DAY_STRAIN\"|\"WORKOUT_STRAIN\"|\"SLEEP_PERFORMANCE\"|\"SLEEP_EFFICIENCY\"|\"SLEEP_CONSISTENCY\"|\"SLEEP_NEED\"|\"ENERGY_EXPENDITURE_KJ\"|\"AVERAGE_HEART_RATE\"|\"MAX_HEART_RATE\"|\"SLEEP_DISTURBANCE_COUNT\"|\"ANS_CHARGE\"|\"CARDIO_LOAD\"|\"SLEEP_SCORE\"|\"BODY_TEMPERATURE_DEVIATION\"|\"RESILIENCE\"|\"PHQ9_SCORE\"|\"GAD7_SCORE\"|\"GRIP_STRENGTH\"|\"PAIN_NRS\"|\"WAIST_CIRCUMFERENCE\"|\"WAIST_TO_HEIGHT\"|\"WHO5_SCORE\"|\"SCI_SCORE\"|\"EXERCISE_MINUTES\"|\"STAND_MINUTES\"|\"STAND_HOURS\"|\"MINDFUL_MINUTES\"|\"STATE_OF_MIND_VALENCE\"|\"INDOOR_CO2\"|\"INDOOR_TEMPERATURE\"|\"INDOOR_HUMIDITY\"|\"AFIB_BURDEN\""
"message": "measurements.0.type: Invalid option: expected one of \"WEIGHT\"|\"BLOOD_PRESSURE_SYS\"|\"BLOOD_PRESSURE_DIA\"|\"PULSE\"|\"BODY_FAT\"|\"SLEEP_DURATION\"|\"ACTIVITY_STEPS\"|\"BLOOD_GLUCOSE\"|\"TOTAL_BODY_WATER\"|\"BONE_MASS\"|\"OXYGEN_SATURATION\"|\"HEART_RATE_VARIABILITY\"|\"RESTING_HEART_RATE\"|\"ACTIVE_ENERGY_BURNED\"|\"FLIGHTS_CLIMBED\"|\"WALKING_RUNNING_DISTANCE\"|\"VO2_MAX\"|\"BODY_TEMPERATURE\"|\"FAT_FREE_MASS\"|\"FAT_MASS\"|\"MUSCLE_MASS\"|\"SKIN_TEMPERATURE\"|\"PULSE_WAVE_VELOCITY\"|\"VASCULAR_AGE\"|\"VISCERAL_FAT\"|\"AUDIO_EXPOSURE_ENV\"|\"AUDIO_EXPOSURE_HEADPHONE\"|\"TIME_IN_DAYLIGHT\"|\"WALKING_STEADINESS\"|\"AUDIO_EXPOSURE_EVENT\"|\"RESPIRATORY_RATE\"|\"BODY_MASS_INDEX\"|\"LEAN_BODY_MASS\"|\"WALKING_HEART_RATE_AVERAGE\"|\"WALKING_ASYMMETRY\"|\"WALKING_DOUBLE_SUPPORT\"|\"WALKING_STEP_LENGTH\"|\"WALKING_SPEED\"|\"CARDIO_RECOVERY\"|\"WRIST_TEMPERATURE\"|\"FALL_COUNT\"|\"SIX_MINUTE_WALK_DISTANCE\"|\"STAIR_ASCENT_SPEED\"|\"STAIR_DESCENT_SPEED\"|\"BREATHING_DISTURBANCES\"|\"IRREGULAR_RHYTHM_NOTIFICATION\"|\"HIGH_HEART_RATE_EVENT\"|\"LOW_HEART_RATE_EVENT\"|\"WALKING_STEADINESS_EVENT\"|\"BREATHING_DISTURBANCE_EVENT\"|\"RECOVERY_SCORE\"|\"STRESS_SCORE\"|\"STRAIN_SCORE\"|\"HRV_RMSSD\"|\"DAY_STRAIN\"|\"WORKOUT_STRAIN\"|\"SLEEP_PERFORMANCE\"|\"SLEEP_EFFICIENCY\"|\"SLEEP_CONSISTENCY\"|\"SLEEP_NEED\"|\"ENERGY_EXPENDITURE_KJ\"|\"AVERAGE_HEART_RATE\"|\"MAX_HEART_RATE\"|\"SLEEP_DISTURBANCE_COUNT\"|\"ANS_CHARGE\"|\"CARDIO_LOAD\"|\"SLEEP_SCORE\"|\"BODY_TEMPERATURE_DEVIATION\"|\"RESILIENCE\"|\"PHQ9_SCORE\"|\"GAD7_SCORE\"|\"GRIP_STRENGTH\"|\"PAIN_NRS\"|\"WAIST_CIRCUMFERENCE\"|\"WAIST_TO_HEIGHT\"|\"WHO5_SCORE\"|\"SCI_SCORE\"|\"EXERCISE_MINUTES\"|\"STAND_MINUTES\"|\"STAND_HOURS\"|\"MINDFUL_MINUTES\"|\"STATE_OF_MIND_VALENCE\"|\"INDOOR_CO2\"|\"INDOOR_TEMPERATURE\"|\"INDOOR_HUMIDITY\"|\"AFIB_BURDEN\"|\"BODY_BATTERY\"|\"STRESS_LEVEL\"|\"TRAINING_READINESS\"|\"ACUTE_TRAINING_LOAD\""
}
}
],
@@ -2742,6 +2742,12 @@
"ouraBackfillProgress": null,
"ouraBackfillCompletedAt": null,
"ouraUserId": null,
"garminApiKeyEncrypted": null,
"garminKeyExpiresAt": null,
"garminConnectedAt": null,
"garminRegion": null,
"garminBackfillProgress": null,
"garminBackfillCompletedAt": null,
"clientAppSharedBy": null,
"stravaClientIdEncrypted": null,
"stravaClientSecretEncrypted": null,
@@ -2236,6 +2236,12 @@
"ouraBackfillProgress": null,
"ouraBackfillCompletedAt": null,
"ouraUserId": null,
"garminApiKeyEncrypted": null,
"garminKeyExpiresAt": null,
"garminConnectedAt": null,
"garminRegion": null,
"garminBackfillProgress": null,
"garminBackfillCompletedAt": null,
"clientAppSharedBy": null,
"stravaClientIdEncrypted": null,
"stravaClientSecretEncrypted": null,
@@ -879,6 +879,12 @@
"ouraBackfillProgress": null,
"ouraBackfillCompletedAt": null,
"ouraUserId": null,
"garminApiKeyEncrypted": null,
"garminKeyExpiresAt": null,
"garminConnectedAt": null,
"garminRegion": null,
"garminBackfillProgress": null,
"garminBackfillCompletedAt": null,
"clientAppSharedBy": null,
"stravaClientIdEncrypted": null,
"stravaClientSecretEncrypted": null,
@@ -1017,6 +1017,12 @@
"ouraBackfillProgress": null,
"ouraBackfillCompletedAt": null,
"ouraUserId": null,
"garminApiKeyEncrypted": null,
"garminKeyExpiresAt": null,
"garminConnectedAt": null,
"garminRegion": null,
"garminBackfillProgress": null,
"garminBackfillCompletedAt": null,
"clientAppSharedBy": null,
"stravaClientIdEncrypted": null,
"stravaClientSecretEncrypted": null,
@@ -381,14 +381,15 @@
"GOOGLE_HEALTH",
"STRAVA",
"EXTERNAL",
"HEALTH_CONNECT"
"HEALTH_CONNECT",
"GARMIN"
],
"path": [
"ecgRecordings",
0,
"source"
],
"message": "Invalid option: expected one of \"MANUAL\"|\"WITHINGS\"|\"IMPORT\"|\"APPLE_HEALTH\"|\"COMPUTED\"|\"WHOOP\"|\"FITBIT\"|\"NIGHTSCOUT\"|\"POLAR\"|\"OURA\"|\"TELEGRAM\"|\"MCP\"|\"GOOGLE_HEALTH\"|\"STRAVA\"|\"EXTERNAL\"|\"HEALTH_CONNECT\""
"message": "Invalid option: expected one of \"MANUAL\"|\"WITHINGS\"|\"IMPORT\"|\"APPLE_HEALTH\"|\"COMPUTED\"|\"WHOOP\"|\"FITBIT\"|\"NIGHTSCOUT\"|\"POLAR\"|\"OURA\"|\"TELEGRAM\"|\"MCP\"|\"GOOGLE_HEALTH\"|\"STRAVA\"|\"EXTERNAL\"|\"HEALTH_CONNECT\"|\"GARMIN\""
},
{
"expected": "string",
@@ -2353,6 +2353,14 @@
},
"ouraBackfillCompletedAt": "2026-06-30T12:34:56.789Z",
"ouraUserId": "rt-User-ouraUserId",
"garminApiKeyEncrypted": "<sealed:v1:rt-User-garminApiKeyEncrypted>",
"garminKeyExpiresAt": "2026-06-30T12:34:56.789Z",
"garminConnectedAt": "2026-06-30T12:34:56.789Z",
"garminRegion": "rt-User-garminRegion",
"garminBackfillProgress": {
"roundTrip": "rt-User-garminBackfillProgress"
},
"garminBackfillCompletedAt": "2026-06-30T12:34:56.789Z",
"clientAppSharedBy": {
"roundTrip": "rt-User-clientAppSharedBy"
},
@@ -1,5 +1,5 @@
{
"registeredQueueCount": 127,
"registeredQueueCount": 129,
"registeredQueues": [
"account-deletion-purge",
"achievement-unlock-sweep",
@@ -48,6 +48,8 @@
"fitbit-sleep-repair",
"fitbit-sync",
"free-text-encryption-backfill",
"garmin-backfill",
"garmin-sync",
"geo-backfill",
"geolite2-fetch",
"google-health-backfill",
@@ -835,6 +837,36 @@
"retry_delay_max": null,
"retry_limit": 2
},
{
"dead_letter": null,
"deletion_seconds": 604800,
"expire_seconds": 900,
"heartbeat_seconds": null,
"name": "garmin-backfill",
"notify": false,
"partition": false,
"policy": "exclusive",
"retention_seconds": 1209600,
"retry_backoff": false,
"retry_delay": 0,
"retry_delay_max": null,
"retry_limit": 2
},
{
"dead_letter": null,
"deletion_seconds": 604800,
"expire_seconds": 900,
"heartbeat_seconds": null,
"name": "garmin-sync",
"notify": false,
"partition": false,
"policy": "standard",
"retention_seconds": 1209600,
"retry_backoff": false,
"retry_delay": 0,
"retry_delay_max": null,
"retry_limit": 2
},
{
"dead_letter": null,
"deletion_seconds": 604800,
@@ -2371,6 +2403,17 @@
},
"timezone": "Europe/Berlin"
},
{
"cron": "23 * * * *",
"data": {},
"key": "",
"kind": "cron",
"name": "garmin-sync",
"options": {
"tz": "Europe/Berlin"
},
"timezone": "Europe/Berlin"
},
{
"cron": "40 * * * *",
"data": {},
@@ -3311,6 +3354,18 @@
"localConcurrency": 1
}
},
{
"queue": "garmin-backfill",
"options": {
"localConcurrency": 1
}
},
{
"queue": "garmin-sync",
"options": {
"localConcurrency": 1
}
},
{
"queue": "geo-backfill",
"options": {
@@ -19,6 +19,7 @@ import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement";
import { upsertNightscoutEntries } from "@/lib/nightscout/sync";
import { upsertOuraMeasurements } from "@/lib/oura/sync";
import { upsertGarminMeasurements } from "@/lib/garmin/sync";
import { upsertPolarMeasurements } from "@/lib/polar/sync";
import { syncUserActivity } from "@/lib/withings/sync-activity";
import { upsertWhoopMeasurements } from "@/lib/whoop/sync-core";
@@ -241,6 +242,21 @@ const SOURCE_CASES = {
]);
},
},
GARMIN: {
expectedType: "BODY_BATTERY",
expectedExternalId: "daily:2026-07-20:body_battery",
write: async () => {
await upsertGarminMeasurements(USER_ID, [
{
type: "BODY_BATTERY",
value: 81,
unit: "score",
measuredAt: new Date("2026-07-20T10:00:00.000Z"),
externalId: "daily:2026-07-20:body_battery",
},
]);
},
},
TELEGRAM: {
expectedType: "WEIGHT",
expectedExternalId: "telegram:source-smoke",
@@ -423,9 +439,9 @@ describe("MeasurementSource write paths against real Postgres", () => {
});
}
expect(sourceCases).toHaveLength(16);
expect(sourceCases).toHaveLength(17);
expect(await prisma.measurement.count({ where: { userId: USER_ID } })).toBe(
16,
17,
);
});
});