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
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 19:06:56 +02:00
1 parent 4884f019b7
commit f5d0b2f696
23 files changed
+1803 -9

No files matched your search

@@ -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: <T extends (...args: unknown[]) => 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<typeof vi.fn>;
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<RouteResult>;
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();
});
});
+103
View File
@@ -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 });
});
@@ -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 });
});
+49
View File
@@ -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,
});
});