mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user