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

@@ -340,7 +340,7 @@ describe("/api/integrations/status — the freshness read failed", () => {
expect(envelope.metricFreshnessDegraded).toBe(true);
// Still fail-soft: the rest of the envelope survives, all eight entries
// present, because a freshness hiccup is never worth the settings surface.
expect(envelope.integrations).toHaveLength(9);
expect(envelope.integrations).toHaveLength(10);
for (const entry of envelope.integrations) {
expect(entry.metricFreshness).toEqual([]);
}
@@ -472,6 +472,7 @@ describe("/api/integrations/status — the unified connection health", () => {
strava: null,
nightscout: null,
dawarich: null,
garmin: null,
"apple-health": null,
} as never);
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "tok" });
+86
View File
@@ -15,6 +15,15 @@
* callers; the web cards no longer hit them.
*/
import { prisma } from "@/lib/db";
import { isGarminRegion, type GarminRegion } from "@/lib/garmin/client";
import {
resolveGarminKeyExpiry,
type GarminKeyExpiry,
} from "@/lib/garmin/key-expiry";
import {
garminBackfillShare,
readGarminBackfillProgress,
} from "@/lib/garmin/backfill-progress";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
@@ -83,6 +92,7 @@ export const GET = apiHandler(async () => {
stravaStatus,
nightscoutStatus,
dawarichStatus,
garminStatus,
dbUser,
withingsConn,
whoopConn,
@@ -108,6 +118,7 @@ export const GET = apiHandler(async () => {
getIntegrationStatus(user.id, "strava"),
getIntegrationStatus(user.id, "nightscout"),
getIntegrationStatus(user.id, "dawarich"),
getIntegrationStatus(user.id, "garmin"),
prisma.user.findUnique({
where: { id: user.id },
select: {
@@ -135,6 +146,12 @@ export const GET = apiHandler(async () => {
nightscoutTokenEncrypted: true,
nightscoutAllowPrivateHost: true,
dawarichUrlEncrypted: true,
// Garmin: the connect marker and the facts its card warns from.
garminApiKeyEncrypted: true,
garminRegion: true,
garminKeyExpiresAt: true,
garminBackfillProgress: true,
garminBackfillCompletedAt: true,
},
}),
prisma.withingsConnection.findUnique({
@@ -194,6 +211,7 @@ export const GET = apiHandler(async () => {
"strava",
"nightscout",
"dawarich",
"garmin",
]),
// See `metricFreshnessDegraded` below.
getSourceMetricFreshness(user.id).catch(() => {
@@ -319,6 +337,7 @@ export const GET = apiHandler(async () => {
const stravaConnected = !!dbUser?.stravaAccessTokenEncrypted;
const nightscoutConfigured = !!dbUser?.nightscoutUrlEncrypted;
const dawarichConfigured = !!dbUser?.dawarichUrlEncrypted;
const garmin = garminExtras(dbUser);
if (metricFreshnessDegraded) {
annotate({ meta: { integrations_metric_freshness_degraded: true } });
@@ -512,6 +531,20 @@ export const GET = apiHandler(async () => {
configured: dawarichConfigured,
}),
} satisfies IntegrationViewModel & DawarichExtras,
{
...publish(garminStatus),
connected: garmin.connected,
configured: garmin.configured,
region: garmin.region,
keyExpiry: garmin.keyExpiry,
backfillCompleted: garmin.backfillCompleted,
backfillShare: garmin.backfillShare,
health: health["garmin"],
...resolve("garmin", garminStatus, {
connected: garmin.connected,
configured: garmin.configured,
}),
} satisfies IntegrationViewModel & GarminExtras,
],
});
});
@@ -617,6 +650,59 @@ interface NightscoutExtras {
allowPrivateHost: boolean;
}
// Garmin: the connect marker plus the key's expiry, the region its
// key was accepted on, and how far the history backfill has got.
interface GarminExtras {
connected: boolean;
configured: boolean;
region: GarminRegion | null;
keyExpiry: GarminKeyExpiry | null;
backfillCompleted: boolean | null;
backfillShare: number | null;
}
/**
* The Garmin entry's own facts. A Personal API key the person pastes once is
* the connect marker; the key is never on the wire, only when it expires,
* resolved here so every client warns alike.
*/
function garminExtras(
dbUser: {
garminApiKeyEncrypted: string | null;
garminRegion: string | null;
garminKeyExpiresAt: Date | null;
garminBackfillProgress: unknown;
garminBackfillCompletedAt: Date | null;
} | null,
): GarminExtras {
const configured = !!dbUser?.garminApiKeyEncrypted;
if (!dbUser || !configured) {
return {
connected: false,
configured: false,
region: null,
keyExpiry: null,
backfillCompleted: null,
backfillShare: null,
};
}
const complete = dbUser.garminBackfillCompletedAt !== null;
return {
connected: true,
configured: true,
region: isGarminRegion(dbUser.garminRegion)
? dbUser.garminRegion
: "global",
keyExpiry: resolveGarminKeyExpiry(dbUser.garminKeyExpiresAt),
backfillCompleted: complete,
backfillShare: complete
? 1
: garminBackfillShare(
readGarminBackfillProgress(dbUser.garminBackfillProgress),
),
};
}
// v1.43 — Dawarich: the connect marker only; the card reads `health` and its
// own status route.
interface DawarichExtras {