diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 21a24b6c8..67dfa39f3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -13879,6 +13879,18 @@ components: anyOf: - type: number - type: "null" + bpInTargetCount90: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + bpInTargetSpanDays90: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" glucoseByContext: type: object propertyNames: @@ -13893,6 +13905,8 @@ components: - bpInTargetPctAllTime - bpInTargetPctPriorMonth - bpInTargetPctPriorYear + - bpInTargetCount90 + - bpInTargetSpanDays90 - glucoseByContext - glucoseClinical additionalProperties: false @@ -14126,7 +14140,6 @@ components: - type: "null" isSpotEstimate: type: boolean - const: true required: - stillLearning - stillLearningReason @@ -14141,8 +14154,9 @@ components: - advanced - isSpotEstimate additionalProperties: false - description: Server-authoritative glucose clinical panel over the trailing 30-day window. Every figure is a SPOT-READING - ESTIMATE (a % of readings), not a CGM time-in-range AGP — `isSpotEstimate` is always true and `stillLearning` + description: Server-authoritative glucose clinical panel over the trailing 30-day window. Figures from sparse spot data + are a SPOT-READING ESTIMATE (a % of readings), not a CGM time-in-range AGP; `isSpotEstimate` is derived from + reading density (true below ~hourly, false for a continuous CGM stream such as Nightscout) and `stillLearning` gates assertion when the window has too few readings or too short a span. `distribution` carries the Battelino 2019 TIR/TBR/TAR fractions (level-2 nested in level-1) plus minutes-of-a-day equivalents; `gmi` (Bergenstal 2018) + `estimatedA1c` (Nathan 2008 ADAG) derive from the mean; `variability` is SD + CV% with the Monnier 2017 diff --git a/prisma/migrations/0161_v1170_reorder_lead_days/migration.sql b/prisma/migrations/0161_v1170_reorder_lead_days/migration.sql index ac0db91e6..d751dd86b 100644 --- a/prisma/migrations/0161_v1170_reorder_lead_days/migration.sql +++ b/prisma/migrations/0161_v1170_reorder_lead_days/migration.sql @@ -16,4 +16,4 @@ -- up without a migration of its own. ALTER TABLE "medications" - ADD COLUMN "reorder_lead_days" INTEGER; + ADD COLUMN IF NOT EXISTS "reorder_lead_days" INTEGER; diff --git a/src/app/api/oura/disconnect/__tests__/route.test.ts b/src/app/api/oura/disconnect/__tests__/route.test.ts new file mode 100644 index 000000000..fb7802709 --- /dev/null +++ b/src/app/api/oura/disconnect/__tests__/route.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { rateLimitMock } = vi.hoisted(() => ({ 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: { findUnique: vi.fn(), update: vi.fn() } }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() })); + +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 }), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; + +const userFind = prisma.user.findUnique as ReturnType; + +type RouteResult = { data: unknown; error: string | null; status: number }; +const post = POST as unknown as () => Promise; + +beforeEach(() => { + vi.clearAllMocks(); + rateLimitMock.mockResolvedValue({ allowed: true, remaining: 19, resetAt: 0 }); + userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "enc:tok" }); +}); + +describe("POST /api/oura/disconnect", () => { + it("disconnects a connected user", async () => { + const res = await post(); + expect(res.status).toBe(200); + expect(res.data).toEqual({ disconnected: true }); + }); + + it("rate-limits the disconnect surface (429 on bucket exhaustion)", async () => { + rateLimitMock.mockResolvedValue({ allowed: false, remaining: 0, resetAt: 0 }); + const res = await post(); + expect(res.status).toBe(429); + // The user lookup never runs once the bucket is exhausted. + expect(userFind).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/oura/disconnect/route.ts b/src/app/api/oura/disconnect/route.ts index 6eb47e33a..a5daf5f97 100644 --- a/src/app/api/oura/disconnect/route.ts +++ b/src/app/api/oura/disconnect/route.ts @@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { markDisconnected } from "@/lib/integrations/status"; /** @@ -16,6 +17,13 @@ export const POST = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "oura.disconnect" } }); + const rl = await checkRateLimit(`oura-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: { ouraAccessTokenEncrypted: true }, diff --git a/src/app/api/polar/disconnect/__tests__/route.test.ts b/src/app/api/polar/disconnect/__tests__/route.test.ts new file mode 100644 index 000000000..db7fb375e --- /dev/null +++ b/src/app/api/polar/disconnect/__tests__/route.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { rateLimitMock } = vi.hoisted(() => ({ 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: { findUnique: vi.fn(), update: vi.fn() } }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() })); + +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 }), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; + +const userFind = prisma.user.findUnique as ReturnType; + +type RouteResult = { data: unknown; error: string | null; status: number }; +const post = POST as unknown as () => Promise; + +beforeEach(() => { + vi.clearAllMocks(); + rateLimitMock.mockResolvedValue({ allowed: true, remaining: 19, resetAt: 0 }); + userFind.mockResolvedValue({ polarAccessTokenEncrypted: "enc:tok" }); +}); + +describe("POST /api/polar/disconnect", () => { + it("disconnects a connected user", async () => { + const res = await post(); + expect(res.status).toBe(200); + expect(res.data).toEqual({ disconnected: true }); + }); + + it("rate-limits the disconnect surface (429 on bucket exhaustion)", async () => { + rateLimitMock.mockResolvedValue({ allowed: false, remaining: 0, resetAt: 0 }); + const res = await post(); + expect(res.status).toBe(429); + // The user lookup never runs once the bucket is exhausted. + expect(userFind).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/polar/disconnect/route.ts b/src/app/api/polar/disconnect/route.ts index f18e46635..f39a73692 100644 --- a/src/app/api/polar/disconnect/route.ts +++ b/src/app/api/polar/disconnect/route.ts @@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { markDisconnected } from "@/lib/integrations/status"; /** @@ -16,6 +17,13 @@ export const POST = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "polar.disconnect" } }); + const rl = await checkRateLimit(`polar-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: { polarAccessTokenEncrypted: true }, diff --git a/src/components/insights/glucose/glucose-clinical-panel.tsx b/src/components/insights/glucose/glucose-clinical-panel.tsx index 95f32c4fb..6f70e07ae 100644 --- a/src/components/insights/glucose/glucose-clinical-panel.tsx +++ b/src/components/insights/glucose/glucose-clinical-panel.tsx @@ -206,10 +206,15 @@ export function GlucoseClinicalPanel() { ) : null} - {/* Spot-reading honesty caveat — always present. */} -

- {t("insights.bloodGlucose.clinical.spotCaveat")} -

+ {/* Spot-reading honesty caveat — only for sparse spot data. A dense + continuous stream (a CGM such as Nightscout) clears the density bar + and reports `isSpotEstimate: false`, so the caveat would undersell + the data. */} + {clinical.isSpotEstimate ? ( +

+ {t("insights.bloodGlucose.clinical.spotCaveat")} +

+ ) : null} {/* Advanced indices behind a disclosure. */} {clinical.advanced ? ( diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 7fbdba570..d5221b81e 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -1539,9 +1539,10 @@ async function buildCoachSnapshotImpl( ? Math.round(clinicalRaw.variability.cv) : null, unstable: clinicalRaw.variability?.unstable ?? null, - // Spot-reading estimate, never a CGM AGP — pinned so the model - // never narrates these as continuous-trace clinical figures. - isSpotEstimate: true as const, + // Density-derived: a sparse spot series stays a spot-reading + // estimate, a continuous CGM stream (Nightscout) reads false so the + // model can narrate the TIR/GMI as continuous-trace figures. + isSpotEstimate: clinicalRaw.isSpotEstimate, }; // The display unit travels with the block so the prompt renders // " " and the EVIDENCE BLOCK tags glucose lines correctly. diff --git a/src/lib/analytics/__tests__/glucose-metrics.test.ts b/src/lib/analytics/__tests__/glucose-metrics.test.ts index 1e4f6a498..68e1b3d91 100644 --- a/src/lib/analytics/__tests__/glucose-metrics.test.ts +++ b/src/lib/analytics/__tests__/glucose-metrics.test.ts @@ -222,12 +222,48 @@ describe("computeGlucoseClinicalMetrics — window + adequacy", () => { expect(m.advanced).not.toBeNull(); }); - it("always marks the result as a spot estimate", () => { - const m = computeGlucoseClinicalMetrics( - dailyReadings([100], now), - { now }, - ); - expect(m.isSpotEstimate).toBe(true); + describe("isSpotEstimate — density-derived", () => { + it("marks sparse spot data as a spot estimate (one reading/day)", () => { + // 14 readings across 14 days = 1/day, far below the hourly CGM bar. + const m = computeGlucoseClinicalMetrics( + dailyReadings(Array(14).fill(120), now), + { now }, + ); + expect(m.readingCount).toBe(14); + expect(m.isSpotEstimate).toBe(true); + }); + + it("marks a dense CGM-density stream as continuous (false)", () => { + // ~288 readings/day over 2 days, every 5 minutes — a Nightscout CGM. + const FIVE_MIN = 5 * 60 * 1000; + const total = 2 * 288; + const readings: GlucoseReading[] = Array.from( + { length: total }, + (_, i) => ({ + mgdl: 110, + measuredAt: new Date(now.getTime() - (total - 1 - i) * FIVE_MIN), + }), + ); + const m = computeGlucoseClinicalMetrics(readings, { now }); + expect(m.readingCount).toBe(total); + expect(m.isSpotEstimate).toBe(false); + }); + + it("does not let a single-day burst masquerade as continuous", () => { + // 30 readings all inside one day → span floors at 1 day, so density is + // 30/day. That clears the bar legitimately, but a handful in a day must + // not: 10 readings in one day = 10/day < 24 → still a spot estimate. + const HOUR = 60 * 60 * 1000; + const readings: GlucoseReading[] = Array.from( + { length: 10 }, + (_, i) => ({ + mgdl: 115, + measuredAt: new Date(now.getTime() - i * HOUR), + }), + ); + const m = computeGlucoseClinicalMetrics(readings, { now }); + expect(m.isSpotEstimate).toBe(true); + }); }); describe("learning gate", () => { diff --git a/src/lib/analytics/glucose-metrics.ts b/src/lib/analytics/glucose-metrics.ts index 3098c2e0b..2faefa59d 100644 --- a/src/lib/analytics/glucose-metrics.ts +++ b/src/lib/analytics/glucose-metrics.ts @@ -9,14 +9,17 @@ * it never recomputes — keeping the clinical math in one literature-locked * place. * - * IMPORTANT honesty contract: HealthLog stores SPOT readings (FASTING / - * POSTPRANDIAL / RANDOM / BEDTIME), NOT a dense CGM stream. The classic - * CGM-adequacy bar (Battelino 2019: 14 days with ≥70% of possible readings) - * cannot be met by spot data. Therefore every TIR / GMI / eA1C produced here - * is a SPOT-READING ESTIMATE — a "% of readings" distribution, NOT a "% of - * time" AGP report. The {@link GlucoseClinicalMetrics.stillLearning} gate and - * the per-metric labelling exist so callers never assert a clinical AGP off - * thin spot data. + * IMPORTANT honesty contract: HealthLog ingests both SPOT readings (manual + * FASTING / POSTPRANDIAL / RANDOM / BEDTIME entries) AND, since v1.17, a dense + * Nightscout CGM stream (~288 readings/day). The classic CGM-adequacy bar + * (Battelino 2019: 14 days with ≥70% of possible readings) cannot be met by + * spot data, so for a sparse series every TIR / GMI / eA1C is a SPOT-READING + * ESTIMATE — a "% of readings" distribution, NOT a "% of time" AGP report. + * {@link GlucoseClinicalMetrics.isSpotEstimate} is derived from reading DENSITY + * (see {@link CGM_READINGS_PER_DAY_THRESHOLD}): a continuous CGM stream reads + * `false` and the spot caveat comes off; sparse spot data stays `true`. The + * {@link GlucoseClinicalMetrics.stillLearning} gate additionally holds back any + * assertion off thin data regardless of source. * * Thresholds and formulas are clean-room from the primary literature (the * consensus band values and coefficients are mathematical facts, not @@ -91,6 +94,17 @@ export const DEFAULT_WINDOW_DAYS = 14; const DEFAULT_MIN_READINGS = 14; const DEFAULT_MIN_SPAN_DAYS = 7; +/** + * Readings-per-day at or above which the series is treated as a CONTINUOUS + * stream (a CGM such as Nightscout, ~288/day) rather than spot fingersticks (a + * handful/day). Roughly hourly — wide enough that a real CGM (≈288/day) clears + * it by an order of magnitude while no realistic spot-checking cadence reaches + * it. Above the threshold the TIR/GMI/eA1C are no longer framed as a + * spot-reading estimate (`isSpotEstimate: false`); below it the conservative + * spot caveat stays. + */ +const CGM_READINGS_PER_DAY_THRESHOLD = 24; + const MS_PER_DAY = 24 * 60 * 60 * 1000; // ── Output shapes ──────────────────────────────────────── @@ -212,11 +226,15 @@ export interface GlucoseClinicalMetrics { } | null; /** - * Always true for this module: every number is a spot-reading estimate, not a - * CGM AGP. Surfaced explicitly so serializers (panel/coach/PDF/iOS) carry the - * caveat without re-deriving it. + * Whether every number here should be framed as a SPOT-READING ESTIMATE (a + * "% of readings") rather than a CGM time-in-range AGP. Derived from reading + * DENSITY, not hard-coded: a continuous stream (a Nightscout CGM at ~288 + * readings/day) clears the {@link CGM_READINGS_PER_DAY_THRESHOLD} hourly bar + * and reports `false`; sparse spot fingersticks stay `true`. Surfaced + * explicitly so serializers (panel/coach/PDF/iOS) carry the caveat without + * re-deriving it. */ - isSpotEstimate: true; + isSpotEstimate: boolean; } // ── Core formulas ──────────────────────────────────────── @@ -488,6 +506,13 @@ export function computeGlucoseClinicalMetrics( }; at least ${minSpanDays} days of coverage are needed.`; } + // Density-derived spot vs continuous framing. Readings/day = count over the + // covered span (floored at one day so a single-day burst can't divide by a + // sub-day span and look continuous). At or above the hourly CGM bar the + // series is a continuous stream and the spot-reading caveat comes off. + const readingsPerDay = readingCount / Math.max(actualSpanDays, 1); + const isSpotEstimate = readingsPerDay < CGM_READINGS_PER_DAY_THRESHOLD; + return { stillLearning, stillLearningReason, @@ -500,7 +525,7 @@ export function computeGlucoseClinicalMetrics( estimatedA1c: eA1cValue, variability, advanced, - isSpotEstimate: true, + isSpotEstimate, }; } diff --git a/src/lib/jobs/__tests__/nightscout-queues.test.ts b/src/lib/jobs/__tests__/nightscout-queues.test.ts index 35f8170d8..d0bb5b0f4 100644 --- a/src/lib/jobs/__tests__/nightscout-queues.test.ts +++ b/src/lib/jobs/__tests__/nightscout-queues.test.ts @@ -1,15 +1,13 @@ /** - * v1.17.0 — pg-boss queue registration for the Nightscout CGM sync layer. + * v1.17.0 — pg-boss queue registration for the three new poll-only sync layers: + * Nightscout CGM, Polar, and Oura. * * Source-as-text guard (the reminder-worker pulls heavy infra we don't boot - * here): assert the queue constant + cron + handler wiring + allQueues + * here): assert each 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`. + * drains). All three syncs are poll-only (no webhook, no backfill queue): a + * single hourly tick walks every configured account. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; @@ -45,3 +43,41 @@ describe("reminder-worker — Nightscout sync queue", () => { ); }); }); + +describe("reminder-worker — Polar / Oura sync queues", () => { + // The same v1.4.37 dead-queue class applies to the two OAuth-wearable polls + // shipped alongside Nightscout in v1.17.0 — assert their allQueues membership + // so a future refactor can't silently drop one and starve the sync. + const allQueuesBlock = () => { + const block = /const allQueues = \[([\s\S]*?)\];/.exec(source); + expect(block).not.toBeNull(); + return block![1]!; + }; + + it("declares the polar-sync and oura-sync queue constants", () => { + expect(source).toMatch(/POLAR_SYNC_QUEUE\s*=\s*["']polar-sync["']/); + expect(source).toMatch(/OURA_SYNC_QUEUE\s*=\s*["']oura-sync["']/); + }); + + it("registers POLAR_SYNC_QUEUE in allQueues (v1.4.37 dead-queue guard)", () => { + expect(allQueuesBlock()).toContain("POLAR_SYNC_QUEUE"); + }); + + it("registers OURA_SYNC_QUEUE in allQueues (v1.4.37 dead-queue guard)", () => { + expect(allQueuesBlock()).toContain("OURA_SYNC_QUEUE"); + }); + + it("schedules the hourly poll cron for both", () => { + expect(source).toMatch(/\[POLAR_SYNC_QUEUE,\s*POLAR_SYNC_CRON\]/); + expect(source).toMatch(/\[OURA_SYNC_QUEUE,\s*OURA_SYNC_CRON\]/); + }); + + it("registers a boss.work handler for each queue", () => { + expect(source).toMatch( + /boss\.work[\s\S]{0,160}POLAR_SYNC_QUEUE[\s\S]{0,160}handlePolarSync/, + ); + expect(source).toMatch( + /boss\.work[\s\S]{0,160}OURA_SYNC_QUEUE[\s\S]{0,160}handleOuraSync/, + ); + }); +}); diff --git a/src/lib/nightscout/__tests__/sync.test.ts b/src/lib/nightscout/__tests__/sync.test.ts index f65bedd04..764c0fb71 100644 --- a/src/lib/nightscout/__tests__/sync.test.ts +++ b/src/lib/nightscout/__tests__/sync.test.ts @@ -149,6 +149,34 @@ describe("syncUserNightscout", () => { expect(recordFailureMock.mock.calls[0]![0].kind).toBe("transient"); }); + it("redacts the token from the recorded lastError message", async () => { + // A SafeFetchError embeds the full target URL — including `?token=` + // — in its message; the recorded lastError must never leak it. + fetchSgvEntriesMock.mockRejectedValue( + new Error( + "safeFetch network error: https://ns.example.com/api/v1/entries.json?count=576&type=sgv&token=ns-secret-abc123", + ), + ); + await expect(syncUserNightscout("u1")).rejects.toThrow(); + const message = recordFailureMock.mock.calls[0]![0].message as string; + expect(message).not.toContain("ns-secret-abc123"); + expect(message).toContain("token=REDACTED"); + // The rest of the diagnostic is preserved. + expect(message).toContain("ns.example.com"); + }); + + it("also redacts a classic api-secret query param", async () => { + fetchSgvEntriesMock.mockRejectedValue( + new Error( + "safeFetch refused private or non-public host: https://ns.lan/api/v1/entries.json?api-secret=deadbeef", + ), + ); + await expect(syncUserNightscout("u1")).rejects.toThrow(); + const message = recordFailureMock.mock.calls[0]![0].message as string; + expect(message).not.toContain("deadbeef"); + expect(message).toContain("api-secret=REDACTED"); + }); + it("recomputes rollup buckets for the imported readings", async () => { fetchSgvEntriesMock.mockResolvedValue([ENTRY_A, ENTRY_B]); await syncUserNightscout("u1"); diff --git a/src/lib/nightscout/sync.ts b/src/lib/nightscout/sync.ts index df29b9989..06e5e39c9 100644 --- a/src/lib/nightscout/sync.ts +++ b/src/lib/nightscout/sync.ts @@ -48,6 +48,28 @@ import { getUserNightscoutCredentials } from "./credentials"; */ export const NIGHTSCOUT_SYNC_COUNT = 576; +/** + * Redact the Nightscout auth secret from an error message before it reaches the + * integration ledger / the `/api/nightscout/status` response. + * + * The entries URL carries the access token as a `?token=` query param + * (and a classic instance may carry `api-secret`). A `SafeFetchError` embeds the + * full target URL verbatim in its message — a private-host refusal, a timeout, + * or a network failure would otherwise persist the token into `lastError` and + * echo it back through the status endpoint. Rewrite any URL in the message so + * the secret-bearing params read `=REDACTED`; everything else is preserved so + * the operator still sees a useful diagnostic. + */ +export function redactNightscoutSecret(message: string): string { + // Match `token=...` / `api-secret=...` up to the next param / whitespace / + // quote boundary. Case-insensitive on the key; the value is whatever a URL + // query value can hold short of a delimiter. + return message.replace( + /\b(token|api-secret)=[^\s&"']+/gi, + "$1=REDACTED", + ); +} + /** 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) { @@ -85,7 +107,9 @@ export async function syncUserNightscout( userId, integration: "nightscout", kind: classifyNightscoutFailure(err), - message: err instanceof Error ? err.message : String(err), + message: redactNightscoutSecret( + err instanceof Error ? err.message : String(err), + ), errorCode: err instanceof NightscoutApiError && err.status != null ? String(err.status) diff --git a/src/lib/openapi/routes/insights.ts b/src/lib/openapi/routes/insights.ts index 5db110d2b..50b76f99a 100644 --- a/src/lib/openapi/routes/insights.ts +++ b/src/lib/openapi/routes/insights.ts @@ -587,12 +587,12 @@ const glucoseClinicalSchema = z hbgi: z.number(), }) .nullable(), - isSpotEstimate: z.literal(true), + isSpotEstimate: z.boolean(), }) .meta({ id: "GlucoseClinicalMetrics", description: - "Server-authoritative glucose clinical panel over the trailing 30-day window. Every figure is a SPOT-READING ESTIMATE (a % of readings), not a CGM time-in-range AGP — `isSpotEstimate` is always true and `stillLearning` gates assertion when the window has too few readings or too short a span. `distribution` carries the Battelino 2019 TIR/TBR/TAR fractions (level-2 nested in level-1) plus minutes-of-a-day equivalents; `gmi` (Bergenstal 2018) + `estimatedA1c` (Nathan 2008 ADAG) derive from the mean; `variability` is SD + CV% with the Monnier 2017 ≥36% instability flag; `advanced` is the disclosure tier — J-index (Wojcicki 1995) + LBGI/HBGI (Kovatchev hypo/hyper risk). All blocks are null when there are no readings; `advanced.jIndex` is null for a single-reading window.", + "Server-authoritative glucose clinical panel over the trailing 30-day window. Figures from sparse spot data are a SPOT-READING ESTIMATE (a % of readings), not a CGM time-in-range AGP; `isSpotEstimate` is derived from reading density (true below ~hourly, false for a continuous CGM stream such as Nightscout) and `stillLearning` gates assertion when the window has too few readings or too short a span. `distribution` carries the Battelino 2019 TIR/TBR/TAR fractions (level-2 nested in level-1) plus minutes-of-a-day equivalents; `gmi` (Bergenstal 2018) + `estimatedA1c` (Nathan 2008 ADAG) derive from the mean; `variability` is SD + CV% with the Monnier 2017 ≥36% instability flag; `advanced` is the disclosure tier — J-index (Wojcicki 1995) + LBGI/HBGI (Kovatchev hypo/hyper risk). All blocks are null when there are no readings; `advanced.jIndex` is null for a single-reading window.", }); const dashboardSnapshotResponse = z @@ -661,6 +661,8 @@ const dashboardSnapshotResponse = z bpInTargetPctAllTime: z.number().nullable(), bpInTargetPctPriorMonth: z.number().nullable(), bpInTargetPctPriorYear: z.number().nullable(), + bpInTargetCount90: z.number().int().nullable(), + bpInTargetSpanDays90: z.number().int().nullable(), glucoseByContext: dataSummaryRecord, glucoseClinical: glucoseClinicalSchema, })