diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d2bd0b42..e308b49c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [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. + ## [1.26.0] — 2026-07-02 — Light theme and a consistent interface ### 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/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/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", 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"); + }); +}); 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 (