feat(fitbit): stop at the Web API turndown and point to Google Health

Google turns the legacy Fitbit Web API off on 30 October 2026 (support
ended on 30 September). From that day the hourly poll, the backfill,
the sleep repair and the token refresh make no call, the connect, sync
and test routes refuse with `provider_retired`, and the connection
reads as not connected to the health read and the watchdog, so the
shutdown pages nobody and asks nobody to reconnect. The connection row
and everything it imported stay.

The status envelope publishes `retirement` on the Fitbit entry (the
day, whether it has come, the successor), and the Fitbit card shows
when the connection stops, how to move to Google Health (same devices,
one more consent, tokens cannot be carried over) and that the data
stays; sync and connect grey out after the day. Both integrations can
run side by side meanwhile: every type Fitbit writes is laddered with
Fitbit just above Google Health, so a day both deliver counts once.

The test setups pin the turndown off, so the Fitbit suites do not turn
red on the day; the turndown's own tests flip it.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 12:46:14 +02:00
1 parent 1fc0268b3d
commit ba1271275f
41 files changed
+920 -39

No files matched your search

+42 -5
View File
@@ -20339,6 +20339,13 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"401": *a1
"410":
description: Google turned the legacy Fitbit Web API off on 2026-10-30; nothing is fetched. `meta.errorCode` =
`provider_retired`. The data already imported stays; Google Health reads the same devices.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"413":
description: Body exceeds 64 KiB.
content:
@@ -20953,6 +20960,13 @@ paths:
schema:
$ref: "#/components/schemas/FitbitIntegrationTestEnvelope"
"401": *a1
"410":
description: Google turned the legacy Fitbit Web API off on 2026-10-30; nothing is probed. `meta.errorCode` =
`provider_retired`.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a49
"429": *a50
"502": *a51
@@ -36298,8 +36312,8 @@ components:
health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at,
json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns,
modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only,
private_origin_not_approved, private_origin_not_grantable, rate_limited_self, sync_in_progress,
too_many_rows, unreadable_json, upstream_invalid_json, vapid_not_configured"
private_origin_not_approved, private_origin_not_grantable, provider_retired, rate_limited_self,
sync_in_progress, too_many_rows, unreadable_json, upstream_invalid_json, vapid_not_configured"
type: string
capability:
description: "On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`,
@@ -79322,13 +79336,36 @@ components:
allowPrivateHost:
description: Nightscout only.
type: boolean
retirement:
description: Fitbit only. The end of the legacy Fitbit Web API.
type: object
properties:
endsOn:
type: string
pattern: ^\d{4}-\d{2}-\d{2}$
description: The day Google turns the legacy Fitbit Web API off.
ended:
type: boolean
description: "The day has come: the connection no longer syncs, its imported data stays, and `health` is null."
successor:
type: string
enum:
- google-health
description: The integration that reads the same Fitbit and Pixel devices. Moving needs a fresh consent; the Fitbit
grant cannot be carried over.
required:
- endsOn
- ended
- successor
additionalProperties: false
health:
anyOf:
- $ref: "#/components/schemas/ConnectionHealth"
- type: "null"
description: The unified connection state, null when the provider is not connected. It supersedes `state`, `lastError`,
`consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`, `hasActivityScope`,
`planUnavailable` and `scopeReconnect` on this entry; those nine are deprecated and removed in v1.44.
description: The unified connection state, null when the provider is not connected (and for Fitbit once
`retirement.ended`). It supersedes `state`, `lastError`, `consecutiveFailuresByKind`, `syncHealth`,
`tokenExpired`, `needsReauth`, `hasActivityScope`, `planUnavailable` and `scopeReconnect` on this entry;
those nine are deprecated and removed in v1.44.
required:
- integration
- state
+18 -8
View File
@@ -16,14 +16,24 @@ Settings. The internal integration key is `fitbit`.
> consent screen with `unauthorized_client — Invalid client_id`, because Fitbit
> and Google Cloud are separate client registries.
> **Heads-up — new registrations are closed.** Fitbit has **discontinued
> self-serve app registration**: the form at `dev.fitbit.com/apps/new` is
> disabled for new applications, and the classic Fitbit Web API sunsets in
> **September 2026**. If you already hold a registered Fitbit app, it keeps
> working until the sunset and the setup below still applies. If you are
> connecting Fitbit data for the first time, use the
> [Google Health integration](google-health.md) instead — Fitbit devices sync
> into Google's health stack, which HealthLog reads directly.
> **The classic Fitbit Web API is turned off on 30 October 2026.** Google
> ended support for it on 30 September 2026 and turns it off on
> **30 October 2026**, after which it "will no longer function or sync data"
> ([Google's migration guide](https://developers.google.com/health/migration)).
> New app registrations at `dev.fitbit.com/apps/new` are already closed.
>
> From that day HealthLog stops calling the Web API for every Fitbit
> connection: no hourly sync, no backfill, no token refresh, and no new
> connection. Nothing is deleted. The connection and every reading it imported
> stay, and the Fitbit card explains the move instead of asking for a
> reconnect. To keep Fitbit and Pixel data coming, connect the
> [Google Health integration](google-health.md). It reads the same devices,
> but Google asks for consent once more: Fitbit tokens cannot be carried over.
>
> Both integrations can run side by side while you move. Where both deliver the
> same day, the source-priority ladder keeps one of them per day (Fitbit ranks
> just above Google Health by default), so steps, sleep and the other daily
> values are not counted twice. After the turndown only Google Health delivers.
## 1. Use an existing Fitbit developer app
+6 -3
View File
@@ -2,9 +2,12 @@
HealthLog reads Fitbit, Pixel Watch and Fitbit Air data through the **Google
Health API** (`health.googleapis.com/v4`), the successor to the classic Fitbit
Web API. Google retires the classic Fitbit Web API in **September 2026**, and
Fitbit access/refresh tokens do not carry over — every account re-consents
through Google OAuth. This integration is that path.
Web API. Google ended support for the classic Fitbit Web API on 30 September
2026 and turns it off on **30 October 2026**
([migration guide](https://developers.google.com/health/migration)); from that
day HealthLog's `fitbit` connection stops syncing and keeps what it imported.
Fitbit access/refresh tokens do not carry over, so every account consents once
more through Google OAuth. This integration is that path.
The OAuth app is a **Google Cloud** OAuth client, not a Fitbit developer-console
app. Credentials are brought **per user**: each user registers their own Google
+11 -1
View File
@@ -6619,7 +6619,8 @@
"nocode": "Fitbit hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Fitbit-Dialog.",
"nocreds": "Fitbit ist nicht konfiguriert. Hinterlege oben deine Fitbit-OAuth-Zugangsdaten und versuche es erneut.",
"token": "Der Token-Austausch mit Fitbit ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut.",
"retired": "Google hat die Fitbit-Web-API abgeschaltet, deshalb lässt sich keine neue Fitbit-Verbindung mehr herstellen. Verbinde stattdessen Google Health, das dieselben Geräte liest."
},
"integrations": {
"withings": {
@@ -7439,6 +7440,15 @@
"homePlaceUnknown": "Diesen Ort kennt die Dawarich-Instanz nicht mehr.",
"notConnected": "Dawarich ist nicht verbunden.",
"generic": "Das hat nicht geklappt. Versuche es erneut."
},
"fitbitRetirement": {
"ending": "Google schaltet die Fitbit-Web-API am {date} ab. Ab dann synchronisiert diese Verbindung nicht mehr.",
"ended": "Google hat die Fitbit-Web-API am {date} abgeschaltet. Diese Verbindung synchronisiert nicht mehr.",
"move": "Google Health liest dieselben Fitbit- und Pixel-Geräte. Verbinde es unten, damit deine Daten weiter ankommen. Google fragt dich noch einmal nach deiner Zustimmung, weil sich die Fitbit-Verbindung nicht übertragen lässt. Alles bereits Importierte bleibt erhalten.",
"toCard": "Zu Google Health",
"guide": "So verbindest du Google Health",
"badgeEnding": "Wird abgeschaltet",
"badgeEnded": "Abgeschaltet"
}
},
"admin": {
+11 -1
View File
@@ -6619,7 +6619,8 @@
"nocode": "Fitbit did not return an authorization code. Try again and allow access in the Fitbit dialog.",
"nocreds": "Fitbit is not configured. Add your Fitbit OAuth credentials above and try again.",
"token": "The token exchange with Fitbit failed. Please start the connection again.",
"generic": "Unknown error while connecting. Please start the connection again."
"generic": "Unknown error while connecting. Please start the connection again.",
"retired": "Google has turned the Fitbit Web API off, so no new Fitbit connection can be made. Connect Google Health instead, which reads the same devices."
},
"integrations": {
"withings": {
@@ -7439,6 +7440,15 @@
"homePlaceUnknown": "The Dawarich instance no longer knows this place.",
"notConnected": "Dawarich is not connected.",
"generic": "That did not work. Try again."
},
"fitbitRetirement": {
"ending": "Google turns the Fitbit Web API off on {date}. From then on this connection stops syncing.",
"ended": "Google turned the Fitbit Web API off on {date}. This connection no longer syncs.",
"move": "Google Health reads the same Fitbit and Pixel devices. Connect it below to keep your data coming. Google asks for your consent once more, because the Fitbit connection cannot be carried over. Everything already imported stays.",
"toCard": "Go to Google Health",
"guide": "How to connect Google Health",
"badgeEnding": "Shutting down",
"badgeEnded": "Retired"
}
},
"admin": {
+11 -1
View File
@@ -6619,7 +6619,8 @@
"nocode": "Fitbit no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Fitbit.",
"nocreds": "Fitbit no está configurado. Añade tus credenciales OAuth de Fitbit arriba e inténtalo de nuevo.",
"token": "El intercambio de token con Fitbit falló. Inicia la conexión de nuevo.",
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo.",
"retired": "Google desactivó la API web de Fitbit, así que ya no se puede crear una nueva conexión de Fitbit. Conecta Google Health, que lee los mismos dispositivos."
},
"integrations": {
"withings": {
@@ -7439,6 +7440,15 @@
"homePlaceUnknown": "La instancia de Dawarich ya no conoce este lugar.",
"notConnected": "Dawarich no está conectado.",
"generic": "No ha funcionado. Inténtalo de nuevo."
},
"fitbitRetirement": {
"ending": "Google desactivará la API web de Fitbit el {date}. A partir de entonces, esta conexión dejará de sincronizar.",
"ended": "Google desactivó la API web de Fitbit el {date}. Esta conexión ya no sincroniza.",
"move": "Google Health lee los mismos dispositivos Fitbit y Pixel. Conéctalo abajo para que tus datos sigan llegando. Google te pedirá tu consentimiento otra vez, porque la conexión de Fitbit no se puede transferir. Todo lo ya importado se conserva.",
"toCard": "Ir a Google Health",
"guide": "Cómo conectar Google Health",
"badgeEnding": "Se desactiva pronto",
"badgeEnded": "Desactivado"
}
},
"admin": {
+11 -1
View File
@@ -6619,7 +6619,8 @@
"nocode": "Fitbit n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Fitbit.",
"nocreds": "Fitbit n'est pas configuré. Ajoutez vos identifiants OAuth Fitbit ci-dessus et réessayez.",
"token": "L'échange de jeton avec Fitbit a échoué. Veuillez relancer la connexion.",
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion.",
"retired": "Google a désactivé l'API Web Fitbit : il n'est plus possible de créer une nouvelle connexion Fitbit. Connectez plutôt Google Health, qui lit les mêmes appareils."
},
"integrations": {
"withings": {
@@ -7439,6 +7440,15 @@
"homePlaceUnknown": "L’instance Dawarich ne connaît plus ce lieu.",
"notConnected": "Dawarich n’est pas connecté.",
"generic": "Ça n’a pas marché. Réessaie."
},
"fitbitRetirement": {
"ending": "Google désactive l'API Web Fitbit le {date}. Ensuite, cette connexion ne se synchronisera plus.",
"ended": "Google a désactivé l'API Web Fitbit le {date}. Cette connexion ne se synchronise plus.",
"move": "Google Health lit les mêmes appareils Fitbit et Pixel. Connectez-le ci-dessous pour continuer à recevoir vos données. Google vous demandera de nouveau votre consentement, car la connexion Fitbit ne peut pas être transférée. Tout ce qui a déjà été importé est conservé.",
"toCard": "Aller à Google Health",
"guide": "Comment connecter Google Health",
"badgeEnding": "Bientôt désactivé",
"badgeEnded": "Désactivé"
}
},
"admin": {
+11 -1
View File
@@ -6619,7 +6619,8 @@
"nocode": "Fitbit non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di Fitbit.",
"nocreds": "Fitbit non è configurato. Aggiungi le tue credenziali OAuth di Fitbit qui sopra e riprova.",
"token": "Lo scambio del token con Fitbit è fallito. Riavvia la connessione.",
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione.",
"retired": "Google ha disattivato l'API web di Fitbit, quindi non è più possibile creare una nuova connessione Fitbit. Collega invece Google Health, che legge gli stessi dispositivi."
},
"integrations": {
"withings": {
@@ -7439,6 +7440,15 @@
"homePlaceUnknown": "L’istanza Dawarich non conosce più questo luogo.",
"notConnected": "Dawarich non è collegato.",
"generic": "Non ha funzionato. Riprova."
},
"fitbitRetirement": {
"ending": "Google disattiva l'API web di Fitbit il {date}. Da quel momento questa connessione smette di sincronizzarsi.",
"ended": "Google ha disattivato l'API web di Fitbit il {date}. Questa connessione non si sincronizza più.",
"move": "Google Health legge gli stessi dispositivi Fitbit e Pixel. Collegalo qui sotto per continuare a ricevere i tuoi dati. Google ti chiederà di nuovo il consenso, perché la connessione Fitbit non può essere trasferita. Tutto ciò che è già stato importato resta.",
"toCard": "Vai a Google Health",
"guide": "Come collegare Google Health",
"badgeEnding": "In dismissione",
"badgeEnded": "Dismesso"
}
},
"admin": {
+11 -1
View File
@@ -6619,7 +6619,8 @@
"nocode": "Fitbit이 인증 코드를 보내지 않았어요. 다시 시도하고 Fitbit 창에서 접근을 허용해 주세요.",
"nocreds": "Fitbit이 설정되지 않았어요. 위에 Fitbit OAuth 자격 증명을 추가하고 다시 시도해 주세요.",
"token": "Fitbit과의 토큰 교환에 실패했어요. 연결을 다시 시작해 주세요.",
"generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요."
"generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요.",
"retired": "Google이 Fitbit Web API를 종료해서 새 Fitbit 연결을 만들 수 없어요. 같은 기기의 데이터를 읽는 Google Health를 대신 연결해 주세요."
},
"integrations": {
"withings": {
@@ -7439,6 +7440,15 @@
"homePlaceUnknown": "Dawarich 인스턴스에 이 장소가 더 이상 없습니다.",
"notConnected": "Dawarich가 연결되어 있지 않습니다.",
"generic": "실패했습니다. 다시 시도하세요."
},
"fitbitRetirement": {
"ending": "Google이 {date}에 Fitbit Web API를 종료해요. 그 이후로 이 연결은 더 이상 동기화되지 않아요.",
"ended": "Google이 {date}에 Fitbit Web API를 종료했어요. 이 연결은 더 이상 동기화되지 않아요.",
"move": "Google Health는 같은 Fitbit 및 Pixel 기기의 데이터를 읽어요. 데이터를 계속 받으려면 아래에서 연결해 주세요. Fitbit 연결은 옮길 수 없어서 Google이 동의를 한 번 더 요청해요. 이미 가져온 데이터는 모두 그대로 남아요.",
"toCard": "Google Health로 이동",
"guide": "Google Health 연결 방법",
"badgeEnding": "종료 예정",
"badgeEnded": "종료됨"
}
},
"admin": {
+11 -1
View File
@@ -6619,7 +6619,8 @@
"nocode": "Fitbit heeft geen autorisatiecode teruggestuurd. Probeer het opnieuw en sta toegang toe in het Fitbit-venster.",
"nocreds": "Fitbit is niet geconfigureerd. Voeg hierboven je Fitbit-OAuth-inloggegevens toe en probeer het opnieuw.",
"token": "De tokenuitwisseling met Fitbit is mislukt. Start de koppeling opnieuw.",
"generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw."
"generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw.",
"retired": "Google heeft de Fitbit Web API uitgeschakeld, dus er kan geen nieuwe Fitbit-koppeling meer worden gemaakt. Koppel in plaats daarvan Google Health, dat dezelfde apparaten leest."
},
"integrations": {
"withings": {
@@ -7439,6 +7440,15 @@
"homePlaceUnknown": "De Dawarich-instantie kent deze plaats niet meer.",
"notConnected": "Dawarich is niet gekoppeld.",
"generic": "Dat is niet gelukt. Probeer het opnieuw."
},
"fitbitRetirement": {
"ending": "Google schakelt de Fitbit Web API uit op {date}. Vanaf dan synchroniseert deze koppeling niet meer.",
"ended": "Google heeft de Fitbit Web API uitgeschakeld op {date}. Deze koppeling synchroniseert niet meer.",
"move": "Google Health leest dezelfde Fitbit- en Pixel-apparaten. Koppel het hieronder om je gegevens te blijven ontvangen. Google vraagt nog een keer om je toestemming, omdat de Fitbit-koppeling niet kan worden overgezet. Alles wat al is geïmporteerd blijft bewaard.",
"toCard": "Naar Google Health",
"guide": "Zo koppel je Google Health",
"badgeEnding": "Wordt uitgeschakeld",
"badgeEnded": "Uitgeschakeld"
}
},
"admin": {
+11 -1
View File
@@ -6619,7 +6619,8 @@
"nocode": "Fitbit nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Fitbit.",
"nocreds": "Fitbit nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth Fitbit i spróbuj ponownie.",
"token": "Wymiana tokena z Fitbit nie powiodła się. Rozpocznij połączenie ponownie.",
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie.",
"retired": "Google wyłączył Fitbit Web API, więc nie można już utworzyć nowego połączenia Fitbit. Połącz zamiast tego Google Health, które odczytuje dane z tych samych urządzeń."
},
"integrations": {
"withings": {
@@ -7439,6 +7440,15 @@
"homePlaceUnknown": "Instancja Dawarich nie zna już tego miejsca.",
"notConnected": "Dawarich nie jest połączony.",
"generic": "Nie udało się. Spróbuj ponownie."
},
"fitbitRetirement": {
"ending": "Google wyłączy Fitbit Web API dnia {date}. Od tego dnia to połączenie przestanie się synchronizować.",
"ended": "Google wyłączył Fitbit Web API dnia {date}. To połączenie już się nie synchronizuje.",
"move": "Google Health odczytuje dane z tych samych urządzeń Fitbit i Pixel. Połącz je poniżej, aby dane nadal napływały. Google poprosi o zgodę jeszcze raz, ponieważ połączenia Fitbit nie da się przenieść. Wszystko, co już zaimportowano, zostaje.",
"toCard": "Przejdź do Google Health",
"guide": "Jak połączyć Google Health",
"badgeEnding": "Wkrótce wyłączone",
"badgeEnded": "Wyłączone"
}
},
"admin": {
@@ -56,6 +56,7 @@ import { GET } from "../route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { getUserFitbitCredentials } from "@/lib/fitbit/credentials";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
function connectRequest(): NextRequest {
return new NextRequest("http://localhost/api/fitbit/connect", {
@@ -112,4 +113,16 @@ describe("GET /api/fitbit/connect", () => {
expect(location).toContain("reason=nocreds");
expect(prisma.fitbitOAuthState.create).not.toHaveBeenCalled();
});
it("redirects to reason=retired once Google has turned the Web API off", async () => {
vi.mocked(isFitbitRetired).mockReturnValue(true);
try {
const response = await GET(connectRequest());
const location = response.headers.get("location") ?? "";
expect(location).toContain("fitbit=error");
expect(location).toContain("reason=retired");
expect(prisma.fitbitOAuthState.create).not.toHaveBeenCalled();
} finally {
vi.mocked(isFitbitRetired).mockReturnValue(false);
}
});
});
+10
View File
@@ -12,6 +12,7 @@ import {
import { NextRequest, NextResponse } from "next/server";
import { relativeRedirect } from "@/lib/http/relative-redirect";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
/**
* Redirect the user to the Fitbit OAuth consent page (classic
@@ -57,6 +58,15 @@ export const GET = apiHandler(async (req: NextRequest) => {
);
}
// A grant issued now would never sync anything: Google has turned the Web
// API off. The card explains the move to Google Health.
if (isFitbitRetired()) {
annotate({ action: { name: "fitbit.connect.retired" } });
return relativeRedirect(
"/settings/integrations?fitbit=error&reason=retired",
);
}
const creds = await getUserFitbitCredentials(user.id);
if (!creds) {
annotate({ action: { name: "fitbit.connect.no_credentials" } });
@@ -0,0 +1,68 @@
/**
* POST /api/fitbit/sync once Google has turned the Fitbit Web API off: a
* clear 410 naming the cause, and no sync attempt behind it.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import type * as ApiHandlerModule from "@/lib/api-handler";
const { syncUserFitbitMock } = vi.hoisted(() => ({
syncUserFitbitMock: vi.fn(),
}));
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof ApiHandlerModule>("@/lib/api-handler");
return {
...actual,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
};
});
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/fitbit/sync", () => ({ syncUserFitbit: syncUserFitbitMock }));
import { POST } from "../route";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
const post = POST as unknown as (r: NextRequest) => Promise<Response>;
function request(): NextRequest {
return new NextRequest("http://localhost/api/fitbit/sync", {
method: "POST",
});
}
beforeEach(() => {
vi.clearAllMocks();
syncUserFitbitMock.mockResolvedValue({ imported: 2, failed: false });
});
describe("POST /api/fitbit/sync", () => {
it("syncs while the Web API is still up", async () => {
const response = await post(request());
expect(response.status).toBe(200);
expect(syncUserFitbitMock).toHaveBeenCalledWith("u1", {
fullSync: false,
});
});
it("answers 410 provider_retired after the turndown and syncs nothing", async () => {
vi.mocked(isFitbitRetired).mockReturnValue(true);
try {
const response = await post(request());
expect(response.status).toBe(410);
const body = (await response.json()) as {
error: string;
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("provider_retired");
expect(body.error).toMatch(/Google Health/);
expect(syncUserFitbitMock).not.toHaveBeenCalled();
} finally {
vi.mocked(isFitbitRetired).mockReturnValue(false);
}
});
});
+9
View File
@@ -4,6 +4,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { readSyncTriggerBody } from "@/lib/integrations/sync-request";
import { syncUserFitbit } from "@/lib/fitbit/sync";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
import { NextRequest } from "next/server";
@@ -31,6 +32,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
if (isFitbitRetired()) {
return apiError(
"Google has turned the Fitbit Web API off. Connect Google Health instead.",
410,
{ errorCode: "provider_retired" },
);
}
const body = await readSyncTriggerBody(request);
if (body.error) return body.error;
const { fullSync } = body;
@@ -36,6 +36,7 @@ import { POST } from "../route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { getValidToken } from "@/lib/fitbit/sync-core";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
interface ApiErrorEnvelope {
data: null;
@@ -109,6 +110,22 @@ describe("POST /api/integrations/fitbit/test", () => {
expect(global.fetch).not.toHaveBeenCalled();
});
it("answers 410 provider_retired once Google has turned the Web API off", async () => {
vi.mocked(isFitbitRetired).mockReturnValue(true);
try {
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(410);
const body = (await response.json()) as ApiErrorEnvelope & {
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("provider_retired");
expect(getValidToken).not.toHaveBeenCalled();
expect(global.fetch).not.toHaveBeenCalled();
} finally {
vi.mocked(isFitbitRetired).mockReturnValue(false);
}
});
it("returns 422 when no Fitbit connection exists", async () => {
vi.mocked(getValidToken).mockResolvedValueOnce(null);
const response = await POST(emptyRequest() as never);
@@ -7,6 +7,7 @@ import { prisma } from "@/lib/db";
import { getValidToken } from "@/lib/fitbit/sync-core";
import { FITBIT_API_BASE } from "@/lib/fitbit/client";
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
export const dynamic = "force-dynamic";
@@ -55,6 +56,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
if (isFitbitRetired()) {
return apiError(
"Google has turned the Fitbit Web API off. Connect Google Health instead.",
410,
{ errorCode: "provider_retired" },
);
}
const tokenInfo = await getValidToken(user.id);
if (!tokenInfo) {
return apiError("Fitbit not connected", 422, {
+7
View File
@@ -57,6 +57,10 @@ import {
type GoogleHealthBackfillEndedEntry,
} from "@/lib/google-health/backfill-progress";
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
import {
fitbitRetirement,
type FitbitRetirement,
} from "@/lib/fitbit/retirement";
export const dynamic = "force-dynamic";
@@ -378,6 +382,7 @@ export const GET = apiHandler(async () => {
? fitbitConn.tokenExpiresAt.getTime() <= now
: null,
backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null,
retirement: fitbitRetirement(),
health: health["fitbit"],
...resolve("fitbit", fitbitStatus, {
connected: fitbitConnected,
@@ -558,6 +563,8 @@ interface FitbitExtras {
tokenExpiresAt: string | null;
tokenExpired: boolean | null;
backfillCompleted: boolean | null;
/** The end of the legacy Fitbit Web API (`@/lib/fitbit/retirement`). */
retirement: FitbitRetirement;
}
// v1.27.0 — Google Health mirrors the Fitbit shape plus `needsReauth`: Google
@@ -71,6 +71,15 @@ describe("oauthReasonKey", () => {
);
});
it("names the Fitbit turndown refusal with its own copy, and only for Fitbit", () => {
expect(oauthReasonKey("fitbit", "retired")).toBe(
"settings.fitbitOauthError.retired",
);
expect(oauthReasonKey("whoop", "retired")).toBe(
"settings.whoopOauthError.generic",
);
});
it("falls back to generic for an unknown tag", () => {
expect(oauthReasonKey("whoop", "totally_unknown")).toBe(
"settings.whoopOauthError.generic",
@@ -0,0 +1,47 @@
/**
* The Fitbit card's notice about the end of the legacy Web API: before the
* day it says when the connection stops, from the day on that it has, and
* both times it points at Google Health and says the data stays.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { FitbitRetirementNotice } from "../fitbit-retirement-notice";
function render(ended: boolean | null) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<FitbitRetirementNotice
retirement={
ended === null
? undefined
: { endsOn: "2026-10-30", ended, successor: "google-health" }
}
/>
</I18nProvider>,
);
}
describe("<FitbitRetirementNotice>", () => {
it("names the day the connection stops, and the way on", () => {
const html = render(false);
expect(html).toContain('data-ended="false"');
expect(html).toContain("Google turns the Fitbit Web API off on");
expect(html).toContain("Google Health reads the same Fitbit and Pixel");
expect(html).toContain("Everything already imported stays.");
expect(html).toContain('href="#google-health"');
expect(html).toContain("/google-health");
});
it("says the connection has stopped once the day has come", () => {
const html = render(true);
expect(html).toContain('data-ended="true"');
expect(html).toContain("Google turned the Fitbit Web API off on");
expect(html).not.toContain("Google turns the Fitbit Web API off on");
});
it("renders nothing without a retirement on the status entry", () => {
expect(render(null)).toBe("");
});
});
@@ -19,6 +19,7 @@ import { showErrorToast } from "@/lib/errors/error-toast";
import { AppleHealthCard } from "@/components/settings/integrations/apple-health-card";
import { DawarichCard } from "@/components/settings/integrations/dawarich-card";
import { FitbitRetirementNotice } from "@/components/settings/integrations/fitbit-retirement-notice";
import { GarminInfoNote } from "@/components/settings/integrations/garmin-info-note";
import { GoogleHealthCard } from "@/components/settings/integrations/google-health-card";
import { IntegrationFallbackRow } from "@/components/settings/integrations/integration-fallback-row";
@@ -136,6 +137,9 @@ const PROVIDER_ONLY_REASONS: Partial<
Record<OAuthOutcomeProvider, ReadonlySet<string>>
> = {
oura: new Set(["issuer"]),
// Fitbit's `retired`: Google has turned the Web API off, so the connect
// route refuses a new grant.
fitbit: new Set(["retired"]),
};
type OAuthOutcome =
@@ -216,6 +220,8 @@ export function ConnectionsPanel({
const queryClient = useQueryClient();
const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated);
const fitbitStatus = pickStatus(integrationStatus, "fitbit");
const fitbitRetired = fitbitStatus?.retirement?.ended === true;
// OAuth callback handler — reads `?withings=connected|error&reason=…`
// from the URL (lazy initialiser, same shape as the Codex handler in
@@ -332,20 +338,35 @@ export function ConnectionsPanel({
/>
</div>
<div id="fitbit" className="scroll-mt-28">
<FitbitRetirementNotice retirement={fitbitStatus?.retirement} />
<OAuthIntegrationCard
config={OAUTH_INTEGRATIONS.fitbit}
viewModel={pickStatus(integrationStatus, "fitbit")}
viewModel={fitbitStatus}
callbackUrl={callbackUrls.fitbit}
syncDisabled={fitbitRetired}
connectDisabled={fitbitRetired}
titleAccessory={
<>
<TagChip>{t("settings.fitbitTag")}</TagChip>
<Badge
variant="outline"
data-testid="fitbit-experimental-badge"
className="border-warning/50 text-warning"
>
{t("settings.fitbitExperimentalBadge")}
</Badge>
{fitbitStatus?.retirement ? (
<Badge
variant="outline"
data-testid="fitbit-retirement-badge"
className="border-warning/50 text-warning"
>
{fitbitRetired
? t("settings.fitbitRetirement.badgeEnded")
: t("settings.fitbitRetirement.badgeEnding")}
</Badge>
) : (
<Badge
variant="outline"
data-testid="fitbit-experimental-badge"
className="border-warning/50 text-warning"
>
{t("settings.fitbitExperimentalBadge")}
</Badge>
)}
</>
}
/>
@@ -0,0 +1,61 @@
"use client";
import { ArrowDown, ExternalLink } from "lucide-react";
import { InlineAction } from "@/components/ui/inline-action";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { FitbitRetirement } from "@/lib/fitbit/retirement";
import { integrationDocsHref } from "./setup-guide-link";
/**
* The end of the legacy Fitbit Web API, on the Fitbit card. Before the day it
* says when the connection stops; from the day on, that it has stopped. Both
* name the way on: Google Health reads the same devices, needs one more
* consent, and leaves everything already imported in place. The server owns
* the date and whether it has passed (`retirement` on the status envelope).
*/
export function FitbitRetirementNotice({
retirement,
}: {
retirement: FitbitRetirement | undefined;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
if (!retirement) return null;
const date = fmt.dateShortSmartCalendar(retirement.endsOn);
return (
<div
role="note"
data-testid="fitbit-retirement-notice"
data-ended={retirement.ended ? "true" : "false"}
className="border-warning/40 bg-warning/5 text-foreground mb-3 space-y-2 rounded-md border px-3 py-2 text-sm"
>
<p>
{retirement.ended
? t("settings.fitbitRetirement.ended", { date })
: t("settings.fitbitRetirement.ending", { date })}
</p>
<p>{t("settings.fitbitRetirement.move")}</p>
<div className="flex flex-wrap gap-x-4 gap-y-1">
<InlineAction asChild size="sm">
<a href="#google-health" data-testid="fitbit-retirement-to-google">
{t("settings.fitbitRetirement.toCard")}
<ArrowDown className="size-3" aria-hidden="true" />
</a>
</InlineAction>
<InlineAction asChild size="sm">
<a
href={integrationDocsHref("google-health")}
target="_blank"
rel="noopener noreferrer"
data-testid="fitbit-retirement-guide"
>
{t("settings.fitbitRetirement.guide")}
<ExternalLink className="size-3" aria-hidden="true" />
</a>
</InlineAction>
</div>
</div>
);
}
@@ -220,6 +220,10 @@ export function OAuthIntegrationCard(props: {
callbackUrl: IntegrationCallbackUrls[OAuthIntegrationKey];
icon?: LucideIcon;
titleAccessory?: ReactNode;
/** The provider cannot sync any more (Fitbit after its API turndown). */
syncDisabled?: boolean;
/** …and cannot be connected either. */
connectDisabled?: boolean;
}) {
const { config } = props;
const sync = useStandardSync({
@@ -229,7 +233,13 @@ export function OAuthIntegrationCard(props: {
fullSyncResult: config.fullSyncResult,
statusQueryKey: config.statusQueryKey(),
});
return <OAuthIntegrationCardView {...props} sync={sync} />;
return (
<OAuthIntegrationCardView
{...props}
sync={sync}
fullSyncDisabled={props.syncDisabled}
/>
);
}
/**
@@ -248,6 +258,7 @@ export function OAuthIntegrationCardView({
fullSyncDisabled,
syncResults,
details,
connectDisabled,
}: {
config: OAuthIntegrationConfig;
viewModel: IntegrationStatusViewModel | undefined;
@@ -263,6 +274,8 @@ export function OAuthIntegrationCardView({
syncResults?: ReactNode;
/** Provider extras for the details section. */
details?: ReactNode;
/** Greys out "Connect" whatever the credentials say. */
connectDisabled?: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -374,7 +387,7 @@ export function OAuthIntegrationCardView({
type="button"
size="sm"
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
disabled={!configured}
disabled={!configured || connectDisabled}
onClick={connect}
data-slot="integration-primary-action"
data-action="connect"
@@ -14,6 +14,7 @@ import type { GoogleHealthBackfillEndedEntry } from "@/lib/google-health/backfil
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
import type { MetricFreshnessEntry } from "@/lib/integrations/sync-verdict";
import { queryKeys } from "@/lib/query-keys";
import type { FitbitRetirement } from "@/lib/fitbit/retirement";
export type IntegrationKey =
| "withings"
@@ -71,6 +72,8 @@ export interface IntegrationStatusViewModel {
hasToken?: boolean;
/** Per-metric last-seen timestamps with the server-computed stale flag. */
metricFreshness?: MetricFreshnessEntry[];
/** Fitbit: the end of the legacy Web API, resolved on the server. */
retirement?: FitbitRetirement;
}
export interface IntegrationStatusEnvelope {
@@ -0,0 +1,37 @@
/**
* The turndown date of the legacy Fitbit Web API, read off the real module
* (the unit setup pins it off for every other suite).
*/
import { describe, expect, it, vi } from "vitest";
const actual = await vi.importActual<typeof import("@/lib/fitbit/retirement")>(
"@/lib/fitbit/retirement",
);
describe("Fitbit Web API retirement", () => {
it("names the day Google published", () => {
// developers.google.com/health/migration: "On October 30, 2026, the
// Fitbit Web API will be turned off".
expect(actual.FITBIT_WEB_API_TURNDOWN_DATE).toBe("2026-10-30");
});
it("turns at midnight UTC at the start of that day", () => {
expect(actual.isFitbitRetired(new Date("2026-10-29T23:59:59.999Z"))).toBe(
false,
);
expect(actual.isFitbitRetired(new Date("2026-10-30T00:00:00.000Z"))).toBe(
true,
);
});
it("publishes the day, whether it has come, and the successor", () => {
expect(actual.fitbitRetirement(new Date("2026-10-21T12:00:00Z"))).toEqual({
endsOn: "2026-10-30",
ended: false,
successor: "google-health",
});
expect(
actual.fitbitRetirement(new Date("2026-11-02T12:00:00Z")).ended,
).toBe(true);
});
});
@@ -91,10 +91,12 @@ vi.mock("../sync-workout", () => ({
}));
import {
getValidToken,
handleCollectionFetchError,
noteHardFailure,
upsertFitbitMeasurements,
} from "../sync-core";
import { isFitbitRetired } from "../retirement";
import { syncUserFitbit } from "../sync";
import { FitbitApiError } from "../response-classifier";
import { prisma } from "@/lib/db";
@@ -453,3 +455,33 @@ describe("syncUserFitbit — backfill collapses rollup recompute to one pass", (
expect(update).not.toHaveBeenCalled();
});
});
describe("after Google turned the Fitbit Web API off", () => {
it("syncs nothing and writes neither a success nor a failure", async () => {
vi.mocked(isFitbitRetired).mockReturnValue(true);
try {
await expect(syncUserFitbit("u1")).resolves.toEqual({
imported: 0,
failed: false,
});
expect(syncUserMetrics).not.toHaveBeenCalled();
expect(syncUserSleep).not.toHaveBeenCalled();
expect(recordSyncSuccess).not.toHaveBeenCalled();
expect(recordSyncFailure).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
} finally {
vi.mocked(isFitbitRetired).mockReturnValue(false);
}
});
it("hands out no token and attempts no refresh", async () => {
vi.mocked(isFitbitRetired).mockReturnValue(true);
try {
await expect(getValidToken("u1")).resolves.toBeNull();
expect(findUnique).not.toHaveBeenCalled();
expect(recordSyncFailure).not.toHaveBeenCalled();
} finally {
vi.mocked(isFitbitRetired).mockReturnValue(false);
}
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* The end of the legacy Fitbit Web API.
*
* Google turns it off on 30 October 2026: "On October 30, 2026, the Fitbit
* Web API will be turned off and will no longer function or sync data to or
* from Fitbit users" (https://developers.google.com/health/migration and the
* August 2026 Google Health developer newsletter; support already ended on
* 30 September 2026). Its successor is the Google Health API, which reads the
* same Fitbit and Pixel devices. Access and refresh tokens cannot be carried
* over, so a person moves by connecting Google Health and consenting once more.
*
* From the turndown on, HealthLog makes no Web API call for any Fitbit
* connection: no poll, no backfill, no sleep repair, no token refresh, and no
* new connection. The connection row and everything it imported stay. The
* connection reads as not connected to the health read and the watchdog, so a
* silence that is the provider's own shutdown pages nobody and asks nobody to
* reconnect; the card keeps explaining the move instead.
*
* The cut is midnight UTC at the start of the published day, still the
* evening before in the Americas. Stopping those few hours early costs
* nothing Google Health does not bring back with its own history.
*/
/** The day Google turns the Web API off, as a calendar date. */
export const FITBIT_WEB_API_TURNDOWN_DATE = "2026-10-30";
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: one instant for every instance, the start of the published day (see the header)
const TURNDOWN_AT_MS = Date.parse(`${FITBIT_WEB_API_TURNDOWN_DATE}T00:00:00Z`);
/** True from the turndown on. */
export function isFitbitRetired(now: Date = new Date()): boolean {
return now.getTime() >= TURNDOWN_AT_MS;
}
/** What the status envelope publishes on the Fitbit entry. */
export interface FitbitRetirement {
/** The turndown day, `YYYY-MM-DD`. */
endsOn: string;
/** The day has come: the connection no longer syncs. */
ended: boolean;
/** The integration that reads the same devices. */
successor: "google-health";
}
export function fitbitRetirement(now: Date = new Date()): FitbitRetirement {
return {
endsOn: FITBIT_WEB_API_TURNDOWN_DATE,
ended: isFitbitRetired(now),
successor: "google-health",
};
}
+6
View File
@@ -21,6 +21,7 @@ import {
recomputeBucketsForKeys,
} from "@/lib/rollups/measurement-rollups";
import { refreshAccessToken } from "./client";
import { isFitbitRetired } from "./retirement";
import { getUserFitbitCredentials } from "./credentials";
import { FitbitApiError, classifyFitbitError } from "./response-classifier";
import type { FitbitClassification } from "./response-classifier";
@@ -71,10 +72,15 @@ export interface FitbitTokenInfo {
* response ever omits the new refresh token (a malformed reply), keep the stored
* one rather than wiping it — a blank refresh token would brick every future
* refresh.
*
* Once Google has turned the Web API off (`./retirement`) there is no token
* worth holding: null, with no refresh attempt and nothing on the ledger, so
* every caller reads the connection as having nothing to fetch.
*/
export async function getValidToken(
userId: string,
): Promise<FitbitTokenInfo | null> {
if (isFitbitRetired()) return null;
const connection = await prisma.fitbitConnection.findUnique({
where: { userId },
});
+8
View File
@@ -23,6 +23,7 @@ import type {
import { syncUserMetrics } from "./sync-metrics";
import { syncUserSleep } from "./sync-sleep";
import { syncUserWorkout } from "./sync-workout";
import { isFitbitRetired } from "./retirement";
/**
* Full per-user sync across every Fitbit resource. Drives the hourly poll
@@ -49,6 +50,13 @@ export async function syncUserFitbit(
userId: string,
opts: { fullSync?: boolean } = {},
): Promise<{ imported: number; failed: boolean }> {
// The Web API is gone (`./retirement`): nothing to fetch, nothing to stamp.
// Neither a success (it would paint the connection green) nor a failure (it
// would ask for a reconnect that cannot help).
if (isFitbitRetired()) {
annotate({ action: { name: "fitbit.sync.retired" } });
return { imported: 0, failed: false };
}
if (await isReauthRequired(userId, "fitbit")) {
getEvent()?.addWarning(
`fitbit sync skipped for ${userId}: parked at error_reauth`,
@@ -46,6 +46,7 @@ import { readOuraScopeDenied } from "@/lib/oura/credentials";
import { ouraScopeReconnectList } from "@/lib/oura/scope";
import { localDayWindow } from "@/lib/tz/local-day";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
export type ConnectionHealthMap = Record<
ConnectionKey,
@@ -234,7 +235,10 @@ export async function loadConnectionFacts(
const connected: Record<ConnectionKey, boolean> = {
withings: !!withingsConn,
whoop: !!whoopConn?.whoopUserId,
fitbit: !!fitbitConn,
// Once Google has turned the Fitbit Web API off the row stays (it carries
// the imported history) but nothing will sync through it: no health to
// judge and nothing for the watchdog to call silent.
fitbit: !!fitbitConn && !isFitbitRetired(now),
"google-health": !!googleHealthConn,
polar: !!dbUser?.polarAccessTokenEncrypted,
oura: !!dbUser?.ouraAccessTokenEncrypted,
@@ -43,6 +43,7 @@ import {
enqueueBootTimeFitbitBackfill,
runFitbitBackfillForUser,
} from "../fitbit-backfill";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
beforeEach(() => {
vi.clearAllMocks();
@@ -146,3 +147,28 @@ describe("runFitbitBackfillForUser — a walk that reached its ceiling", () => {
expect(prismaMock.fitbitConnection.update).not.toHaveBeenCalled();
});
});
describe("after Google turned the Fitbit Web API off", () => {
it("enqueues no backfill and runs a queued one without walking or stamping", async () => {
vi.mocked(isFitbitRetired).mockReturnValue(true);
try {
prismaMock.fitbitConnection.findMany.mockResolvedValue([
{ userId: "u1" },
]);
await expect(enqueueBootTimeFitbitBackfill()).resolves.toEqual({
enqueued: 0,
skipped: 0,
error: null,
});
expect(bossSend).not.toHaveBeenCalled();
await expect(runFitbitBackfillForUser("u1")).resolves.toEqual({
imported: 0,
});
expect(syncUserFitbit).not.toHaveBeenCalled();
expect(prismaMock.fitbitConnection.update).not.toHaveBeenCalled();
} finally {
vi.mocked(isFitbitRetired).mockReturnValue(false);
}
});
});
@@ -71,6 +71,7 @@ import {
enqueueBootTimeFitbitSleepRepair,
runFitbitSleepRepairForUser,
} from "../fitbit-sleep-repair";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
beforeEach(() => {
vi.clearAllMocks();
@@ -231,3 +232,29 @@ describe("runFitbitSleepRepairForUser", () => {
expect(prismaMock.fitbitConnection.update).not.toHaveBeenCalled();
});
});
describe("after Google turned the Fitbit Web API off", () => {
it("enqueues no repair and runs a queued one without reading or stamping", async () => {
vi.mocked(isFitbitRetired).mockReturnValue(true);
try {
prismaMock.fitbitConnection.findMany.mockResolvedValue([
{ userId: "u1" },
]);
await expect(enqueueBootTimeFitbitSleepRepair()).resolves.toEqual({
enqueued: 0,
skipped: 0,
error: null,
});
expect(bossSend).not.toHaveBeenCalled();
await expect(runFitbitSleepRepairForUser("u1")).resolves.toEqual({
imported: 0,
removed: 0,
});
expect(syncUserSleep).not.toHaveBeenCalled();
expect(prismaMock.fitbitConnection.update).not.toHaveBeenCalled();
} finally {
vi.mocked(isFitbitRetired).mockReturnValue(false);
}
});
});
@@ -0,0 +1,47 @@
/**
* The hourly Fitbit cron after Google turned the Web API off: it reads no
* connection, runs no cohort, and finishes as a clean no-op, so the shutdown
* cannot turn into an hourly storm of failed polls.
*/
import { describe, expect, it, vi } from "vitest";
const { findMany, runFitbitPollCohort } = vi.hoisted(() => ({
findMany: vi.fn(async () => [{ userId: "u1" }]),
runFitbitPollCohort: vi.fn(async () => ({
usersSynced: 1,
measurementsImported: 3,
})),
}));
vi.mock("@/lib/jobs/reminder/shared", () => ({
getWorkerPrisma: () => ({ fitbitConnection: { findMany } }),
}));
vi.mock("@/lib/fitbit/sync", () => ({ runFitbitPollCohort }));
vi.mock("@/lib/jobs/reminder-satisfy", () => ({
enqueueReminderSatisfy: vi.fn(async () => {}),
}));
import { handleFitbitSync } from "../reminder/fitbit-sync";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
describe("handleFitbitSync", () => {
it("polls the cohort while the Web API is up", async () => {
await handleFitbitSync([]);
expect(findMany).toHaveBeenCalledTimes(1);
expect(runFitbitPollCohort).toHaveBeenCalledTimes(1);
});
it("does nothing at all after the turndown", async () => {
findMany.mockClear();
runFitbitPollCohort.mockClear();
vi.mocked(isFitbitRetired).mockReturnValue(true);
try {
const outcome = await handleFitbitSync([]);
expect(findMany).not.toHaveBeenCalled();
expect(runFitbitPollCohort).not.toHaveBeenCalled();
expect(outcome).toMatchObject({ ok: true, did: { total: 0 } });
} finally {
vi.mocked(isFitbitRetired).mockReturnValue(false);
}
});
});
+13 -1
View File
@@ -20,6 +20,7 @@ import {
withPageWalkLedger,
} from "@/lib/integrations/page-walk";
import { syncUserFitbit } from "@/lib/fitbit/sync";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
export const FITBIT_BACKFILL_QUEUE = "fitbit-backfill";
@@ -58,6 +59,17 @@ export interface FitbitBackfillPayload {
export async function runFitbitBackfillForUser(
userId: string,
): Promise<{ imported: number }> {
// A job queued before the turndown: the history it would walk is gone with
// the API. Returns without stamping, so the marker never claims a walk.
if (isFitbitRetired()) {
annotate({
action: {
name: "fitbit.backfill.skipped_retired",
details: { imported: 0 },
},
});
return { imported: 0 };
}
if (await isReauthRequired(userId, "fitbit")) {
annotate({
action: {
@@ -133,7 +145,7 @@ export async function enqueueBootTimeFitbitBackfill(
error: string | null;
}> {
const boss = getGlobalBoss();
if (!boss) {
if (!boss || isFitbitRetired()) {
return { enqueued: 0, skipped: 0, error: null };
}
+12 -1
View File
@@ -45,6 +45,7 @@ import {
runWithFitbitHardFailLedger,
} from "@/lib/fitbit/sync-core";
import { syncUserSleep } from "@/lib/fitbit/sync-sleep";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
export const FITBIT_SLEEP_REPAIR_QUEUE = "fitbit-sleep-repair";
@@ -82,6 +83,16 @@ export interface FitbitSleepRepairPayload {
export async function runFitbitSleepRepairForUser(
userId: string,
): Promise<{ imported: number; removed: number }> {
// Nothing left to re-read once the Web API is gone; no stamp either.
if (isFitbitRetired()) {
annotate({
action: {
name: "fitbit.sleepRepair.skipped_retired",
details: { imported: 0, removed: 0 },
},
});
return { imported: 0, removed: 0 };
}
if (await isReauthRequired(userId, "fitbit")) {
annotate({
action: {
@@ -155,7 +166,7 @@ export async function enqueueBootTimeFitbitSleepRepair(
error: string | null;
}> {
const boss = getGlobalBoss();
if (!boss) {
if (!boss || isFitbitRetired()) {
return { enqueued: 0, skipped: 0, error: null };
}
+15
View File
@@ -13,6 +13,7 @@ import { runFitbitPollCohort } from "@/lib/fitbit/sync";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { cleanupExpiredFitbitOAuthStates } from "@/lib/jobs/fitbit-oauth-state-cleanup";
import { getWorkerPrisma } from "./shared";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
/**
* v1.12.0 — Fitbit poll-sync payload. Poll-only (no webhook at launch): the
@@ -28,6 +29,20 @@ export async function handleFitbitSync(
jobs: Job<FitbitSyncPayload>[],
): Promise<JobOutcome> {
return withBackgroundEvent("job.fitbit_sync", async (evt) => {
// Google turned the Web API off (`@/lib/fitbit/retirement`): the cron
// tick reads no connection and calls nothing.
if (isFitbitRetired()) {
evt.setBackground({
task_name: "job.fitbit_sync",
result: { retired: true },
});
return jobDone({
total: 0,
users_synced: 0,
users_failed: 0,
measurements_imported: 0,
});
}
const prisma = getWorkerPrisma();
try {
const targets: Array<{ userId: string }> = [];
+1
View File
@@ -484,6 +484,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"oidc_only",
"private_origin_not_approved",
"private_origin_not_grantable",
"provider_retired",
"rate_limited_self",
"sync_in_progress",
"too_many_rows",
+30 -1
View File
@@ -1151,10 +1151,29 @@ const integrationEnvelopeEntry = z
),
hasToken: z.boolean().optional().describe("Nightscout only."),
allowPrivateHost: z.boolean().optional().describe("Nightscout only."),
retirement: z
.object({
endsOn: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.describe("The day Google turns the legacy Fitbit Web API off."),
ended: z
.boolean()
.describe(
"The day has come: the connection no longer syncs, its imported data stays, and `health` is null.",
),
successor: z
.enum(["google-health"])
.describe(
"The integration that reads the same Fitbit and Pixel devices. Moving needs a fresh consent; the Fitbit grant cannot be carried over.",
),
})
.optional()
.describe("Fitbit only. The end of the legacy Fitbit Web API."),
health: connectionHealth
.nullable()
.describe(
"The unified connection state, null when the provider is not connected. It supersedes `state`, `lastError`, `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`, `hasActivityScope`, `planUnavailable` and `scopeReconnect` on this entry; those nine are deprecated and removed in v1.44.",
"The unified connection state, null when the provider is not connected (and for Fitbit once `retirement.ended`). It supersedes `state`, `lastError`, `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`, `hasActivityScope`, `planUnavailable` and `scopeReconnect` on this entry; those nine are deprecated and removed in v1.44.",
),
})
.meta({
@@ -2020,6 +2039,11 @@ export const integrationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"Either limiter tripped: more than 5 syncs in 60 s, or a second full sync within the hour. `meta.errorCode` = `rate_limited_self` for both; the message distinguishes them.",
content: { "application/json": { schema: errorEnvelope } },
},
"410": {
description:
"Google turned the legacy Fitbit Web API off on 2026-10-30; nothing is fetched. `meta.errorCode` = `provider_retired`. The data already imported stays; Google Health reads the same devices.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
@@ -2663,6 +2687,11 @@ export const integrationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...stdResponses,
...probeNotConfigured422,
...selfRateLimited429,
"410": {
description:
"Google turned the legacy Fitbit Web API off on 2026-10-30; nothing is probed. `meta.errorCode` = `provider_retired`.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
+22 -1
View File
@@ -1,4 +1,4 @@
import { afterEach, inject } from "vitest";
import { afterEach, inject, vi } from "vitest";
declare module "vitest" {
export interface ProvidedContext {
@@ -32,3 +32,24 @@ afterEach(async () => {
await import("@/lib/logging/background-tasks");
await settleBackgroundTasks();
});
/**
* The same pin the unit setup applies (see `vitest.setup.ts`): the Fitbit
* suites run before the Web API turndown, whatever the real date, and a test
* about the turndown flips `isFitbitRetired` itself. The factory imports the
* module lazily, so nothing of the application loads before DATABASE_URL is
* bridged above.
*/
vi.mock("@/lib/fitbit/retirement", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/fitbit/retirement")>();
const isFitbitRetired = vi.fn((_now?: Date) => false);
return {
...actual,
isFitbitRetired,
fitbitRetirement: vi.fn((now?: Date) => ({
...actual.fitbitRetirement(now),
ended: isFitbitRetired(now),
})),
};
});
+113
View File
@@ -0,0 +1,113 @@
/**
* After Google turned the Fitbit Web API off, a Fitbit connection that has
* gone quiet is the provider's shutdown, not a broken pipe. Against real
* Postgres: the health read publishes no state for it (so no card or banner
* asks for a reconnect), the watchdog pages nobody, and the connection row
* and every imported reading stay.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/notifications/dispatcher", () => ({
dispatchNotification: vi.fn().mockResolvedValue({
dispatched: true,
channelsAttempted: 1,
channelsSucceeded: 1,
}),
}));
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import { runIntegrationWatchdog } from "@/lib/integrations/watchdog";
import { loadConnectionHealth } from "@/lib/integrations/connection-health-load";
import { recordSyncSuccess } from "@/lib/integrations/status";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
const NOW = new Date("2026-11-03T12:00:00.000Z");
const USER = "user-fitbit-retired";
const ADMIN = "user-fitbit-retired-admin";
const prisma = () => getPrismaClient();
beforeEach(async () => {
await truncateAllTables(prisma());
await prisma().user.create({
data: {
id: ADMIN,
username: "fr-admin",
email: "admin@example.test",
role: "ADMIN",
},
});
await prisma().user.create({
data: { id: USER, username: "fr-subject", email: "subject@example.test" },
});
await prisma().fitbitConnection.create({
data: {
userId: USER,
fitbitUserId: "fb-1",
accessToken: "not-a-real-token",
refreshToken: "not-a-real-token",
tokenExpiresAt: new Date(NOW.getTime() - DAY),
lastSyncedAt: new Date(NOW.getTime() - 5 * DAY),
},
});
// A fortnight of daily steps that stopped with the turndown.
await prisma().measurement.createMany({
data: Array.from({ length: 14 }, (_, i) => ({
userId: USER,
type: "ACTIVITY_STEPS" as const,
value: 8000,
unit: "count",
source: "FITBIT" as const,
measuredAt: new Date(NOW.getTime() - (i + 5) * DAY),
externalId: `stats:day-${i}`,
})),
});
vi.mocked(dispatchNotification).mockClear();
// The watchdog has run on this instance before, so it pages on a fresh
// incident instead of recording what stands.
await prisma().integrationWatch.create({
data: { userId: ADMIN, integration: "withings" },
});
});
describe("Fitbit after the Web API turndown", () => {
it("still reads as a connection with a health state before the day", async () => {
await recordSyncSuccess(USER, "fitbit");
const health = await loadConnectionHealth(USER, ["fitbit"]);
expect(health.fitbit).not.toBeNull();
});
it("publishes no health, pages nobody, and keeps the row and the data", async () => {
await recordSyncSuccess(USER, "fitbit");
vi.mocked(isFitbitRetired).mockReturnValue(true);
try {
const health = await loadConnectionHealth(USER, ["fitbit"]);
expect(health.fitbit).toBeNull();
await runIntegrationWatchdog({ mode: "hourly", now: NOW });
await runIntegrationWatchdog({ mode: "daily", now: NOW });
expect(dispatchNotification).not.toHaveBeenCalled();
const watch = await prisma().integrationWatch.findUnique({
where: { userId_integration: { userId: USER, integration: "fitbit" } },
});
expect(watch?.alertedAt ?? null).toBeNull();
} finally {
vi.mocked(isFitbitRetired).mockReturnValue(false);
}
expect(
await prisma().fitbitConnection.count({ where: { userId: USER } }),
).toBe(1);
expect(
await prisma().measurement.count({
where: { userId: USER, source: "FITBIT" },
}),
).toBe(14);
});
});
+24
View File
@@ -59,3 +59,27 @@ vi.mock("@/lib/auth/write-meter", async (importOriginal) => {
await importOriginal<typeof import("@/lib/auth/write-meter")>();
return { ...actual, meterAccountWrite: vi.fn(async () => undefined) };
});
/**
* The end of the legacy Fitbit Web API is a calendar date
* (`src/lib/fitbit/retirement.ts`). Read off the real clock, every suite that
* exercises the Fitbit sync, its jobs or its routes would turn red on that day
* and stay red until the provider code is removed. The suites therefore run
* before the turndown by default; a test about the turndown itself flips
* `isFitbitRetired` (`vi.mocked(isFitbitRetired).mockReturnValue(true)`) or
* loads the real module with `vi.importActual`, as
* `src/lib/fitbit/__tests__/retirement.test.ts` does.
*/
vi.mock("@/lib/fitbit/retirement", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/fitbit/retirement")>();
const isFitbitRetired = vi.fn((_now?: Date) => false);
return {
...actual,
isFitbitRetired,
fitbitRetirement: vi.fn((now?: Date) => ({
...actual.fitbitRetirement(now),
ended: isFitbitRetired(now),
})),
};
});