mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #399 from MBombeck/fix/fitbit-setup-docs
fix(fitbit): correct the setup guide to the classic Fitbit Web API (#396)
This commit is contained in:
8 files changed
+186
-88
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 <https://dev.fitbit.com/apps/new> 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.
|
||||
@@ -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 <https://console.cloud.google.com> 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.
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user