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

+161
View File
@@ -0,0 +1,161 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { markReconnected } from "@/lib/integrations/status";
import { GARMIN_SYNC_QUEUE } from "@/lib/jobs/integration-poll-queues";
import { GARMIN_BACKFILL_QUEUE } from "@/lib/jobs/garmin-backfill-queue";
import { integrationBackfillSourceOptions } from "@/lib/jobs/integration-backfill-admission";
import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready";
import {
clearGarminTokenCache,
exchangeGarminKey,
GARMIN_REGIONS,
GarminApiError,
type GarminRegion,
type GarminToken,
} from "@/lib/garmin/client";
import {
estimateGarminKeyExpiry,
resolveGarminKeyExpiry,
} from "@/lib/garmin/key-expiry";
import { garminConnectSchema } from "@/lib/validations/garmin";
/**
* Connect (or replace) the caller's Garmin Personal API key (#1333).
*
* The key is validated by buying a token with it before anything is stored:
* a mistyped, revoked or expired key answers here, not on the first cron
* tick. Garmin runs a separate service for accounts in China; a key the
* global service refuses is tried there once, and the region that accepted it
* is kept so the sync talks to the right host.
*
* On success the key is sealed at rest, the expiry is noted (Garmin's own
* when the token reveals it, else the validity the person picked), the
* history backfill is reopened, the `garmin` ledger is reset to connected,
* and a first sync plus the backfill are sent best-effort. The key is never
* logged, audited or echoed: the audit row carries only the region.
*
* Rate-limited 10/60 s per user: every attempt is an outbound exchange.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "garmin.connect" } });
const rl = await checkRateLimit(`garmin-connect:${user.id}`, 10, 60_000);
if (!rl.allowed) {
return apiError("Too many connection attempts", 429, {
headers: rateLimitHeaders(rl),
});
}
const parsed = await parseJsonBody(request, garminConnectSchema, {
maxBytes: 16 * 1024,
message: "A valid Garmin Personal API key is required",
});
if (!parsed.ok) return parsed.error;
const { apiKey, validityDays } = parsed.data;
let token: GarminToken | null = null;
let region: GarminRegion | null = null;
let lastError: unknown = null;
for (const candidate of GARMIN_REGIONS) {
try {
token = await exchangeGarminKey(apiKey, candidate);
region = candidate;
break;
} catch (err) {
lastError = err;
// Only a refused key is worth asking the other service about; an
// outage or a rate limit would answer the same there.
if (!(err instanceof GarminApiError && err.keyRefused)) break;
}
}
if (!token || !region) {
annotate({
meta: {
"garmin.connect.refused":
lastError instanceof GarminApiError ? lastError.reason : "network",
},
});
if (lastError instanceof GarminApiError && lastError.keyRefused) {
return apiError(
"Garmin did not accept this key. Check that it was copied whole and has not expired or been revoked.",
422,
{ errorCode: "garmin.key_refused" },
);
}
if (lastError instanceof GarminApiError && lastError.httpStatus === 429) {
return apiError(
"Garmin is limiting requests right now. Try again in a few minutes.",
422,
{ errorCode: "garmin.rate_limited" },
);
}
return apiError(
"Could not reach Garmin. Try again in a few minutes.",
422,
{ errorCode: "garmin.unreachable" },
);
}
const connectedAt = new Date();
const keyExpiresAt =
token.keyExpiresAt !== null
? new Date(token.keyExpiresAt)
: validityDays
? estimateGarminKeyExpiry(connectedAt, validityDays)
: null;
await prisma.user.update({
where: { id: user.id },
data: {
// Field by field; never the parsed body.
garminApiKeyEncrypted: encrypt(apiKey),
garminRegion: region,
garminConnectedAt: connectedAt,
garminKeyExpiresAt: keyExpiresAt,
// A new key reopens the history walk from today.
garminBackfillProgress: Prisma.DbNull,
garminBackfillCompletedAt: null,
},
});
clearGarminTokenCache(user.id);
await markReconnected(user.id, "garmin");
await auditLog("garmin.connect", { userId: user.id, details: { region } });
await enqueueWhenBossReady("garmin-sync", async (boss) => {
await boss
.send(GARMIN_SYNC_QUEUE, { userId: user.id })
.catch((err) =>
getEvent()?.addWarning(`garmin-sync connect enqueue failed: ${err}`),
);
await boss
.send(
GARMIN_BACKFILL_QUEUE,
{ userId: user.id, enqueuedAt: new Date().toISOString() },
integrationBackfillSourceOptions(`garmin-backfill|${user.id}`),
)
.catch((err) =>
getEvent()?.addWarning(
`garmin-backfill connect enqueue failed: ${err}`,
),
);
});
return apiSuccess({
connected: true,
region,
keyExpiry: resolveGarminKeyExpiry(keyExpiresAt),
});
});
+55
View File
@@ -0,0 +1,55 @@
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { markDisconnected } from "@/lib/integrations/status";
import { clearGarminTokenCache } from "@/lib/garmin/client";
/**
* Disconnect the caller's Garmin key.
*
* Clears the sealed key and everything stored about it (region, expiry,
* backfill progress) and parks the `garmin` ledger at `disconnected`. Rows
* already imported stay: disconnecting stops future syncs, it does not
* delete history. The key stays valid at Garmin until it is revoked there.
*/
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "garmin.disconnect" } });
const rl = await checkRateLimit(`garmin-disconnect:${user.id}`, 20, 60_000);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { garminApiKeyEncrypted: true },
});
if (!dbUser?.garminApiKeyEncrypted) {
return apiError("No Garmin connection", 404);
}
await prisma.user.update({
where: { id: user.id },
data: {
garminApiKeyEncrypted: null,
garminRegion: null,
garminConnectedAt: null,
garminKeyExpiresAt: null,
garminBackfillProgress: Prisma.DbNull,
garminBackfillCompletedAt: null,
},
});
clearGarminTokenCache(user.id);
await auditLog("garmin.disconnect", { userId: user.id });
await markDisconnected(user.id, "garmin");
return apiSuccess({ disconnected: true });
});
+76
View File
@@ -0,0 +1,76 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
import { getIntegrationStatus } from "@/lib/integrations/status";
import {
INTEGRATION_CADENCE,
resolveSyncVerdict,
} from "@/lib/integrations/sync-verdict";
import { resolveGarminKeyExpiry } from "@/lib/garmin/key-expiry";
import {
garminBackfillShare,
readGarminBackfillProgress,
} from "@/lib/garmin/backfill-progress";
import { isGarminRegion } from "@/lib/garmin/client";
/**
* Garmin connection status for the current user.
*
* `configured` is whether a key is stored. The ledger snapshot and the
* server-resolved health come off the shared `garmin` IntegrationKey, the
* same as every sibling. `keyExpiry` is the one Garmin-specific fact: when
* the key stops working, and whether that is close, resolved here so web and
* iOS warn alike. The key itself is never returned.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "garmin.status" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
garminApiKeyEncrypted: true,
garminRegion: true,
garminKeyExpiresAt: true,
garminBackfillProgress: true,
garminBackfillCompletedAt: true,
},
});
if (!dbUser?.garminApiKeyEncrypted) {
return apiSuccess({ connected: false, configured: false });
}
const status = await getIntegrationStatus(user.id, "garmin");
return apiSuccess({
connected: true,
configured: true,
region: isGarminRegion(dbUser.garminRegion)
? dbUser.garminRegion
: "global",
keyExpiry: resolveGarminKeyExpiry(dbUser.garminKeyExpiresAt),
backfill: {
complete: dbUser.garminBackfillCompletedAt !== null,
share: dbUser.garminBackfillCompletedAt
? 1
: garminBackfillShare(
readGarminBackfillProgress(dbUser.garminBackfillProgress),
),
},
state: status.state,
lastSuccessAt: status.lastSuccessAt,
lastAttemptAt: status.lastAttemptAt,
lastError: status.lastError,
syncHealth: resolveSyncVerdict({
connected: true,
configured: true,
state: status.state,
lastSuccessAt: status.lastSuccessAt,
lastAttemptAt: status.lastAttemptAt,
failingSinceAt: status.failingSince,
cadence: INTEGRATION_CADENCE["garmin"],
}),
health: await loadOneConnectionHealth(user.id, "garmin"),
});
});
+43
View File
@@ -0,0 +1,43 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { syncUserGarmin } from "@/lib/garmin/sync";
import { isGarminKeyRefusal } from "@/lib/garmin/client";
import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
/**
* Manually trigger a Garmin sync for the current user.
*
* Reads the recent window of every resource; the history is the backfill's.
* `imported` counts rows written this run and `outcome` says how that count
* reads. No body. The error arm answers with a fixed message: the classified
* failure is already on the `garmin` ledger.
*
* Rate-limited 5/60 s per user, like the sibling sync routes.
*/
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "garmin.sync" } });
const rl = await checkRateLimit(`garmin-sync:${user.id}`, 5, 60_000);
if (!rl.allowed) {
return apiError("Too many sync requests", 429, {
errorCode: "rate_limited_self",
});
}
try {
const result = await syncUserGarmin(user.id);
return apiSuccess(resolveSyncOutcome(result));
} catch (error) {
if (isGarminKeyRefusal(error)) {
return apiError(
"Garmin no longer accepts the stored key. Create a new one and connect again.",
422,
{ errorCode: "garmin.key_refused" },
);
}
return apiError("Garmin sync failed", 502);
}
});
+75
View File
@@ -0,0 +1,75 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { getGarminCredentials } from "@/lib/garmin/credentials";
import { exchangeGarminKey, GarminApiError } from "@/lib/garmin/client";
import { SafeFetchError } from "@/lib/safe-fetch";
export const dynamic = "force-dynamic";
/**
* Cheap live probe of the stored Garmin key: buy one token with it,
* through the same exchange the sync uses. Nothing is written; the token is
* dropped.
*/
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "garmin.test" } });
const rl = await checkRateLimit(`garmin-test:${user.id}`, 5, 60_000);
if (!rl.allowed) {
return apiError("Too many test requests", 429, {
errorCode: "rate_limited_self",
});
}
const creds = await getGarminCredentials(user.id);
if (!creds) {
return apiError("Garmin not connected", 422, {
errorCode: "not_configured",
});
}
const start = performance.now();
try {
await exchangeGarminKey(creds.apiKey, creds.region);
return apiSuccess({
ok: true,
latencyMs: Math.round(performance.now() - start),
});
} catch (e) {
if (e instanceof GarminApiError) {
const code = e.keyRefused
? "credentials_rejected"
: e.httpStatus === 429
? "rate_limited"
: e.httpStatus !== undefined && e.httpStatus >= 500
? "upstream_error"
: "connection_failed";
annotate({
meta: { garmin_test_status: e.httpStatus, garmin_test_code: code },
});
return apiError(
code === "credentials_rejected"
? "Garmin rejected the key"
: code === "rate_limited"
? "Garmin rate-limited the request"
: code === "upstream_error"
? "Garmin returned an error"
: "Garmin connection failed",
502,
{ errorCode: code },
);
}
const isTimeout = e instanceof SafeFetchError && e.kind === "timeout";
annotate({
meta: { garmin_test_code: isTimeout ? "timeout" : "connection_failed" },
});
return apiError(
isTimeout ? "Garmin request timed out" : "Garmin connection failed",
502,
{ errorCode: isTimeout ? "timeout" : "connection_failed" },
);
}
});
@@ -62,6 +62,10 @@ const METRIC_MODULE: Partial<Record<MetricStatusMetricId, ModuleKey>> = {
DAY_STRAIN: "recovery",
WORKOUT_STRAIN: "recovery",
CARDIO_LOAD: "recovery",
BODY_BATTERY: "recovery",
STRESS_LEVEL: "recovery",
TRAINING_READINESS: "recovery",
ACUTE_TRAINING_LOAD: "recovery",
AVERAGE_HEART_RATE: "recovery",
MAX_HEART_RATE: "recovery",
ENERGY_EXPENDITURE_KJ: "recovery",
@@ -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 {
@@ -87,6 +87,9 @@ export const GET = apiHandler(async () => {
// Not offered on a managed profile (no managed integrations before
// v1.44); listed so the map stays total.
dawarich: false,
// Garmin is a key the record's owner pastes on their own settings page;
// a managed profile has none to offer.
garmin: false,
};
// The one server-resolved state per connection, from the same loader the
// owner's own envelope uses, so a guardian reads the record's connections in