From f5d0b2f696f75977a463e3f8cc7f808ffd922318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 19:06:56 +0200 Subject: [PATCH] feat(integrations): add Nightscout CGM glucose sync Pull continuous glucose readings from a self-hosted Nightscout instance and store them as immutable BLOOD_GLUCOSE mg/dL measurements (source=NIGHTSCOUT), giving the glucose panel real CGM density. - client over safeFetch: public-host pin by default, relaxed only on the per-user private-network opt-in; redirect pinned manual + timeout - credentials encrypted at rest on User; URL is the connect marker - sync maps SGV entries first-write-wins on (userId,type,source,externalId) - connect/disconnect/status routes: apiHandler + requireAuth + rate limit; connect validates by a live test fetch with clear errors - hourly poll-cohort job registered in the all-queues registry - settings card with URL, token, and the private-network toggle - i18n across all six locales --- messages/de.json | 21 +- messages/en.json | 21 +- messages/es.json | 21 +- messages/fr.json | 21 +- messages/it.json | 21 +- messages/pl.json | 21 +- .../connect/__tests__/route.test.ts | 154 +++++++++ src/app/api/nightscout/connect/route.ts | 103 ++++++ src/app/api/nightscout/disconnect/route.ts | 50 +++ src/app/api/nightscout/status/route.ts | 49 +++ .../__tests__/integrations-section.test.tsx | 6 +- .../settings/integrations-section.tsx | 2 + .../settings/integrations/nightscout-card.tsx | 301 ++++++++++++++++++ .../jobs/__tests__/nightscout-queues.test.ts | 47 +++ src/lib/jobs/reminder-worker.ts | 27 ++ src/lib/jobs/reminder/nightscout-sync.ts | 70 ++++ src/lib/nightscout/__tests__/client.test.ts | 219 +++++++++++++ src/lib/nightscout/__tests__/sync.test.ts | 158 +++++++++ src/lib/nightscout/client.ts | 231 ++++++++++++++ src/lib/nightscout/credentials.ts | 46 +++ src/lib/nightscout/sync.ts | 176 ++++++++++ src/lib/query-keys/integrations.ts | 6 + src/lib/validations/nightscout.ts | 41 +++ 23 files changed, 1803 insertions(+), 9 deletions(-) create mode 100644 src/app/api/nightscout/connect/__tests__/route.test.ts create mode 100644 src/app/api/nightscout/connect/route.ts create mode 100644 src/app/api/nightscout/disconnect/route.ts create mode 100644 src/app/api/nightscout/status/route.ts create mode 100644 src/components/settings/integrations/nightscout-card.tsx create mode 100644 src/lib/jobs/__tests__/nightscout-queues.test.ts create mode 100644 src/lib/jobs/reminder/nightscout-sync.ts create mode 100644 src/lib/nightscout/__tests__/client.test.ts create mode 100644 src/lib/nightscout/__tests__/sync.test.ts create mode 100644 src/lib/nightscout/client.ts create mode 100644 src/lib/nightscout/credentials.ts create mode 100644 src/lib/nightscout/sync.ts create mode 100644 src/lib/validations/nightscout.ts diff --git a/messages/de.json b/messages/de.json index cfea1b804..b16430a9a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4470,7 +4470,26 @@ "enable": "Zyklus-Tracking aktivieren", "manage": "Zyklus-Tracking öffnen", "error": "Zyklus-Tracking konnte nicht aktualisiert werden." - } + }, + "nightscout": "Nightscout", + "nightscoutTag": "CGM", + "nightscoutDescription": "Verbinde deine eigene Nightscout-Seite, um kontinuierliche Glukosewerte für das Glukose-Panel zu synchronisieren.", + "nightscoutHelp": "Richte HealthLog auf deine Nightscout-Instanz aus. Werte werden stündlich im Hintergrund abgerufen.", + "nightscoutUrl": "Nightscout-URL", + "nightscoutUrlSavedPlaceholder": "Gespeichert — neue URL eingeben zum Ersetzen", + "nightscoutToken": "API-Token", + "nightscoutTokenOptional": "Optional bei einer öffentlichen Seite", + "nightscoutTokenSavedPlaceholder": "Gespeichert — neuen Token eingeben zum Ersetzen", + "nightscoutTokenHelp": "Füge einen Zugriffstoken aus deinen Nightscout-Admin-Tools ein. Leer lassen, wenn deine Seite öffentlich lesbar ist.", + "nightscoutPrivateHost": "Diese Seite läuft in einem privaten Netzwerk", + "nightscoutPrivateHostHelp": "Nur aktivieren, wenn deine Nightscout-Instanz unter einer LAN-, VPN- oder Tailscale-Adresse läuft. Aus bei öffentlichen Seiten.", + "nightscoutConnect": "Verbinden", + "nightscoutUpdate": "Änderungen speichern", + "nightscoutConnected": "Nightscout verbunden.", + "nightscoutConnectFailed": "Verbindung zu Nightscout fehlgeschlagen.", + "nightscoutDisconnect": "Trennen", + "nightscoutDisconnectTitle": "Nightscout trennen?", + "nightscoutDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Glukosewerte bleiben in deinem Verlauf." }, "admin": { "title": "Administration", diff --git a/messages/en.json b/messages/en.json index 0e147aab4..b48598477 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4470,7 +4470,26 @@ "enable": "Enable cycle tracking", "manage": "Open cycle tracking", "error": "Couldn't update cycle tracking." - } + }, + "nightscout": "Nightscout", + "nightscoutTag": "CGM", + "nightscoutDescription": "Connect your own Nightscout site to sync continuous glucose readings for the glucose panel.", + "nightscoutHelp": "Point HealthLog at your Nightscout instance. Readings are pulled in the background once an hour.", + "nightscoutUrl": "Nightscout URL", + "nightscoutUrlSavedPlaceholder": "Saved — enter a new URL to replace it", + "nightscoutToken": "API token", + "nightscoutTokenOptional": "Optional for a public site", + "nightscoutTokenSavedPlaceholder": "Saved — enter a new token to replace it", + "nightscoutTokenHelp": "Paste an access token from your Nightscout admin tools. Leave empty if your site is publicly readable.", + "nightscoutPrivateHost": "This site runs on a private network", + "nightscoutPrivateHostHelp": "Enable only if your Nightscout instance is on a LAN, VPN, or Tailscale address. Off for public sites.", + "nightscoutConnect": "Connect", + "nightscoutUpdate": "Save changes", + "nightscoutConnected": "Nightscout connected.", + "nightscoutConnectFailed": "Could not connect to Nightscout.", + "nightscoutDisconnect": "Disconnect", + "nightscoutDisconnectTitle": "Disconnect Nightscout?", + "nightscoutDisconnectDescription": "Future syncs stop. Glucose readings already imported stay in your history." }, "admin": { "title": "Administration", diff --git a/messages/es.json b/messages/es.json index ee2ea77c3..1a70e0ec5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4470,7 +4470,26 @@ "enable": "Activar el seguimiento del ciclo", "manage": "Abrir seguimiento del ciclo", "error": "No se pudo actualizar el seguimiento del ciclo." - } + }, + "nightscout": "Nightscout", + "nightscoutTag": "CGM", + "nightscoutDescription": "Conecta tu propio sitio Nightscout para sincronizar lecturas continuas de glucosa en el panel de glucosa.", + "nightscoutHelp": "Apunta HealthLog a tu instancia de Nightscout. Las lecturas se obtienen en segundo plano una vez por hora.", + "nightscoutUrl": "URL de Nightscout", + "nightscoutUrlSavedPlaceholder": "Guardado: introduce una URL nueva para reemplazarla", + "nightscoutToken": "Token de API", + "nightscoutTokenOptional": "Opcional para un sitio público", + "nightscoutTokenSavedPlaceholder": "Guardado: introduce un token nuevo para reemplazarlo", + "nightscoutTokenHelp": "Pega un token de acceso de las herramientas de administración de Nightscout. Déjalo vacío si tu sitio es de lectura pública.", + "nightscoutPrivateHost": "Este sitio funciona en una red privada", + "nightscoutPrivateHostHelp": "Actívalo solo si tu instancia de Nightscout está en una dirección LAN, VPN o Tailscale. Desactivado para sitios públicos.", + "nightscoutConnect": "Conectar", + "nightscoutUpdate": "Guardar cambios", + "nightscoutConnected": "Nightscout conectado.", + "nightscoutConnectFailed": "No se pudo conectar con Nightscout.", + "nightscoutDisconnect": "Desconectar", + "nightscoutDisconnectTitle": "¿Desconectar Nightscout?", + "nightscoutDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas de glucosa ya importadas permanecen en tu historial." }, "admin": { "title": "Administration", diff --git a/messages/fr.json b/messages/fr.json index 54b3b084b..3d35ae421 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4470,7 +4470,26 @@ "enable": "Activer le suivi du cycle", "manage": "Ouvrir le suivi du cycle", "error": "Impossible de mettre à jour le suivi du cycle." - } + }, + "nightscout": "Nightscout", + "nightscoutTag": "CGM", + "nightscoutDescription": "Connectez votre propre site Nightscout pour synchroniser les mesures de glycémie en continu dans le panneau glucose.", + "nightscoutHelp": "Pointez HealthLog vers votre instance Nightscout. Les mesures sont récupérées en arrière-plan une fois par heure.", + "nightscoutUrl": "URL Nightscout", + "nightscoutUrlSavedPlaceholder": "Enregistré — saisissez une nouvelle URL pour la remplacer", + "nightscoutToken": "Jeton d'API", + "nightscoutTokenOptional": "Facultatif pour un site public", + "nightscoutTokenSavedPlaceholder": "Enregistré — saisissez un nouveau jeton pour le remplacer", + "nightscoutTokenHelp": "Collez un jeton d'accès depuis les outils d'administration de Nightscout. Laissez vide si votre site est lisible publiquement.", + "nightscoutPrivateHost": "Ce site fonctionne sur un réseau privé", + "nightscoutPrivateHostHelp": "Activez uniquement si votre instance Nightscout est sur une adresse LAN, VPN ou Tailscale. Désactivé pour les sites publics.", + "nightscoutConnect": "Connecter", + "nightscoutUpdate": "Enregistrer les modifications", + "nightscoutConnected": "Nightscout connecté.", + "nightscoutConnectFailed": "Impossible de se connecter à Nightscout.", + "nightscoutDisconnect": "Déconnecter", + "nightscoutDisconnectTitle": "Déconnecter Nightscout ?", + "nightscoutDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures de glycémie déjà importées restent dans votre historique." }, "admin": { "title": "Administration", diff --git a/messages/it.json b/messages/it.json index 04730edac..18cb829a3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4470,7 +4470,26 @@ "enable": "Attiva il monitoraggio del ciclo", "manage": "Apri il monitoraggio del ciclo", "error": "Impossibile aggiornare il monitoraggio del ciclo." - } + }, + "nightscout": "Nightscout", + "nightscoutTag": "CGM", + "nightscoutDescription": "Collega il tuo sito Nightscout per sincronizzare le letture continue della glicemia nel pannello glicemia.", + "nightscoutHelp": "Punta HealthLog alla tua istanza Nightscout. Le letture vengono recuperate in background una volta all'ora.", + "nightscoutUrl": "URL Nightscout", + "nightscoutUrlSavedPlaceholder": "Salvato — inserisci un nuovo URL per sostituirlo", + "nightscoutToken": "Token API", + "nightscoutTokenOptional": "Facoltativo per un sito pubblico", + "nightscoutTokenSavedPlaceholder": "Salvato — inserisci un nuovo token per sostituirlo", + "nightscoutTokenHelp": "Incolla un token di accesso dagli strumenti di amministrazione di Nightscout. Lascia vuoto se il tuo sito è leggibile pubblicamente.", + "nightscoutPrivateHost": "Questo sito è su una rete privata", + "nightscoutPrivateHostHelp": "Attiva solo se la tua istanza Nightscout è su un indirizzo LAN, VPN o Tailscale. Disattivato per i siti pubblici.", + "nightscoutConnect": "Connetti", + "nightscoutUpdate": "Salva modifiche", + "nightscoutConnected": "Nightscout connesso.", + "nightscoutConnectFailed": "Impossibile connettersi a Nightscout.", + "nightscoutDisconnect": "Disconnetti", + "nightscoutDisconnectTitle": "Disconnettere Nightscout?", + "nightscoutDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture della glicemia già importate restano nella tua cronologia." }, "admin": { "title": "Administration", diff --git a/messages/pl.json b/messages/pl.json index e3915160a..9d6e88a13 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4470,7 +4470,26 @@ "enable": "Włącz śledzenie cyklu", "manage": "Otwórz śledzenie cyklu", "error": "Nie udało się zaktualizować śledzenia cyklu." - } + }, + "nightscout": "Nightscout", + "nightscoutTag": "CGM", + "nightscoutDescription": "Połącz własną witrynę Nightscout, aby synchronizować ciągłe odczyty glukozy w panelu glukozy.", + "nightscoutHelp": "Wskaż HealthLog na swoją instancję Nightscout. Odczyty są pobierane w tle raz na godzinę.", + "nightscoutUrl": "Adres URL Nightscout", + "nightscoutUrlSavedPlaceholder": "Zapisano — wpisz nowy adres URL, aby go zastąpić", + "nightscoutToken": "Token API", + "nightscoutTokenOptional": "Opcjonalny dla witryny publicznej", + "nightscoutTokenSavedPlaceholder": "Zapisano — wpisz nowy token, aby go zastąpić", + "nightscoutTokenHelp": "Wklej token dostępu z narzędzi administracyjnych Nightscout. Pozostaw puste, jeśli witryna jest publicznie czytelna.", + "nightscoutPrivateHost": "Ta witryna działa w sieci prywatnej", + "nightscoutPrivateHostHelp": "Włącz tylko, jeśli instancja Nightscout znajduje się pod adresem LAN, VPN lub Tailscale. Wyłączone dla witryn publicznych.", + "nightscoutConnect": "Połącz", + "nightscoutUpdate": "Zapisz zmiany", + "nightscoutConnected": "Nightscout połączony.", + "nightscoutConnectFailed": "Nie można połączyć z Nightscout.", + "nightscoutDisconnect": "Rozłącz", + "nightscoutDisconnectTitle": "Rozłączyć Nightscout?", + "nightscoutDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty glukozy pozostaną w historii." }, "admin": { "title": "Administracja", diff --git a/src/app/api/nightscout/connect/__tests__/route.test.ts b/src/app/api/nightscout/connect/__tests__/route.test.ts new file mode 100644 index 000000000..fa81b4160 --- /dev/null +++ b/src/app/api/nightscout/connect/__tests__/route.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +import { SafeFetchError } from "@/lib/safe-fetch"; +import { NightscoutApiError } from "@/lib/nightscout/client"; + +const { fetchSgvEntriesMock, rateLimitMock } = vi.hoisted(() => ({ + fetchSgvEntriesMock: vi.fn(), + rateLimitMock: vi.fn(), +})); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { user: { update: vi.fn() } }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` })); +vi.mock("@/lib/integrations/status", () => ({ markReconnected: vi.fn() })); +vi.mock("@/lib/nightscout/client", async (importOriginal) => { + const actual = await importOriginal< + typeof import("@/lib/nightscout/client") + >(); + return { ...actual, fetchSgvEntries: fetchSgvEntriesMock }; +}); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: rateLimitMock, + rateLimitHeaders: () => ({}), +})); + +vi.mock("@/lib/api-response", () => ({ + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), + apiError: (error: string, status: number) => ({ data: null, error, status }), + safeJson: async (req: NextRequest) => { + try { + return { data: await req.json(), error: null }; + } catch { + return { data: null, error: { status: 400 } }; + } + }, +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { markReconnected } from "@/lib/integrations/status"; + +const userUpdate = prisma.user.update as ReturnType; + +function req(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/nightscout/connect", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +type RouteResult = { data: unknown; error: string | null; status: number }; +const post = POST as unknown as (r: NextRequest) => Promise; + +beforeEach(() => { + vi.clearAllMocks(); + rateLimitMock.mockResolvedValue({ allowed: true, remaining: 9, resetAt: 0 }); + fetchSgvEntriesMock.mockResolvedValue([{ id: "x", sgv: 100, date: 1 }]); +}); + +describe("POST /api/nightscout/connect", () => { + it("422s on a missing / malformed URL", async () => { + const res = await post(req({ url: "not a url" })); + expect(res.status).toBe(422); + expect(userUpdate).not.toHaveBeenCalled(); + }); + + it("validates via a test fetch and stores encrypted creds on success", async () => { + const res = await post( + req({ url: "https://ns.example.com", token: "tok" }), + ); + expect(res.status).toBe(200); + expect(fetchSgvEntriesMock).toHaveBeenCalledTimes(1); + const fetchArg = fetchSgvEntriesMock.mock.calls[0]![0]; + expect(fetchArg.count).toBe(1); + expect(fetchArg.allowPrivateHost).toBe(false); + + const data = userUpdate.mock.calls[0]![0].data; + expect(data.nightscoutUrlEncrypted).toBe("enc:https://ns.example.com"); + expect(data.nightscoutTokenEncrypted).toBe("enc:tok"); + expect(data.nightscoutAllowPrivateHost).toBe(false); + expect(markReconnected).toHaveBeenCalledWith("u1", "nightscout"); + }); + + it("stores a null token for a public, token-less instance", async () => { + await post(req({ url: "https://ns.example.com" })); + const data = userUpdate.mock.calls[0]![0].data; + expect(data.nightscoutTokenEncrypted).toBeNull(); + }); + + it("passes the private-host opt-in through to the test fetch", async () => { + await post( + req({ + url: "http://192.168.1.5:1337", + token: "tok", + allowPrivateHost: true, + }), + ); + const fetchArg = fetchSgvEntriesMock.mock.calls[0]![0]; + expect(fetchArg.allowPrivateHost).toBe(true); + }); + + it("refuses a private host when the opt-in is off (clear 422, no store)", async () => { + fetchSgvEntriesMock.mockRejectedValue( + new SafeFetchError("refused", "private_host"), + ); + const res = await post(req({ url: "http://10.0.0.5", token: "tok" })); + expect(res.status).toBe(422); + expect(String(res.error)).toMatch(/private network/i); + expect(userUpdate).not.toHaveBeenCalled(); + }); + + it("surfaces a wrong-token rejection as a clear 422", async () => { + fetchSgvEntriesMock.mockRejectedValue( + new NightscoutApiError("responded 401", 401), + ); + const res = await post(req({ url: "https://ns.example.com", token: "x" })); + expect(res.status).toBe(422); + expect(String(res.error)).toMatch(/token/i); + expect(userUpdate).not.toHaveBeenCalled(); + }); + + it("surfaces an unreachable instance as a clear 422", async () => { + fetchSgvEntriesMock.mockRejectedValue( + new SafeFetchError("timeout", "timeout"), + ); + const res = await post(req({ url: "https://ns.example.com" })); + expect(res.status).toBe(422); + expect(String(res.error)).toMatch(/reach/i); + expect(userUpdate).not.toHaveBeenCalled(); + }); + + it("rate-limits the connect surface", async () => { + rateLimitMock.mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: 0, + }); + const res = await post(req({ url: "https://ns.example.com" })); + expect(res.status).toBe(429); + expect(fetchSgvEntriesMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/nightscout/connect/route.ts b/src/app/api/nightscout/connect/route.ts new file mode 100644 index 000000000..5c49350ee --- /dev/null +++ b/src/app/api/nightscout/connect/route.ts @@ -0,0 +1,103 @@ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { encrypt } from "@/lib/crypto"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { markReconnected } from "@/lib/integrations/status"; +import { SafeFetchError } from "@/lib/safe-fetch"; +import { + fetchSgvEntries, + NightscoutApiError, +} from "@/lib/nightscout/client"; +import { nightscoutConnectSchema } from "@/lib/validations/nightscout"; + +/** + * Connect (or update) the user's Nightscout instance (v1.17.0). + * + * Validates the URL + token by a live TEST FETCH of one SGV entry before + * storing anything — a wrong token (401/403), an unreachable instance, or a + * private host the user didn't opt into all surface as a clear error here + * rather than silently parking the integration on its first cron tick. + * + * On success the URL + token are encrypted at rest on `User` and the + * `nightscout` integration ledger is reset to connected. Per-user rate-limited + * (a test fetch is an outbound call; throttle the connect surface so a tight + * retry loop can't be used to probe arbitrary hosts). + */ +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "nightscout.connect" } }); + + const rl = await checkRateLimit(`nightscout-connect:${user.id}`, 10, 60_000); + if (!rl.allowed) { + return apiError("Too many connection attempts", 429, { + headers: rateLimitHeaders(rl), + }); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const result = z.safeParse(nightscoutConnectSchema, body); + if (!result.success) { + return apiError("A valid Nightscout URL is required", 422); + } + + const { url, token, allowPrivateHost } = result.data; + + // Live validation: pull a single SGV entry. A connection that can't even + // fetch one row is not worth storing. + try { + await fetchSgvEntries({ + baseUrl: url, + token, + count: 1, + allowPrivateHost, + }); + } catch (err) { + if (err instanceof SafeFetchError && err.kind === "private_host") { + return apiError( + "That instance is on a private network. Enable the private-network option to connect to it.", + 422, + ); + } + if (err instanceof NightscoutApiError) { + if (err.status === 401 || err.status === 403) { + return apiError( + "Nightscout rejected the token. Check the API token and try again.", + 422, + ); + } + return apiError( + "Could not reach the Nightscout instance. Check the URL and that it is online.", + 422, + ); + } + return apiError( + "Could not reach the Nightscout instance. Check the URL and that it is online.", + 422, + ); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { + // Build the data object field-by-field — never spread the parsed body. + nightscoutUrlEncrypted: encrypt(url), + nightscoutTokenEncrypted: token ? encrypt(token) : null, + nightscoutAllowPrivateHost: allowPrivateHost, + }, + }); + + await markReconnected(user.id, "nightscout"); + await auditLog("nightscout.connect", { userId: user.id }); + + return apiSuccess({ connected: true }); +}); diff --git a/src/app/api/nightscout/disconnect/route.ts b/src/app/api/nightscout/disconnect/route.ts new file mode 100644 index 000000000..3a9adff87 --- /dev/null +++ b/src/app/api/nightscout/disconnect/route.ts @@ -0,0 +1,50 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { markDisconnected } from "@/lib/integrations/status"; + +/** + * Disconnect the user's Nightscout instance (v1.17.0). + * + * Clears the encrypted URL + token and the private-host flag on `User`, and + * parks the `nightscout` integration ledger at `disconnected`. Already-ingested + * glucose rows are left in place — disconnecting stops future syncs, it does + * not delete the history the panel reads. + */ +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "nightscout.disconnect" } }); + + const rl = await checkRateLimit(`nightscout-disconnect:${user.id}`, 20, 60_000); + if (!rl.allowed) { + return apiError("Too many requests", 429, { + headers: rateLimitHeaders(rl), + }); + } + + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { nightscoutUrlEncrypted: true }, + }); + + if (!dbUser?.nightscoutUrlEncrypted) { + return apiError("No Nightscout connection", 404); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { + nightscoutUrlEncrypted: null, + nightscoutTokenEncrypted: null, + nightscoutAllowPrivateHost: false, + }, + }); + + await auditLog("nightscout.disconnect", { userId: user.id }); + await markDisconnected(user.id, "nightscout"); + + return apiSuccess({ disconnected: true }); +}); diff --git a/src/app/api/nightscout/status/route.ts b/src/app/api/nightscout/status/route.ts new file mode 100644 index 000000000..38096cd59 --- /dev/null +++ b/src/app/api/nightscout/status/route.ts @@ -0,0 +1,49 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess } from "@/lib/api-response"; +import { getIntegrationStatus } from "@/lib/integrations/status"; + +/** + * Nightscout connection status for the current user (v1.17.0). + * + * `configured` is whether an instance URL is stored (the connect marker — a + * fully-public instance has no token). The ledger snapshot (state / + * lastSuccessAt / lastError) comes off the shared `nightscout` IntegrationKey + * so the Settings card surfaces the same connected / error / parked pill the + * other integrations use. The stored URL is echoed back (host only is enough + * for the card, but the URL is the user's own and not a secret); the token is + * NEVER returned. + */ +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "nightscout.status" } }); + + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { + nightscoutUrlEncrypted: true, + nightscoutTokenEncrypted: true, + nightscoutAllowPrivateHost: true, + }, + }); + + const configured = !!dbUser?.nightscoutUrlEncrypted; + + if (!configured) { + return apiSuccess({ connected: false, configured: false }); + } + + const status = await getIntegrationStatus(user.id, "nightscout"); + + return apiSuccess({ + connected: true, + configured: true, + hasToken: !!dbUser?.nightscoutTokenEncrypted, + allowPrivateHost: dbUser?.nightscoutAllowPrivateHost ?? false, + state: status.state, + lastSuccessAt: status.lastSuccessAt, + lastAttemptAt: status.lastAttemptAt, + lastError: status.lastError, + }); +}); diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx index b26ef5596..29b574db1 100644 --- a/src/components/settings/__tests__/integrations-section.test.tsx +++ b/src/components/settings/__tests__/integrations-section.test.tsx @@ -131,9 +131,9 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => { }); const html = render(); - // Exactly one pill per card → 3 pills total (Withings, WHOOP, - // Fitbit / Google Health). The moodLog integration was removed. - expect(count(html, 'data-testid="integration-status-pill"')).toBe(3); + // Exactly one pill per card → 4 pills total (Withings, WHOOP, + // Fitbit / Google Health, Nightscout). The moodLog integration was removed. + expect(count(html, 'data-testid="integration-status-pill"')).toBe(4); // The redundant banner from v1.4.15 is gone. expect(html).not.toContain('data-testid="integration-status-banner"'); // Card-body "letzter Sync" repetition is gone — no diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index 5b777b5e8..6e700ec0b 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -7,6 +7,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { FitbitCard } from "@/components/settings/integrations/fitbit-card"; +import { NightscoutCard } from "@/components/settings/integrations/nightscout-card"; import { pickStatus, useIntegrationStatuses, @@ -124,6 +125,7 @@ export function IntegrationsSection() { + ); } diff --git a/src/components/settings/integrations/nightscout-card.tsx b/src/components/settings/integrations/nightscout-card.tsx new file mode 100644 index 000000000..c42bbfc91 --- /dev/null +++ b/src/components/settings/integrations/nightscout-card.tsx @@ -0,0 +1,301 @@ +"use client"; + +// v1.17.0 — Nightscout CGM integration card. Unlike WHOOP / Fitbit (OAuth, +// BYO-key), Nightscout is a URL + token the self-hoster pastes once: the user +// runs their own instance and HealthLog pulls continuous glucose off it. The +// card has its own status read (`/api/nightscout/status`) rather than the +// consolidated envelope. Warm copy, mobile-first, the private-network opt-in +// toggle maps to `nightscoutAllowPrivateHost`. + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Activity, Link2, Loader2, Save, Unlink } from "lucide-react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { PasswordInput } from "@/components/ui/password-input"; +import { Switch } from "@/components/ui/switch"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { + IntegrationStatusPill, + type IntegrationPillState, +} from "@/components/settings/integration-status-pill"; +import { apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { invalidateKeys, measurementDependentKeys, queryKeys } from "@/lib/query-keys"; + +import { IntegrationErrorMessage } from "./shared"; + +interface NightscoutStatus { + connected: boolean; + configured: boolean; + hasToken?: boolean; + allowPrivateHost?: boolean; + state?: "connected" | "error_transient" | "error_reauth" | "disconnected" | "parked"; + lastSuccessAt?: string | null; + lastAttemptAt?: string | null; + lastError?: string | null; +} + +/** Map the shared ledger state onto the pill's display state. */ +function pillStateFor(status: NightscoutStatus | undefined): IntegrationPillState { + if (!status?.connected) return "disconnected"; + switch (status.state) { + case "parked": + return "parked"; + case "error_reauth": + return "error"; + case "error_transient": + return "warning"; + default: + return "connected"; + } +} + +export function NightscoutCard({ enabled = true }: { enabled?: boolean }) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + + const [url, setUrl] = useState(""); + const [token, setToken] = useState(""); + const [allowPrivateHost, setAllowPrivateHost] = useState(false); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + + const { data: status } = useQuery({ + queryKey: queryKeys.nightscoutStatus(), + queryFn: async () => apiGet("/api/nightscout/status"), + enabled, + refetchOnWindowFocus: true, + }); + + const disconnect = useMutation({ + mutationFn: async () => { + await apiPost("/api/nightscout/disconnect"); + }, + onSuccess: () => { + setUrl(""); + setToken(""); + setAllowPrivateHost(false); + queryClient.invalidateQueries({ queryKey: queryKeys.nightscout() }); + }, + }); + + async function handleConnect(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setMsg(null); + setMsgType(null); + try { + const res = await apiFetchRaw("/api/nightscout/connect", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: url.trim(), + token: token.trim(), + allowPrivateHost, + }), + }); + if (res.ok) { + setMsg(t("settings.nightscoutConnected")); + setMsgType("success"); + setToken(""); + void invalidateKeys(queryClient, measurementDependentKeys); + queryClient.invalidateQueries({ queryKey: queryKeys.nightscout() }); + } else { + let detail = t("settings.nightscoutConnectFailed"); + try { + const json = await res.json(); + if (json.error) detail = json.error; + } catch { + // keep the generic message + } + setMsg(detail); + setMsgType("error"); + } + } catch { + setMsg(t("common.networkError")); + setMsgType("error"); + } + setSaving(false); + } + + const pillState = pillStateFor(status); + const errorMessage = + (pillState === "error" || pillState === "parked") && status?.lastError + ? status.lastError + : null; + + return ( +
+ + {t("settings.nightscoutTag")} + + } + description={

{t("settings.nightscoutDescription")}

} + status={ + + } + /> + +
+ +
+ {errorMessage && } + +

+ {t("settings.nightscoutHelp")} +

+ +
+
+ + setUrl(e.target.value)} + placeholder={ + status?.configured + ? t("settings.nightscoutUrlSavedPlaceholder") + : "https://your-site.up.railway.app" + } + maxLength={2048} + autoComplete="off" + inputMode="url" + spellCheck={false} + autoCapitalize="none" + enterKeyHint="next" + /> +
+
+ + setToken(e.target.value)} + placeholder={ + status?.hasToken + ? t("settings.nightscoutTokenSavedPlaceholder") + : t("settings.nightscoutTokenOptional") + } + maxLength={512} + autoComplete="off" + spellCheck={false} + autoCapitalize="none" + enterKeyHint="done" + /> +

+ {t("settings.nightscoutTokenHelp")} +

+
+ +
+
+ +

+ {t("settings.nightscoutPrivateHostHelp")} +

+
+ +
+ +
+ +
+ + {msg && ( +

+ {msg} +

+ )} +
+ + {status?.connected && ( + + + + + + + + {t("settings.nightscoutDisconnectTitle")} + + + {t("settings.nightscoutDisconnectDescription")} + + + + {t("common.cancel")} + disconnect.mutate()} + > + {t("settings.nightscoutDisconnect")} + + + + + )} +
+
+ ); +} diff --git a/src/lib/jobs/__tests__/nightscout-queues.test.ts b/src/lib/jobs/__tests__/nightscout-queues.test.ts new file mode 100644 index 000000000..35f8170d8 --- /dev/null +++ b/src/lib/jobs/__tests__/nightscout-queues.test.ts @@ -0,0 +1,47 @@ +/** + * v1.17.0 — pg-boss queue registration for the Nightscout CGM sync layer. + * + * Source-as-text guard (the reminder-worker pulls heavy infra we don't boot + * here): assert the queue constant + cron + handler wiring + allQueues + * membership are present — the fast guard against the v1.4.37 dead-queue class + * (a queue declared but never registered in `allQueues`, which silently never + * drains). + * + * Nightscout is poll-only (no webhook, no OAuth, no backfill queue): a single + * hourly `nightscout-sync` tick walks every configured instance via + * `syncUserNightscout`. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const source = + readFileSync(join(__dirname, "..", "reminder-worker.ts"), "utf8") + + readFileSync(join(__dirname, "..", "reminder", "nightscout-sync.ts"), "utf8"); + +describe("reminder-worker — Nightscout sync queue", () => { + it("declares the nightscout-sync queue constant", () => { + expect(source).toMatch( + /NIGHTSCOUT_SYNC_QUEUE\s*=\s*["']nightscout-sync["']/, + ); + }); + + it("registers the queue in allQueues (v1.4.37 dead-queue guard)", () => { + const block = /const allQueues = \[([\s\S]*?)\];/.exec(source); + expect(block).not.toBeNull(); + expect(block![1]!).toContain("NIGHTSCOUT_SYNC_QUEUE"); + }); + + it("schedules the hourly poll cron", () => { + expect(source).toMatch( + /\[NIGHTSCOUT_SYNC_QUEUE,\s*NIGHTSCOUT_SYNC_CRON\]/, + ); + }); + + it("registers a boss.work handler for the queue", () => { + expect(source).toMatch( + /boss\.work[\s\S]{0,160}NIGHTSCOUT_SYNC_QUEUE[\s\S]{0,160}handleNightscoutSync/, + ); + }); +}); diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index cac07ce26..9ec660a47 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -259,6 +259,10 @@ import { FitbitOAuthStateCleanupPayload, handleFitbitOAuthStateCleanup, } from "./reminder/fitbit-sync"; +import { + NightscoutSyncPayload, + handleNightscoutSync, +} from "./reminder/nightscout-sync"; import { handleMedicationInventoryExpire, handleIntakeAutoSkip, @@ -390,6 +394,13 @@ const FITBIT_SYNC_CRON = "8 * * * *"; // every hour at :08 const FITBIT_OAUTH_STATE_CLEANUP_QUEUE = "fitbit-oauth-state-cleanup"; const FITBIT_OAUTH_STATE_CLEANUP_CRON = "24 3 * * *"; +// v1.17.0 — Nightscout CGM poll sync. Poll-only (no webhook): one hourly tick +// pulls the recent SGV window per configured instance. :11 staggers off the +// WHOOP (:05), Fitbit (:08), and Withings sync ticks so the hourly polls don't +// pile up on one boss poll. +const NIGHTSCOUT_SYNC_QUEUE = "nightscout-sync"; + +const NIGHTSCOUT_SYNC_CRON = "11 * * * *"; // every hour at :11 // v1.15.19 — daily duplicate dose-slot dedup discovery tick. The boot-time // pass only ran on worker restart, so a cross-source duplicate slot created // between deploys (a pending REMINDER row plus a standalone API/WEB row on @@ -606,6 +617,11 @@ export async function startReminderWorker() { FITBIT_SYNC_QUEUE, FITBIT_BACKFILL_QUEUE, FITBIT_OAUTH_STATE_CLEANUP_QUEUE, + // v1.17.0 — Nightscout CGM poll sync. Poll-only (no webhook, no OAuth, no + // backfill queue — the hourly window walks the recent SGV set). The queue + // MUST be registered here or pg-boss never provisions it and the schedule + // below silently no-ops (the v1.4.37 dead-queue class). + NIGHTSCOUT_SYNC_QUEUE, OFFHOST_BACKUP_QUEUE, RESTORE_DRILL_QUEUE, HOST_METRIC_QUEUE, @@ -826,6 +842,9 @@ export async function startReminderWorker() { // daily 03:24 Europe/Berlin prune for expired Fitbit OAuth states. [FITBIT_SYNC_QUEUE, FITBIT_SYNC_CRON], [FITBIT_OAUTH_STATE_CLEANUP_QUEUE, FITBIT_OAUTH_STATE_CLEANUP_CRON], + // v1.17.0 — hourly Nightscout CGM poll (:11, staggered off the other sync + // ticks). + [NIGHTSCOUT_SYNC_QUEUE, NIGHTSCOUT_SYNC_CRON], [OFFHOST_BACKUP_QUEUE, OFFHOST_BACKUP_CRON], [RESTORE_DRILL_QUEUE, RESTORE_DRILL_CRON], [HOST_METRIC_QUEUE, HOST_METRIC_CRON], @@ -1018,6 +1037,14 @@ export async function startReminderWorker() { { localConcurrency: 1 }, handleFitbitOAuthStateCleanup, ); + // v1.17.0 — Nightscout CGM poll-cohort sync. The hourly cron tick (no + // `userId`) walks every configured instance; one user's unreachable host is + // warned, not fatal. + await boss.work( + NIGHTSCOUT_SYNC_QUEUE, + { localConcurrency: 1 }, + handleNightscoutSync, + ); await boss.work( GENERAL_STATUS_QUEUE, { localConcurrency: 1 }, diff --git a/src/lib/jobs/reminder/nightscout-sync.ts b/src/lib/jobs/reminder/nightscout-sync.ts new file mode 100644 index 000000000..a05d68d14 --- /dev/null +++ b/src/lib/jobs/reminder/nightscout-sync.ts @@ -0,0 +1,70 @@ +/** + * Nightscout hourly poll-cohort sync handler (v1.17.0). + * + * Poll-only: Nightscout has no outbound webhook to HealthLog, so the single + * hourly cron tick carries no `userId` and the handler iterates every user with + * a configured instance, re-syncing each via `syncUserNightscout`. One user's + * unreachable instance never starves the rest of the cohort — a per-user error + * is warned and the pass continues. + * + * Extracted from reminder-worker.ts, which owns the queue name, cron schedule, + * and boss.work registration. + */ +import { type Job } from "pg-boss"; + +import { recordError } from "@/lib/jobs/worker-status"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { syncUserNightscout } from "@/lib/nightscout/sync"; +import { getWorkerPrisma } from "./shared"; + +export interface NightscoutSyncPayload { + userId?: string; +} + +export async function handleNightscoutSync(jobs: Job[]) { + await withBackgroundEvent("job.nightscout_sync", async (evt) => { + const prisma = getWorkerPrisma(); + try { + const targets: Array<{ userId: string }> = []; + for (const job of jobs) { + if (job.data?.userId) targets.push({ userId: job.data.userId }); + } + if (targets.length === 0) { + // Cron tick — re-walk every configured instance. The URL column is + // the connect marker (a public instance stores no token). + const users = await prisma.user.findMany({ + where: { nightscoutUrlEncrypted: { not: null } }, + select: { id: true }, + }); + targets.push(...users.map((u) => ({ userId: u.id }))); + } + if (targets.length === 0) return; + + let usersSynced = 0; + let measurementsImported = 0; + for (const { userId } of targets) { + try { + measurementsImported += await syncUserNightscout(userId); + usersSynced++; + } catch (err) { + evt.addWarning( + `job.nightscout_sync failed for user ${userId}: ${err}`, + ); + } + } + + evt.setBackground({ + task_name: "job.nightscout_sync", + result: { + users_synced: usersSynced, + total: targets.length, + measurements_imported: measurementsImported, + }, + }); + } catch (err) { + evt.setError(err); + recordError(); + throw err; + } + }); +} diff --git a/src/lib/nightscout/__tests__/client.test.ts b/src/lib/nightscout/__tests__/client.test.ts new file mode 100644 index 000000000..b48d95bce --- /dev/null +++ b/src/lib/nightscout/__tests__/client.test.ts @@ -0,0 +1,219 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { SafeFetchError } from "@/lib/safe-fetch"; + +const { safeFetchMock } = vi.hoisted(() => ({ safeFetchMock: vi.fn() })); +vi.mock("@/lib/safe-fetch", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, safeFetch: safeFetchMock }; +}); + +import { + buildEntriesUrl, + fetchSgvEntries, + mapSgvEntryToMeasurement, + parseSgvEntries, + sha1Hex, + type NightscoutSgvEntry, +} from "../client"; + +function jsonResponse(status: number, body: unknown): Response { + return { + status, + ok: status >= 200 && status < 300, + json: async () => body, + } as unknown as Response; +} + +const SAMPLE: NightscoutSgvEntry[] = [ + { + _id: "abc123", + type: "sgv", + sgv: 112, + date: 1718000000000, + dateString: "2024-06-10T08:53:20.000Z", + }, + { + _id: "def456", + type: "sgv", + sgv: 98, + date: 1718000300000, + dateString: "2024-06-10T08:58:20.000Z", + }, +]; + +beforeEach(() => { + safeFetchMock.mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("parseSgvEntries", () => { + it("keeps only SGV rows with a numeric value and timestamp", () => { + const raw = [ + ...SAMPLE, + { _id: "mbg1", type: "mbg", mbg: 100, date: 1718000600000 }, + { _id: "cal1", type: "cal", date: 1718000700000 }, + { _id: "bad", type: "sgv", sgv: null, date: 1718000800000 }, + { _id: "nodate", type: "sgv", sgv: 120 }, + ]; + const parsed = parseSgvEntries(raw); + expect(parsed).toHaveLength(2); + expect(parsed[0]!.sgv).toBe(112); + expect(parsed[1]!.sgv).toBe(98); + }); + + it("returns an empty array for a non-array payload", () => { + expect(parseSgvEntries(null)).toEqual([]); + expect(parseSgvEntries({})).toEqual([]); + expect(parseSgvEntries("nope")).toEqual([]); + }); +}); + +describe("mapSgvEntryToMeasurement", () => { + it("maps an SGV entry to a BLOOD_GLUCOSE mg/dL measurement", () => { + const m = mapSgvEntryToMeasurement(SAMPLE[0]!); + expect(m.type).toBe("BLOOD_GLUCOSE"); + expect(m.unit).toBe("mg/dL"); + expect(m.value).toBe(112); + expect(m.measuredAt.getTime()).toBe(1718000000000); + }); + + it("derives a stable externalId so re-sync is idempotent", () => { + const a = mapSgvEntryToMeasurement(SAMPLE[0]!); + const b = mapSgvEntryToMeasurement(SAMPLE[0]!); + expect(a.externalId).toBe(b.externalId); + expect(a.externalId).toContain("abc123"); + }); + + it("falls back to the timestamp when the entry has no _id", () => { + const m = mapSgvEntryToMeasurement({ + type: "sgv", + sgv: 90, + date: 1718000000000, + }); + expect(m.externalId).toContain("1718000000000"); + }); +}); + +describe("buildEntriesUrl", () => { + it("requests SGV entries with the requested count", () => { + const url = buildEntriesUrl("https://ns.example.com", 50); + expect(url).toContain("/api/v1/entries.json"); + expect(url).toContain("count=50"); + expect(url).toContain("type=sgv"); + }); + + it("normalises a trailing slash on the base URL", () => { + const url = buildEntriesUrl("https://ns.example.com/", 10); + expect(url).not.toContain("com//api"); + }); + + it("appends the token as a query param when provided", () => { + const url = buildEntriesUrl("https://ns.example.com", 10, "tok-abc"); + expect(url).toContain("token=tok-abc"); + }); +}); + +describe("sha1Hex", () => { + it("hashes the API secret as Nightscout expects (api-secret header)", () => { + // Known SHA1 of "secret". + expect(sha1Hex("secret")).toBe( + "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", + ); + }); +}); + +describe("fetchSgvEntries — SSRF gating", () => { + it("requires a public host by default (public instance)", async () => { + safeFetchMock.mockResolvedValue(jsonResponse(200, SAMPLE)); + await fetchSgvEntries({ + baseUrl: "https://ns.example.com", + token: "", + count: 10, + allowPrivateHost: false, + }); + expect(safeFetchMock).toHaveBeenCalledTimes(1); + const opts = safeFetchMock.mock.calls[0]![2]; + expect(opts.requirePublicHost).toBe(true); + }); + + it("relaxes the public-host pin only when the private-host flag is set", async () => { + safeFetchMock.mockResolvedValue(jsonResponse(200, SAMPLE)); + await fetchSgvEntries({ + baseUrl: "http://192.168.1.50:1337", + token: "", + count: 10, + allowPrivateHost: true, + }); + const opts = safeFetchMock.mock.calls[0]![2]; + expect(opts.requirePublicHost).toBe(false); + }); + + it("pins redirect:manual + a timeout on every call", async () => { + safeFetchMock.mockResolvedValue(jsonResponse(200, SAMPLE)); + await fetchSgvEntries({ + baseUrl: "https://ns.example.com", + token: "", + count: 10, + allowPrivateHost: false, + }); + const opts = safeFetchMock.mock.calls[0]![2]; + expect(opts.followRedirects).toBeFalsy(); + expect(opts.timeoutMs).toBeGreaterThan(0); + }); + + it("surfaces a private-host rejection from safeFetch", async () => { + safeFetchMock.mockRejectedValue( + new SafeFetchError("refused", "private_host"), + ); + await expect( + fetchSgvEntries({ + baseUrl: "http://10.0.0.5", + token: "", + count: 10, + allowPrivateHost: false, + }), + ).rejects.toThrow(); + }); + + it("sends the api-secret SHA1 header when a token is supplied via header auth", async () => { + safeFetchMock.mockResolvedValue(jsonResponse(200, SAMPLE)); + await fetchSgvEntries({ + baseUrl: "https://ns.example.com", + token: "secret", + count: 10, + allowPrivateHost: false, + authMode: "header", + }); + const init = safeFetchMock.mock.calls[0]![1]; + const headers = init.headers as Record; + expect(headers["api-secret"]).toBe(sha1Hex("secret")); + }); + + it("throws on a non-2xx upstream so the caller can classify", async () => { + safeFetchMock.mockResolvedValue(jsonResponse(401, { status: 401 })); + await expect( + fetchSgvEntries({ + baseUrl: "https://ns.example.com", + token: "wrong", + count: 10, + allowPrivateHost: false, + }), + ).rejects.toThrow(); + }); + + it("parses SGV rows from a successful response", async () => { + safeFetchMock.mockResolvedValue(jsonResponse(200, SAMPLE)); + const entries = await fetchSgvEntries({ + baseUrl: "https://ns.example.com", + token: "", + count: 10, + allowPrivateHost: false, + }); + expect(entries).toHaveLength(2); + expect(entries[0]!.sgv).toBe(112); + }); +}); diff --git a/src/lib/nightscout/__tests__/sync.test.ts b/src/lib/nightscout/__tests__/sync.test.ts new file mode 100644 index 000000000..f65bedd04 --- /dev/null +++ b/src/lib/nightscout/__tests__/sync.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + fetchSgvEntriesMock, + getCredsMock, + upsertMock, + recordSuccessMock, + recordFailureMock, + recomputeMock, + invalidateMock, +} = vi.hoisted(() => ({ + fetchSgvEntriesMock: vi.fn(), + getCredsMock: vi.fn(), + upsertMock: vi.fn(), + recordSuccessMock: vi.fn(), + recordFailureMock: vi.fn(), + recomputeMock: vi.fn(), + invalidateMock: vi.fn(), +})); + +vi.mock("../credentials", () => ({ + getUserNightscoutCredentials: getCredsMock, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { measurement: { upsert: upsertMock } }, +})); + +vi.mock("@/lib/integrations/status", () => ({ + recordSyncSuccess: recordSuccessMock, + recordSyncFailure: recordFailureMock, +})); + +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + collapseToTypeDayKeys: ( + rows: Array<{ type: string; measuredAt: Date }>, + ) => rows.map((r) => ({ type: r.type, measuredAt: r.measuredAt })), + recomputeBucketsForMeasurement: recomputeMock, +})); + +vi.mock("@/lib/insights/comprehensive-generate", () => ({ + invalidateStatusInsightsForTypes: invalidateMock, +})); + +vi.mock("../client", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchSgvEntries: fetchSgvEntriesMock }; +}); + +import { syncUserNightscout } from "../sync"; +import { NightscoutApiError } from "../client"; + +const CREDS = { + baseUrl: "https://ns.example.com", + token: "tok", + allowPrivateHost: false, +}; + +const ENTRY_A = { id: "abc", sgv: 112, date: 1718000000000 }; +const ENTRY_B = { id: "def", sgv: 98, date: 1718000300000 }; + +beforeEach(() => { + fetchSgvEntriesMock.mockReset(); + getCredsMock.mockReset(); + upsertMock.mockReset(); + recordSuccessMock.mockReset(); + recordFailureMock.mockReset(); + recomputeMock.mockReset(); + invalidateMock.mockReset(); + getCredsMock.mockResolvedValue(CREDS); + upsertMock.mockResolvedValue({}); + invalidateMock.mockResolvedValue(undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("syncUserNightscout", () => { + it("returns 0 and records nothing when the user is not configured", async () => { + getCredsMock.mockResolvedValue(null); + const n = await syncUserNightscout("u1"); + expect(n).toBe(0); + expect(fetchSgvEntriesMock).not.toHaveBeenCalled(); + expect(recordSuccessMock).not.toHaveBeenCalled(); + }); + + it("writes each SGV entry as a BLOOD_GLUCOSE mg/dL measurement", async () => { + fetchSgvEntriesMock.mockResolvedValue([ENTRY_A, ENTRY_B]); + const n = await syncUserNightscout("u1"); + expect(n).toBe(2); + expect(upsertMock).toHaveBeenCalledTimes(2); + const first = upsertMock.mock.calls[0]![0]; + expect(first.create.type).toBe("BLOOD_GLUCOSE"); + expect(first.create.unit).toBe("mg/dL"); + expect(first.create.source).toBe("NIGHTSCOUT"); + expect(first.create.value).toBe(112); + }); + + it("keys the upsert on (userId,type,source,externalId) for idempotency", async () => { + fetchSgvEntriesMock.mockResolvedValue([ENTRY_A]); + await syncUserNightscout("u1"); + const where = upsertMock.mock.calls[0]![0].where + .userId_type_source_externalId; + expect(where).toEqual({ + userId: "u1", + type: "BLOOD_GLUCOSE", + source: "NIGHTSCOUT", + externalId: "ns:abc", + }); + }); + + it("is first-write-wins — the update branch never overwrites value", async () => { + fetchSgvEntriesMock.mockResolvedValue([ENTRY_A]); + await syncUserNightscout("u1"); + const arg = upsertMock.mock.calls[0]![0]; + // An immutable sample: update must not carry a new value/measuredAt. + expect(arg.update).not.toHaveProperty("value"); + }); + + it("records sync success after a clean pass", async () => { + fetchSgvEntriesMock.mockResolvedValue([ENTRY_A]); + await syncUserNightscout("u1"); + expect(recordSuccessMock).toHaveBeenCalledWith("u1", "nightscout"); + }); + + it("records a failure and rethrows when the instance is unreachable", async () => { + fetchSgvEntriesMock.mockRejectedValue( + new NightscoutApiError("Nightscout responded 401", 401), + ); + await expect(syncUserNightscout("u1")).rejects.toThrow(); + expect(recordFailureMock).toHaveBeenCalledTimes(1); + const arg = recordFailureMock.mock.calls[0]![0]; + expect(arg.integration).toBe("nightscout"); + expect(recordSuccessMock).not.toHaveBeenCalled(); + }); + + it("classifies a 401/403 as reauth_required", async () => { + fetchSgvEntriesMock.mockRejectedValue( + new NightscoutApiError("Nightscout responded 403", 403), + ); + await expect(syncUserNightscout("u1")).rejects.toThrow(); + expect(recordFailureMock.mock.calls[0]![0].kind).toBe("reauth_required"); + }); + + it("classifies a network error as transient", async () => { + fetchSgvEntriesMock.mockRejectedValue(new Error("ECONNREFUSED")); + await expect(syncUserNightscout("u1")).rejects.toThrow(); + expect(recordFailureMock.mock.calls[0]![0].kind).toBe("transient"); + }); + + it("recomputes rollup buckets for the imported readings", async () => { + fetchSgvEntriesMock.mockResolvedValue([ENTRY_A, ENTRY_B]); + await syncUserNightscout("u1"); + expect(recomputeMock).toHaveBeenCalled(); + expect(invalidateMock).toHaveBeenCalled(); + }); +}); diff --git a/src/lib/nightscout/client.ts b/src/lib/nightscout/client.ts new file mode 100644 index 000000000..daf2ab22c --- /dev/null +++ b/src/lib/nightscout/client.ts @@ -0,0 +1,231 @@ +/** + * Nightscout CGM client (v1.17.0). + * + * Nightscout is a per-user SELF-HOSTED continuous-glucose hub: the self-hoster + * runs their own instance (Railway / Heroku / Fly / a LAN box) and HealthLog + * is the CLIENT pulling SGV (sensor glucose value) entries off it. This module + * is the single source of truth for the wire shape, auth, the SGV parse, and + * the entry→Measurement mapping. No SDK — hand-rolled fetch over `safeFetch`, + * mirroring the Withings / WHOOP / Fitbit clients. + * + * SSRF (critical): every outbound call routes through `safeFetch` with + * `requirePublicHost: true` BY DEFAULT — public instances are the norm, and a + * malicious URL pointing at `169.254.169.254` / an RFC1918 admin panel must be + * refused. Only when the user has flipped `nightscoutAllowPrivateHost` (the + * explicit self-hoster opt-in for a LAN instance) does the public-host pin + * come off. Redirects are pinned to `manual` and a timeout is always composed + * so a tar-pit instance can't hold a worker. + * + * Auth: Nightscout accepts the API secret two ways — the `api-secret` HTTP + * header carrying the SHA1 hex of the secret, OR a `token` query param (a + * role-scoped access token). The stored field can hold either; default to the + * token query param (works for both role tokens and a raw secret on modern + * instances) and offer the SHA1 header mode for classic `API_SECRET` setups. + */ +import { createHash } from "node:crypto"; + +import type { MeasurementType } from "@/generated/prisma/client"; +import { safeFetch } from "@/lib/safe-fetch"; + +/** Default per-request timeout for a Nightscout fetch. */ +const NIGHTSCOUT_TIMEOUT_MS = 15_000; + +/** Canonical glucose storage unit. Nightscout SGV is always mg/dL on the wire. */ +export const NIGHTSCOUT_GLUCOSE_UNIT = "mg/dL" as const; + +/** How auth is presented to the instance. */ +export type NightscoutAuthMode = "token" | "header"; + +/** + * One raw SGV entry as Nightscout's `/api/v1/entries.json` returns it. Only + * the fields we read are typed; the upstream payload carries many more + * (`direction`, `noise`, `device`, …) that we deliberately ignore. + */ +export interface NightscoutSgvEntry { + _id?: string; + type?: string; + /** Sensor glucose value in mg/dL. */ + sgv?: number | null; + /** Epoch milliseconds of the reading. */ + date?: number | null; + dateString?: string; +} + +/** A parsed, validated SGV entry (numeric value + millisecond timestamp). */ +export interface ParsedSgvEntry { + id: string | null; + sgv: number; + date: number; +} + +/** The mapped, source-tagged measurement a sync writes for one SGV entry. */ +export interface NightscoutMeasurement { + type: MeasurementType; + value: number; + unit: typeof NIGHTSCOUT_GLUCOSE_UNIT; + measuredAt: Date; + externalId: string; +} + +export interface FetchSgvOptions { + baseUrl: string; + token: string; + count: number; + allowPrivateHost: boolean; + /** Defaults to `token` (query param). `header` uses the api-secret SHA1. */ + authMode?: NightscoutAuthMode; + /** Override the default timeout (tests). */ + timeoutMs?: number; +} + +/** SHA1 hex of a string — the `api-secret` header format Nightscout expects. */ +export function sha1Hex(value: string): string { + return createHash("sha1").update(value, "utf8").digest("hex"); +} + +/** + * Strip a trailing slash so URL concatenation never produces `com//api`. + */ +function normaliseBase(baseUrl: string): string { + return baseUrl.replace(/\/+$/, ""); +} + +/** + * Build the `/api/v1/entries.json` URL for `count` SGV entries. Pins + * `type=sgv` upstream so non-glucose rows (meter BG, calibration) never reach + * the client; the parse re-checks anyway. When `token` is supplied it is + * appended as the `token` query param (the modern Nightscout auth path). + */ +export function buildEntriesUrl( + baseUrl: string, + count: number, + token?: string, +): string { + const url = new URL(`${normaliseBase(baseUrl)}/api/v1/entries.json`); + url.searchParams.set("count", String(count)); + url.searchParams.set("type", "sgv"); + if (token) url.searchParams.set("token", token); + return url.toString(); +} + +/** + * Keep only well-formed SGV rows: `type === "sgv"` (or untyped legacy rows + * carrying an `sgv`), a finite numeric value, and a millisecond `date`. Drops + * meter-BG (`mbg`) and calibration (`cal`) rows, and any row missing a value + * or timestamp. Returns `[]` for a non-array payload (a Nightscout error + * object, an HTML login page, …) so a misconfigured instance can't crash the + * sync. + */ +export function parseSgvEntries(payload: unknown): ParsedSgvEntry[] { + if (!Array.isArray(payload)) return []; + const out: ParsedSgvEntry[] = []; + for (const raw of payload as NightscoutSgvEntry[]) { + if (!raw || typeof raw !== "object") continue; + if (raw.type && raw.type !== "sgv") continue; + const sgv = raw.sgv; + const date = raw.date; + if (typeof sgv !== "number" || !Number.isFinite(sgv)) continue; + if (typeof date !== "number" || !Number.isFinite(date)) continue; + out.push({ + id: typeof raw._id === "string" ? raw._id : null, + sgv, + date, + }); + } + return out; +} + +/** + * Map one SGV entry to a `BLOOD_GLUCOSE` mg/dL measurement. The `externalId` + * is `ns:` when the entry carries Nightscout's `_id`, falling back to + * `ns:date:` — both stable per reading, so a re-sync collapses onto + * the existing row via the `(userId, type, source, externalId)` unique. mg/dL + * is stored canonical (Nightscout always reports mg/dL; the display unit is a + * user preference resolved elsewhere). + */ +export function mapSgvEntryToMeasurement( + entry: NightscoutSgvEntry, +): NightscoutMeasurement { + const externalId = entry._id + ? `ns:${entry._id}` + : `ns:date:${entry.date ?? 0}`; + return { + type: "BLOOD_GLUCOSE", + value: entry.sgv as number, + unit: NIGHTSCOUT_GLUCOSE_UNIT, + measuredAt: new Date(entry.date as number), + externalId, + }; +} + +/** Externalid for a parsed entry — the canonical idempotency key. */ +export function externalIdFor(entry: ParsedSgvEntry): string { + return entry.id ? `ns:${entry.id}` : `ns:date:${entry.date}`; +} + +/** + * Raised when an instance answers non-2xx or is unreachable. The connect + * route + sync classifier switch on `status` to tell "wrong token" (401/403) + * from "instance down" (network) and surface a clear message to the user. + */ +export class NightscoutApiError extends Error { + readonly status: number | null; + constructor(message: string, status: number | null) { + super(message); + this.name = "NightscoutApiError"; + this.status = status; + } +} + +/** + * Fetch the most recent `count` SGV entries from the user's instance. + * + * SSRF floor: `requirePublicHost` is `true` unless the user opted a private + * host in via `allowPrivateHost`. Redirects pinned to manual + a timeout + * always composed. Throws `NightscoutApiError` on a non-2xx response; re-raises + * a `SafeFetchError` (private host / timeout / network) unchanged so the caller + * can classify it. + */ +export async function fetchSgvEntries( + opts: FetchSgvOptions, +): Promise { + const authMode: NightscoutAuthMode = opts.authMode ?? "token"; + const headers: Record = { Accept: "application/json" }; + let url: string; + + if (authMode === "header" && opts.token) { + // Classic `API_SECRET` instances: the SHA1 hex in the api-secret header. + headers["api-secret"] = sha1Hex(opts.token); + url = buildEntriesUrl(opts.baseUrl, opts.count); + } else { + // Token query-param path (role-scoped access token; the default). + url = buildEntriesUrl(opts.baseUrl, opts.count, opts.token || undefined); + } + + const res = await safeFetch( + url, + { method: "GET", headers }, + { + // Default-on public-host pin; relaxed ONLY when the user opted a + // private/LAN instance in. `followRedirects` defaults false (manual). + requirePublicHost: !opts.allowPrivateHost, + timeoutMs: opts.timeoutMs ?? NIGHTSCOUT_TIMEOUT_MS, + }, + ); + + if (res.status < 200 || res.status >= 300) { + throw new NightscoutApiError( + `Nightscout responded ${res.status}`, + res.status, + ); + } + + let body: unknown; + try { + body = await res.json(); + } catch { + throw new NightscoutApiError("Nightscout returned a non-JSON body", null); + } + + return parseSgvEntries(body); +} diff --git a/src/lib/nightscout/credentials.ts b/src/lib/nightscout/credentials.ts new file mode 100644 index 000000000..4e704a455 --- /dev/null +++ b/src/lib/nightscout/credentials.ts @@ -0,0 +1,46 @@ +/** + * Per-user Nightscout connection credentials (v1.17.0). + * + * Unlike WHOOP / Fitbit (OAuth, with a dedicated `*Connection` row), Nightscout + * is a URL + token the user pastes once. Both are stored encrypted on `User` + * (`nightscoutUrlEncrypted` / `nightscoutTokenEncrypted`) via the fail-closed + * `*Encrypted` convention; the private-host opt-in is the plaintext boolean + * `nightscoutAllowPrivateHost`. This helper is the single read+decrypt path the + * sync + status + connect surfaces share. + */ +import { prisma } from "@/lib/db"; +import { decrypt } from "@/lib/crypto"; + +export interface NightscoutCredentials { + baseUrl: string; + token: string; + allowPrivateHost: boolean; +} + +/** + * Fetch and decrypt the user's Nightscout connection. Returns null when the + * URL is not configured (a token-less, fully-public instance still stores a + * URL, so the URL is the configured-marker, not the token). + */ +export async function getUserNightscoutCredentials( + userId: string, +): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + nightscoutUrlEncrypted: true, + nightscoutTokenEncrypted: true, + nightscoutAllowPrivateHost: true, + }, + }); + + if (!user?.nightscoutUrlEncrypted) return null; + + return { + baseUrl: decrypt(user.nightscoutUrlEncrypted), + token: user.nightscoutTokenEncrypted + ? decrypt(user.nightscoutTokenEncrypted) + : "", + allowPrivateHost: user.nightscoutAllowPrivateHost, + }; +} diff --git a/src/lib/nightscout/sync.ts b/src/lib/nightscout/sync.ts new file mode 100644 index 000000000..7d0c438db --- /dev/null +++ b/src/lib/nightscout/sync.ts @@ -0,0 +1,176 @@ +/** + * Nightscout CGM sync (v1.17.0). + * + * Pulls the most recent SGV entries from the user's self-hosted Nightscout + * instance and writes each as a `BLOOD_GLUCOSE` mg/dL Measurement tagged + * `source = NIGHTSCOUT`. This is the CGM density that makes the clinical + * glucose panel (TIR / GMI) real — a continuous stream rather than the manual + * spot readings. + * + * Idempotency: each row's `externalId` is derived from Nightscout's `_id` (or + * the reading's epoch ms), and the write is FIRST-WRITE-WINS — a CGM sample is + * an immutable canonical reading, so the `(userId, type, source, externalId)` + * unique collapses a re-sync onto the existing row and the `update` branch is a + * no-op (no value overwrite). This mirrors the Apple Health immutable-sample + * contract, not the WHOOP re-score overwrite. + * + * Status bookkeeping rides the shared integration ledger (`nightscout` key): a + * clean pass stamps `recordSyncSuccess`; an unreachable instance / wrong token + * records a classified `recordSyncFailure` and rethrows so the cohort runner + * can warn-and-continue. + */ +import { prisma } from "@/lib/db"; +import type { MeasurementType } from "@/generated/prisma/client"; +import { getEvent } from "@/lib/logging/context"; +import { + recordSyncFailure, + recordSyncSuccess, + type FailureKind, +} from "@/lib/integrations/status"; +import { + collapseToTypeDayKeys, + recomputeBucketsForMeasurement, +} from "@/lib/rollups/measurement-rollups"; +import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; +import { + externalIdFor, + fetchSgvEntries, + NightscoutApiError, + NIGHTSCOUT_GLUCOSE_UNIT, + type ParsedSgvEntry, +} from "./client"; +import { getUserNightscoutCredentials } from "./credentials"; + +/** + * How many recent SGV entries to pull per sync tick. A CGM emits one reading + * every ~5 min (≈288/day); 576 covers ~48 h of catch-up so a missed hourly + * tick (or a brief outage) still backfills on the next run without paging the + * full instance. The far-history backfill walks larger windows separately. + */ +export const NIGHTSCOUT_SYNC_COUNT = 576; + +/** Map a Nightscout HTTP status / network error onto the shared ledger kind. */ +export function classifyNightscoutFailure(err: unknown): FailureKind { + if (err instanceof NightscoutApiError && err.status != null) { + // Wrong / missing token, or a token whose role can't read SGV. + if (err.status === 401 || err.status === 403) return "reauth_required"; + // 4xx other than auth = a contract / config problem worth surfacing. + if (err.status >= 400 && err.status < 500) return "persistent"; + } + // 5xx, timeout, DNS, connection refused — retry on the next tick. + return "transient"; +} + +/** + * Sync one user's recent SGV entries. Returns the count of rows written + * (newly inserted or re-confirmed). A user with no configured instance is a + * clean no-op (returns 0, touches no status row). + */ +export async function syncUserNightscout( + userId: string, + opts: { count?: number } = {}, +): Promise { + const creds = await getUserNightscoutCredentials(userId); + if (!creds) return 0; + + let entries: ParsedSgvEntry[]; + try { + entries = await fetchSgvEntries({ + baseUrl: creds.baseUrl, + token: creds.token, + count: opts.count ?? NIGHTSCOUT_SYNC_COUNT, + allowPrivateHost: creds.allowPrivateHost, + }); + } catch (err) { + await recordSyncFailure({ + userId, + integration: "nightscout", + kind: classifyNightscoutFailure(err), + message: err instanceof Error ? err.message : String(err), + errorCode: + err instanceof NightscoutApiError && err.status != null + ? String(err.status) + : undefined, + }); + throw err; + } + + const imported = await upsertNightscoutEntries(userId, entries); + + await recordSyncSuccess(userId, "nightscout"); + return imported; +} + +/** + * Insert each SGV entry as an immutable BLOOD_GLUCOSE mg/dL row, then fold the + * rollup tier + invalidate status-insight caches once at the end (mirrors the + * Withings / WHOOP sync tail). First-write-wins: the `update` branch is empty + * so a re-sync of the same reading is a no-op rather than a rewrite. Best-effort + * on the per-row write + the rollup fold — one bad row never aborts the pass. + */ +export async function upsertNightscoutEntries( + userId: string, + entries: ParsedSgvEntry[], +): Promise { + if (entries.length === 0) return 0; + + const type = "BLOOD_GLUCOSE" as MeasurementType; + let imported = 0; + const touched: Array<{ type: MeasurementType; measuredAt: Date }> = []; + + for (const entry of entries) { + const externalId = externalIdFor(entry); + const measuredAt = new Date(entry.date); + try { + await prisma.measurement.upsert({ + where: { + userId_type_source_externalId: { + userId, + type, + source: "NIGHTSCOUT", + externalId, + }, + }, + create: { + userId, + type, + source: "NIGHTSCOUT", + value: entry.sgv, + unit: NIGHTSCOUT_GLUCOSE_UNIT, + measuredAt, + externalId, + }, + // First-write-wins: a CGM sample is immutable, so a re-sync of the + // same reading must not rewrite the stored value or measuredAt. + update: {}, + }); + touched.push({ type, measuredAt }); + imported++; + } catch (err) { + getEvent()?.addWarning( + `nightscout: failed to upsert measurement: ${err}`, + ); + } + } + + try { + const keys = collapseToTypeDayKeys(touched); + for (const k of keys) { + await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt); + } + invalidateStatusInsightsForTypes( + userId, + keys.map((k) => k.type), + ).catch((err) => { + getEvent()?.addWarning( + `nightscout: status-insight invalidate failed for ${userId}: ${err}`, + ); + }); + } catch (err) { + getEvent()?.addWarning( + `nightscout: rollup fold failed for ${userId}: ${err}`, + ); + } + + return imported; +} diff --git a/src/lib/query-keys/integrations.ts b/src/lib/query-keys/integrations.ts index 60a309111..adcbf3c6c 100644 --- a/src/lib/query-keys/integrations.ts +++ b/src/lib/query-keys/integrations.ts @@ -28,6 +28,12 @@ export const integrationKeys = { */ fitbitStatus: () => ["fitbit", "status"] as const, + // v1.17.0 — Nightscout CGM integration card. Self-contained status read + // (not the consolidated /api/integrations/status envelope), so the + // connect/disconnect mutations invalidate this single key. + nightscout: () => ["nightscout"] as const, + nightscoutStatus: () => ["nightscout", "status"] as const, + moodlogStatus: () => ["moodlog-status"] as const, integrationsStatus: () => ["integrations", "status"] as const, diff --git a/src/lib/validations/nightscout.ts b/src/lib/validations/nightscout.ts new file mode 100644 index 000000000..8b0e5a77d --- /dev/null +++ b/src/lib/validations/nightscout.ts @@ -0,0 +1,41 @@ +import { z } from "zod/v4"; + +/** + * Per-user Nightscout connection input (v1.17.0). The self-hoster points + * HealthLog at their own Nightscout instance (Railway / Heroku / Fly / a LAN + * box) and pastes the instance's API token. Both fields are stored encrypted + * on `User` (`nightscoutUrlEncrypted` / `nightscoutTokenEncrypted`); the + * private-host opt-in maps to `nightscoutAllowPrivateHost`. + * + * The URL is validated to be a parseable http(s) origin at input time. The + * SSRF floor (public-host requirement, unless `allowPrivateHost` is set) is + * enforced at fetch time by the client through `safeFetch` — input-shape + * validation here only rejects garbage early so the connect route can return + * a clear 422 instead of letting a malformed string reach the egress layer. + */ +export const nightscoutConnectSchema = z.object({ + // Trimmed: a trailing space / newline from a copy button reaches the + // instance verbatim and produces an opaque DNS failure. + url: z + .string() + .trim() + .min(1) + .max(2048) + .refine((value) => { + try { + const u = new URL(value); + return u.protocol === "http:" || u.protocol === "https:"; + } catch { + return false; + } + }, "Must be a valid http(s) URL"), + // Nightscout API tokens are opaque strings (a role-scoped access token or + // the raw `API_SECRET`). Optional: a fully public instance with + // `AUTH_DEFAULT_ROLES=readable` serves SGV entries without a token. + token: z.string().trim().max(512).optional().default(""), + // The explicit self-hoster opt-in to allow a private / LAN Nightscout host + // past the public-host SSRF floor. Defaults false (public instances). + allowPrivateHost: z.boolean().optional().default(false), +}); + +export type NightscoutConnectInput = z.infer;