From 46db21dbeb004aa4b8a7d19eb6ce09c8905027f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 2 Jul 2026 09:10:38 +0200 Subject: [PATCH 1/5] fix(fitbit): correct the setup guide to the classic Fitbit Web API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Fitbit integration connects through the classic Fitbit Web API, so a self-hoster registers a Fitbit developer app at dev.fitbit.com — but the setup guide walked users through creating a Google Cloud OAuth client, whose client id the Fitbit sign-in rejects (unauthorized_client). Rewrite the guide for the dev.fitbit.com flow (app type, HTTPS callback, scopes, PKCE), leave the old page as a pointer, and add a test that keeps the documented scopes and endpoints in sync with the client so the guide cannot drift from the code again. Flags the September 2026 sunset of the classic Fitbit Web API. --- docs/integrations/fitbit.md | 99 +++++++++++++++++++ docs/integrations/google-health.md | 89 +++-------------- .../settings/integrations/fitbit-card.tsx | 2 +- .../__tests__/setup-docs-parity.test.ts | 49 +++++++++ 4 files changed, 162 insertions(+), 77 deletions(-) create mode 100644 docs/integrations/fitbit.md create mode 100644 src/lib/fitbit/__tests__/setup-docs-parity.test.ts diff --git a/docs/integrations/fitbit.md b/docs/integrations/fitbit.md new file mode 100644 index 000000000..3fbb57481 --- /dev/null +++ b/docs/integrations/fitbit.md @@ -0,0 +1,99 @@ +# Fitbit integration (Fitbit & Pixel) + +> **Experimental.** Field mapping is still being verified against live accounts, +> so some values may be missing or change in a future update. + +HealthLog reads Fitbit and Pixel Watch data through the **classic Fitbit Web +API** (`api.fitbit.com`). The OAuth app is a **Fitbit developer app registered +at [dev.fitbit.com](https://dev.fitbit.com/apps/new)** — **not** a Google Cloud +OAuth client. Like WHOOP, credentials are brought per user: each user (or the +operator on their behalf) registers their own Fitbit app and pastes the client +id/secret into Settings. The internal integration key is `fitbit`. + +> **Common mistake:** the field wants a **Fitbit** Client ID (a short numeric +> id from dev.fitbit.com), **not** a Google Cloud client id ending in +> `.apps.googleusercontent.com`. A Google Cloud client fails at the Fitbit +> consent screen with `unauthorized_client — Invalid client_id`, because Fitbit +> and Google Cloud are separate client registries. + +> **Heads-up — September 2026 sunset.** Google is retiring the classic Fitbit +> Web API in **September 2026** and moving to the Google Health API behind Google +> sign-in. That replacement currently requires Google Restricted-scope brand +> verification plus an annual third-party CASA security assessment, which does +> not fit a self-hosted bring-your-own-credentials model, so the path beyond the +> sunset is still being worked out. Until then the setup below is the supported +> way to connect Fitbit. + +## 1. Register a Fitbit developer app + +1. Sign in at and register a new application. +2. **OAuth 2.0 Application Type:** + - **Personal** — the app only ever sees the **owner's own** Fitbit account, + and gets intraday (per-minute) heart-rate/steps automatically. This is the + right choice for a single-person self-host. + - **Server** (or **Client**) — needed when the instance connects **other + people's** accounts. Intraday/heart-rate access is then granted case by + case via Fitbit's Intraday Data Access request form. +3. **Callback URL:** `https://your-instance.example.com/api/fitbit/callback` + — absolute, **HTTPS**, and matching the `redirect_uri` HealthLog sends (see + the redirect-URI note below). One entry per environment you connect from. +4. **Scopes:** tick the ones HealthLog requests (next section). +5. Save. Fitbit issues a **Client ID** (short, numeric) and a **Client Secret** + — keep the secret out of any chat log. + +## 2. Scopes + +HealthLog requests exactly these read scopes (space-separated on the wire): + +``` +activity cardio_fitness heartrate oxygen_saturation profile respiratory_rate sleep weight +``` + +The `temperature` scope is intentionally omitted — the classic skin-temperature +reading is a baseline delta rather than an absolute value, so it has no honest +canonical slot yet. The authorization uses the Authorization Code Grant **with +PKCE (S256)**, which Fitbit recommends; HealthLog mints the `code_verifier` +server-side and never exposes it to the browser. + +## 3. Wire the credentials in Settings → Integrations + +Credentials are stored per user, not in `.env`. Sign in as the target user, open +**Settings → Integrations → Fitbit**, and paste the Client ID and Client Secret. +They are encrypted AES-256-GCM at rest. + +## 4. Connect + +Click **Connect with Fitbit**. HealthLog redirects to the Fitbit consent screen +(`www.fitbit.com/oauth2/authorize`), exchanges the returned code for an access + +refresh token at `api.fitbit.com/oauth2/token`, stores them encrypted, and runs +an initial sync. There is **no webhook** — Fitbit syncs on the safety-net cron +pull. Classic Fitbit refresh tokens rotate (one-time use); HealthLog replaces the +stored token on every refresh. + +## Redirect URI is validated strictly + +The resolved `redirect_uri` is asserted at connect time to be absolute, `https` +(or `http` on localhost), to target exactly `/api/fitbit/callback`, and — when +`FITBIT_REDIRECT_URI` is set alongside `NEXT_PUBLIC_APP_URL` — to share its +origin. A misconfigured value fails closed rather than redirecting elsewhere. + +## Optional instance env var + +```env +# Only when the callback origin differs from NEXT_PUBLIC_APP_URL: +FITBIT_REDIRECT_URI="https://your-instance.example.com/api/fitbit/callback" +``` + +The Fitbit client id/secret are per-user (Settings), not env vars. + +## Source overlap + +If Fitbit and another source (Withings, WHOOP, Apple Health) both supply the same +vital — resting heart rate, blood oxygen, body temperature or respiratory rate — +you may see both values until a future update settles on a single preferred +source. + +## Disconnect + +The Settings **Disconnect** button revokes the stored tokens and removes the +connection. Previously synced measurements stay in the database. diff --git a/docs/integrations/google-health.md b/docs/integrations/google-health.md index 98d96db88..48a36173c 100644 --- a/docs/integrations/google-health.md +++ b/docs/integrations/google-health.md @@ -1,79 +1,16 @@ -# Google Health integration (Fitbit & Pixel) +# Google Health / Fitbit integration -> **Beta.** Field mapping is still being verified against live Google Health -> accounts, so some values may be missing or change in a future update. +> **This page moved.** HealthLog connects Fitbit and Pixel Watch data through the +> classic **Fitbit Web API** (an app registered at +> [dev.fitbit.com](https://dev.fitbit.com/apps/new)), **not** through a Google +> Cloud OAuth client. If you were told to create a Google Cloud client and hit +> `unauthorized_client — Invalid client_id`, that is why. -HealthLog reads Fitbit and Pixel Watch data through the **Google Health API**. -The OAuth app is therefore a **Google Cloud** OAuth client — not a Fitbit -developer-console app. Like WHOOP, credentials are brought per user: each user -registers their own Google Cloud OAuth client and pastes the client id/secret -into Settings. The internal integration key is `fitbit`. +See **[Fitbit integration (Fitbit & Pixel)](./fitbit.md)** for the correct setup. -## 1. Create a Google Cloud OAuth client - -1. In create (or pick) a project and, under - **APIs & Services**, enable the **Google Health API**. -2. Configure the OAuth consent screen for the project. -3. Under **APIs & Services → Credentials**, create an **OAuth 2.0 Client ID** of - type **Web application**. -4. **Authorized redirect URI:** - `https://your-instance.example.com/api/fitbit/callback` -5. Save. Google issues a **Client ID** and **Client Secret** — keep the secret - out of any chat log. - -## 2. Scopes - -HealthLog requests exactly these read-only scopes: - -``` -https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly -https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly -https://www.googleapis.com/auth/googlehealth.sleep.readonly -https://www.googleapis.com/auth/googlehealth.profile.readonly -``` - -Location and intraday scopes are deliberately omitted to keep the -Restricted-scope review surface minimal. The authorize request sends -`access_type=offline` + `prompt=consent` — Google's requirement to issue a -refresh token (the equivalent of WHOOP's `offline` scope). - -## 3. Wire the credentials in Settings → Integrations - -Credentials are stored per user, not in `.env`. Sign in as the target user, open -**Settings → Integrations → Google Health**, and paste the client ID and secret. -They are encrypted AES-256-GCM at rest. - -## 4. Connect - -Click **Connect with Google Health**. HealthLog redirects to Google's OAuth -consent screen, exchanges the returned code for an access + refresh token, stores -them encrypted, and runs an initial sync. There is **no webhook** — Google Health -syncs on the safety-net cron pull. - -## Redirect URI is validated strictly - -The resolved `redirect_uri` is asserted at connect time to be absolute, `https` -(or `http` on localhost), to target exactly `/api/fitbit/callback`, and — when -`FITBIT_REDIRECT_URI` is set alongside `NEXT_PUBLIC_APP_URL` — to share its -origin. A misconfigured value fails closed rather than redirecting elsewhere. - -## Optional instance env var - -```env -# Only when the callback origin differs from NEXT_PUBLIC_APP_URL: -FITBIT_REDIRECT_URI="https://your-instance.example.com/api/fitbit/callback" -``` - -The Google Cloud client id/secret are per-user (Settings), not env vars. - -## Source overlap - -If Google Health and another source (Withings, WHOOP, Apple Health) both supply -the same vital — resting heart rate, blood oxygen, body temperature or -respiratory rate — you may see both values until a future update settles on a -single preferred source. - -## Disconnect - -The Settings **Disconnect** button revokes the stored tokens and removes the -connection. Previously synced measurements stay in the database. +Background: Google is retiring the classic Fitbit Web API in September 2026 in +favour of the Google Health API behind Google sign-in. That replacement requires +Restricted-scope brand verification plus an annual CASA security assessment, +which does not currently fit a self-hosted bring-your-own-credentials model, so +until the sunset the Fitbit developer-app path documented above is the supported +way to connect. diff --git a/src/components/settings/integrations/fitbit-card.tsx b/src/components/settings/integrations/fitbit-card.tsx index c51bd675a..529f5eed7 100644 --- a/src/components/settings/integrations/fitbit-card.tsx +++ b/src/components/settings/integrations/fitbit-card.tsx @@ -1,6 +1,6 @@ "use client"; -// v1.12.0 — Google Health (Fitbit & Pixel) card. Mirrors the WHOOP card: a +// v1.12.0 — Fitbit (Fitbit & Pixel) card. Mirrors the WHOOP card: a // BYO-key credentials form first, then an OAuth connect, then the // sync/test/disconnect action row + parked-resume banner. v1.12.1 — status // reads off the consolidated /api/integrations/status envelope (the per-card diff --git a/src/lib/fitbit/__tests__/setup-docs-parity.test.ts b/src/lib/fitbit/__tests__/setup-docs-parity.test.ts new file mode 100644 index 000000000..d057402fa --- /dev/null +++ b/src/lib/fitbit/__tests__/setup-docs-parity.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { FITBIT_OAUTH_SCOPE } from "../client"; + +/** + * Guard against the class of bug reported in #396: the setup docs drifting away + * from what the code actually does. The Fitbit integration uses the CLASSIC + * Fitbit Web API (an app from dev.fitbit.com), not a Google Cloud OAuth client — + * a doc that says otherwise sends users to the wrong registry and fails with + * `unauthorized_client — Invalid client_id`. + */ +const readDoc = (name: string): string => + readFileSync(join(process.cwd(), "docs", "integrations", name), "utf8"); + +describe("Fitbit setup docs stay in sync with the code", () => { + const fitbitDoc = readDoc("fitbit.md"); + + it("documents every OAuth scope the client actually requests", () => { + for (const scope of FITBIT_OAUTH_SCOPE.split(" ")) { + expect(fitbitDoc).toContain(scope); + } + }); + + it("points at the classic Fitbit endpoints and dev.fitbit.com", () => { + expect(fitbitDoc).toContain("dev.fitbit.com"); + expect(fitbitDoc).toContain("www.fitbit.com/oauth2/authorize"); + expect(fitbitDoc).toContain("api.fitbit.com/oauth2/token"); + expect(fitbitDoc).toContain("/api/fitbit/callback"); + }); + + it("never instructs the user to create a Google Cloud OAuth client (the #396 mistake)", () => { + // The "do this" markers of the wrong walkthrough: the Google Cloud console + // and the Google Health scope URLs. Mentioning `apps.googleusercontent.com` + // as a "don't paste this" warning is allowed and intentional. + expect(fitbitDoc).not.toContain("console.cloud.google"); + expect(fitbitDoc).not.toContain("googleapis.com/auth/googlehealth"); + }); + + it("keeps the old google-health page as a pointer to the Fitbit setup", () => { + const legacy = readDoc("google-health.md"); + expect(legacy).toContain("fitbit.md"); + // The legacy page must not re-introduce the wrong Google Cloud walkthrough. + expect(legacy).not.toContain("console.cloud.google"); + expect(legacy).not.toContain("googleapis.com/auth/googlehealth"); + }); +}); From 3fbd7428e587b62377ef5a2913c551c6c9d43c4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 2 Jul 2026 09:10:38 +0200 Subject: [PATCH 2/5] =?UTF-8?q?chore(release):=20v1.26.1=20=E2=80=94=20Fit?= =?UTF-8?q?bit=20setup=20guide=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 +++++++- docs/api/openapi.yaml | 2 +- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d2bd0b42..eccb735df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,13 @@ ## [Unreleased] -## [1.26.0] — 2026-07-02 — Light theme and a consistent interface +## [1.26.1] — 2026-07-02 — Fitbit setup guide fix + +### Fixed + +- The Fitbit (Fitbit & Pixel) setup guide described the wrong OAuth app. HealthLog connects through the classic Fitbit Web API, so you register a **Fitbit developer app at dev.fitbit.com** and paste that Client ID/Secret — but the guide walked you through creating a **Google Cloud** OAuth client instead, which the Fitbit sign-in rejects with `unauthorized_client — Invalid client_id`. The guide now describes the correct dev.fitbit.com setup (app type, HTTPS callback, scopes), and flags that Google is retiring the classic Fitbit Web API in September 2026. + + ### Changed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 7192ae660..c3a2880cc 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.26.0 + version: 1.26.1 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 0a27d02e6..637b934b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.26.0", + "version": "1.26.1", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", From 160ab6c6b599460ef42b6d2d8403f8600025449d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 4 Jul 2026 14:25:34 +0200 Subject: [PATCH 3/5] style(changelog): remove stray blank lines --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eccb735df..832d23d44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,6 @@ - The Fitbit (Fitbit & Pixel) setup guide described the wrong OAuth app. HealthLog connects through the classic Fitbit Web API, so you register a **Fitbit developer app at dev.fitbit.com** and paste that Client ID/Secret — but the guide walked you through creating a **Google Cloud** OAuth client instead, which the Fitbit sign-in rejects with `unauthorized_client — Invalid client_id`. The guide now describes the correct dev.fitbit.com setup (app type, HTTPS callback, scopes), and flags that Google is retiring the classic Fitbit Web API in September 2026. - - ### Changed - The light theme is calmer. Text now sits on a soft off-white instead of pure white, the near-black body colour is lifted to a dark tinted grey, cards separate from the background by a gentle step rather than a hard edge, and charts and status colours draw from the theme's own palette — so nothing glares and light mode reads as one harmonious whole. The dark theme is unchanged. From f95c0230a3dffab938220b2bd82b907e5f6b6d79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 4 Jul 2026 14:28:34 +0200 Subject: [PATCH 4/5] docs(changelog): restore the 1.26.0 heading above its entries --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 832d23d44..e308b49c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - The Fitbit (Fitbit & Pixel) setup guide described the wrong OAuth app. HealthLog connects through the classic Fitbit Web API, so you register a **Fitbit developer app at dev.fitbit.com** and paste that Client ID/Secret — but the guide walked you through creating a **Google Cloud** OAuth client instead, which the Fitbit sign-in rejects with `unauthorized_client — Invalid client_id`. The guide now describes the correct dev.fitbit.com setup (app type, HTTPS callback, scopes), and flags that Google is retiring the classic Fitbit Web API in September 2026. +## [1.26.0] — 2026-07-02 — Light theme and a consistent interface + ### Changed - The light theme is calmer. Text now sits on a soft off-white instead of pure white, the near-black body colour is lifted to a dark tinted grey, cards separate from the background by a gentle step rather than a hard edge, and charts and status colours draw from the theme's own palette — so nothing glares and light mode reads as one harmonious whole. The dark theme is unchanged. From b8794dd4c763650c4a8b80e0adb285070ef1c7a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 4 Jul 2026 14:46:19 +0200 Subject: [PATCH 5/5] test(integration): anchor the reconciled sleep night inside the 30-day window The midnight-spanning dual-source case seeded fixed calendar dates; once they aged past the route's trailing 30-day window the assertion went red on every branch. Anchor the night relative to now, like the first case in the file already does. --- .../analytics-sleep-stages.test.ts | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/tests/integration/analytics-sleep-stages.test.ts b/tests/integration/analytics-sleep-stages.test.ts index 2b8fdd13d..a944b591c 100644 --- a/tests/integration/analytics-sleep-stages.test.ts +++ b/tests/integration/analytics-sleep-stages.test.ts @@ -204,9 +204,16 @@ describe("GET /api/analytics — sleep-stage aggregation", () => { const prisma = getPrismaClient(); const user = await seedSession("sleep-reconcile-user"); - // Contiguous overnight: 22:30 → 06:15 local (Berlin, UTC+2 in June), - // stage ends straddling UTC midnight. WHOOP + Apple Health both report - // it; WHOOP wins the default ladder. + // Contiguous overnight, stage rows straddling a UTC midnight + // (≈23:30 → 06:15 Berlin). WHOOP + Apple Health both report it; + // WHOOP wins the default ladder. Anchor the night ~6 days back so it + // stays inside the route's trailing 30-day window as the calendar + // advances — fixed calendar dates seeded here expire out of the + // window and the test starts failing a month later. + const midnight = new Date(Date.now() - 6 * 24 * 60 * 60 * 1000); + midnight.setUTCHours(0, 0, 0, 0); + const at = (offsetMin: number) => + new Date(midnight.getTime() + offsetMin * 60_000).toISOString(); const seed = async ( iso: string, stage: "CORE" | "DEEP" | "REM", @@ -227,13 +234,13 @@ describe("GET /api/analytics — sleep-stage aggregation", () => { }); }; // WHOOP — the canonical source. CORE 60 + DEEP 90 + REM 120 + CORE 195. - await seed("2026-06-03T21:30:00.000Z", "CORE", 60, "WHOOP"); - await seed("2026-06-03T23:00:00.000Z", "DEEP", 90, "WHOOP"); - await seed("2026-06-04T01:00:00.000Z", "REM", 120, "WHOOP"); - await seed("2026-06-04T04:15:00.000Z", "CORE", 195, "WHOOP"); + await seed(at(-150), "CORE", 60, "WHOOP"); // 21:30 UTC, day before + await seed(at(-60), "DEEP", 90, "WHOOP"); // 23:00 UTC, day before + await seed(at(60), "REM", 120, "WHOOP"); // 01:00 UTC + await seed(at(255), "CORE", 195, "WHOOP"); // 04:15 UTC // Apple Health parallel rows for the SAME night — must be dropped. - await seed("2026-06-03T21:35:00.000Z", "CORE", 55, "APPLE_HEALTH"); - await seed("2026-06-04T01:05:00.000Z", "REM", 110, "APPLE_HEALTH"); + await seed(at(-145), "CORE", 55, "APPLE_HEALTH"); // 21:35 UTC, day before + await seed(at(65), "REM", 110, "APPLE_HEALTH"); // 01:05 UTC const { GET } = await import("@/app/api/analytics/route"); const response = await (