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

@@ -103,7 +103,7 @@ describe("sourcePrioritySchema", () => {
it("rejects an unknown source string", () => {
const result = sourcePrioritySchema.safeParse({
weight: ["GARMIN", "WITHINGS"],
weight: ["SAMSUNG_HEALTH", "WITHINGS"],
});
expect(result.success).toBe(false);
});
@@ -578,7 +578,12 @@ describe("DEFAULT_SOURCE_PRIORITY — the sources that had no place (v1.43.11)",
});
it("leaves the order of the sources the ladders already named alone", () => {
const added = new Set<string>([...HAND_ENTERED, "HEALTH_CONNECT"]);
const added = new Set<string>([
...HAND_ENTERED,
"HEALTH_CONNECT",
// Garmin joined the ladders later; without it the rest kept its order.
"GARMIN",
]);
const without = (key: keyof typeof DEFAULT_SOURCE_PRIORITY) =>
DEFAULT_SOURCE_PRIORITY[key].filter((s) => !added.has(s));
expect(without("weight")).toEqual([
+26
View File
@@ -0,0 +1,26 @@
import { z } from "zod/v4";
import { GARMIN_KEY_VALIDITY_DAYS } from "@/lib/garmin/key-expiry";
/**
* `POST /api/garmin/connect`. The key is what Garmin Connect shows once when
* it is created (Settings, Personal API). It is trimmed before use: a copy
* button's trailing newline would otherwise reach Garmin and answer as a
* refused key. `validityDays` is the validity picked at creation; Garmin
* does not report it, so it is the only way to warn ahead of the end until
* the key's last day gives it away.
*/
export const garminConnectSchema = z.object({
apiKey: z
.string()
.trim()
.min(16, "The Garmin key is too short")
.max(1024, "The Garmin key is too long")
.regex(/^\S+$/, "The Garmin key cannot contain spaces"),
validityDays: z
.union(GARMIN_KEY_VALIDITY_DAYS.map((d) => z.literal(d)))
.nullable()
.optional(),
});
export type GarminConnectInput = z.infer<typeof garminConnectSchema>;
+12
View File
@@ -157,6 +157,18 @@ export const measurementTypeEnum = z.enum([
"INDOOR_CO2",
"INDOOR_TEMPERATURE",
"INDOOR_HUMIDITY",
// ── Garmin Personal API (#1333) ──
// BODY_BATTERY and STRESS_LEVEL ingest server-side as `source = GARMIN`.
// STRESS_LEVEL is Garmin's own all-day stress average and is kept apart
// from the COMPUTED STRESS_SCORE proxy: the score readers collapse only the
// recovery set across sources, so a second STRESS_SCORE producer would be
// averaged with the proxy instead of ranked above it.
"BODY_BATTERY",
"STRESS_LEVEL",
// The Personal API does not carry these two; a home-automation bridge pushes
// them through the ingest token (`measurements:write`, source EXTERNAL).
"TRAINING_READINESS",
"ACUTE_TRAINING_LOAD",
]);
/**
+18
View File
@@ -261,12 +261,20 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
// device-native wearables and above MANUAL so an Apple-Watch + Strava twin of
// the same run collapses onto the richer device row, while a Strava-only run
// still survives.
// Garmin (Personal API) is a worn watch in the same class as the
// Fitbit / Google Health wearables and ranks right after them on every
// ladder it writes to, above the rings and straps that follow. On weight
// and body fat it sits with the scale-fed sources, since a Garmin Index
// scale reports there; below WITHINGS and the HealthKit relay. It writes
// no SDNN, so the `hrv` ladder does not name it; its overnight HRV is
// RMSSD and ranks on `hrvRmssd`.
steps: [
"APPLE_HEALTH",
"HEALTH_CONNECT",
"WITHINGS",
"FITBIT",
"GOOGLE_HEALTH",
"GARMIN",
"OURA",
"POLAR",
"STRAVA",
@@ -281,6 +289,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"WITHINGS",
"FITBIT",
"GOOGLE_HEALTH",
"GARMIN",
"OURA",
"POLAR",
"MANUAL",
@@ -294,6 +303,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"WITHINGS",
"FITBIT",
"GOOGLE_HEALTH",
"GARMIN",
"OURA",
"POLAR",
"MANUAL",
@@ -306,6 +316,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"WITHINGS",
"FITBIT",
"GOOGLE_HEALTH",
"GARMIN",
"MANUAL",
"EXTERNAL",
"MCP",
@@ -323,6 +334,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
"GARMIN",
"OURA",
"POLAR",
"APPLE_HEALTH",
@@ -341,6 +353,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
"GARMIN",
"OURA",
"POLAR",
"APPLE_HEALTH",
@@ -352,6 +365,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"WITHINGS",
"APPLE_HEALTH",
"HEALTH_CONNECT",
"GARMIN",
"MANUAL",
"EXTERNAL",
"MCP",
@@ -389,6 +403,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"WITHINGS",
"APPLE_HEALTH",
"HEALTH_CONNECT",
"GARMIN",
"MANUAL",
"EXTERNAL",
"MCP",
@@ -415,6 +430,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
"GARMIN",
"OURA",
"POLAR",
"APPLE_HEALTH",
@@ -446,6 +462,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
"GARMIN",
"OURA",
"POLAR",
"APPLE_HEALTH",
@@ -475,6 +492,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
"GARMIN",
"OURA",
"POLAR",
"APPLE_HEALTH",