mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge pull request #543 from MBombeck/release/v1.30.19
release/v1.30.19 — switching a module off now holds everywhere
This commit is contained in:
26 files changed
+1842
-68
No files matched your search
@@ -2,6 +2,16 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.30.19] — 2026-07-19
|
||||
|
||||
Switching a module off now holds everywhere.
|
||||
|
||||
- **Three surfaces ignored your module switches.** The per-topic endpoints have always honoured them, but the sync feed still sent cycle data, the FHIR export still returned the full record including the insurance number, and the dashboard summary still built glucose and sleep cards — all regardless of whether those modules were on. Each now honours the switch: the sync feed leaves the cycle rows out, the FHIR export refuses the same way the health-record export already did, and the summary omits the cards. Nothing is deleted — turning a module back on finds the history complete, and the sync feed picks up where it left off rather than skipping what changed while it was hidden.
|
||||
- **Two assistant features reached a provider without a consent record.** The follow-up questions after saving your profile, and the assistant's own nudges, both sent data to a provider without checking whether consent was on file — and could fall back to the server-wide key. Both now check first and fall back to their non-generated text when consent is absent. The follow-up questions also moved to the same reservation-based spend accounting everything else uses; a self-hoster on their own key is no longer measured against the operator's daily ceiling.
|
||||
- The check that was supposed to catch the module gaps accepted a helper being imported as proof that a gate existed. It now pins which module each surface must check and names the tests that exercise a disabled one.
|
||||
|
||||
No breaking changes to the web app. The native client may see a 403 from the FHIR endpoints and absent cards where a module is off — see the coordination note.
|
||||
|
||||
## [1.30.18] — 2026-07-19
|
||||
|
||||
Health data and credentials no longer reach the logs or the backups.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.30.18
|
||||
version: 1.30.19
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.30.18",
|
||||
"version": "1.30.19",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.30.18";
|
||||
/* @sw-version-fallback */ "v1.30.19";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Structural guard on the AI consent boundary.
|
||||
*
|
||||
* Two surfaces shipped without a consent check — the self-context clarifying
|
||||
* questions and the AI-composed proactive nudge — for the same reason: nothing
|
||||
* connected "I am holding a live provider instance" to "I must have checked a
|
||||
* receipt first". Behavioural tests close the two we found. This closes the
|
||||
* class, so the third one cannot appear quietly.
|
||||
*
|
||||
* The invariant: a module that resolves a provider INSTANCE for a user
|
||||
* (`resolveProvider` / `resolveProviderChain` / `resolveProviderForTest`) holds
|
||||
* something it can call `generateCompletion` on. Every such module must also
|
||||
* pull in a consent helper — or sit on the allowlist below with a written
|
||||
* reason a reviewer signed off.
|
||||
*
|
||||
* WHAT THIS CANNOT PROVE — read this before trusting it:
|
||||
*
|
||||
* - It is an IMPORT-level check. It proves a consent helper is in the
|
||||
* module's scope, NOT that the gate is reached on every path through the
|
||||
* file. A module could import `hasActiveConsentForSurface`, call it on one
|
||||
* branch, and egress ungated on another; this guard stays green.
|
||||
* - It cannot prove ORDER. A gate called after the provider call, or after
|
||||
* the snapshot is built, passes here and is still wrong.
|
||||
* - It cannot prove the SURFACE is right. Passing `"insights"` where the data
|
||||
* is a Coach snapshot passes here.
|
||||
* - It cannot prove an allowlist entry is HONEST. A reviewer who waves
|
||||
* through a new entry with a plausible-sounding reason defeats it entirely.
|
||||
* The allowlist is a review checkpoint, not a proof.
|
||||
*
|
||||
* What it does buy: a NEW ungated provider call site cannot land silently. The
|
||||
* author must either wire a consent helper or edit this file, and editing this
|
||||
* file puts the decision in front of a reviewer. That is the whole claim.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, sep } from "node:path";
|
||||
import { globSync } from "node:fs";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/** Every non-test `.ts` / `.tsx` under `src/`, minus the generated client. */
|
||||
function sourceFiles(): string[] {
|
||||
return globSync("**/*.{ts,tsx}", { cwd: SRC })
|
||||
.filter(
|
||||
(p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"),
|
||||
)
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.map((p) => p.split(sep).join("/"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* The helpers that hand back a live, callable provider for a user. Deliberately
|
||||
* NOT including the presence-only probes (`hasAnyConfiguredProvider`,
|
||||
* `resolveProviderAvailability`, `userRowHasProviderCredential`): those never
|
||||
* decrypt a key or construct a client, so they cannot egress anything.
|
||||
*/
|
||||
const RESOLUTION_HELPERS = [
|
||||
"resolveProvider",
|
||||
"resolveProviderChain",
|
||||
"resolveProviderForTest",
|
||||
] as const;
|
||||
|
||||
/** The two sanctioned consent APIs. Either satisfies the guard. */
|
||||
const CONSENT_HELPERS = [
|
||||
// Chain-shaped: throw-form and predicate-form.
|
||||
"assertConsentForChain",
|
||||
"chainRequiresServerManagedConsent",
|
||||
"hasActiveConsentForSurface",
|
||||
// Document-class, pick-shaped.
|
||||
"assertDocumentEgressConsent",
|
||||
"isExternalDocumentEgress",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Extract the named bindings a file imports from a given module specifier.
|
||||
* Handles the multi-line `import { a, b } from "…"` form the codebase uses.
|
||||
* Comments mentioning a helper are NOT import statements and do not count —
|
||||
* that distinction is the whole point of parsing the import rather than
|
||||
* grepping the file.
|
||||
*/
|
||||
function importedNamesFrom(source: string, moduleSpecifier: string): string[] {
|
||||
const re = new RegExp(
|
||||
`import\\s+(?:type\\s+)?\\{([^}]*)\\}\\s*from\\s*["']${moduleSpecifier.replace(
|
||||
/[/\\^$*+?.()|[\]{}]/g,
|
||||
"\\$&",
|
||||
)}["']`,
|
||||
"g",
|
||||
);
|
||||
const names: string[] = [];
|
||||
for (const match of source.matchAll(re)) {
|
||||
for (const raw of match[1].split(",")) {
|
||||
const name = raw
|
||||
.trim()
|
||||
.replace(/^type\s+/, "")
|
||||
.split(/\s+as\s+/)[0];
|
||||
if (name) names.push(name.trim());
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function resolvesAProvider(source: string): boolean {
|
||||
const imported = importedNamesFrom(source, "@/lib/ai/provider");
|
||||
return RESOLUTION_HELPERS.some((h) => imported.includes(h));
|
||||
}
|
||||
|
||||
function importsAConsentHelper(source: string): boolean {
|
||||
const imported = importedNamesFrom(source, "@/lib/ai/consent-guard");
|
||||
return CONSENT_HELPERS.some((h) => imported.includes(h));
|
||||
}
|
||||
|
||||
/**
|
||||
* Modules that resolve a provider and deliberately carry no consent helper.
|
||||
* Every entry needs a reason that survives a reviewer asking "what PHI does
|
||||
* this send?" — the answer here is "none" in every case.
|
||||
*
|
||||
* `lib/ai/provider.ts` needs no entry: it DEFINES the resolution helpers rather
|
||||
* than importing them, so the detector never flags it. That is correct — the
|
||||
* resolver constructs clients and never sends a prompt, and gating it would
|
||||
* invert the layering.
|
||||
*/
|
||||
const ALLOWLIST: Record<string, string> = {
|
||||
// --- Capability / config probes: resolve a chain, never call it. ---
|
||||
"lib/labs/ocr-capability.ts":
|
||||
"Resolves the chain to REPORT which entry could do vision/text OCR (mode, reason, pdfSupported). Makes no completion call. Every consumer that then egresses a document is gated at its own call site with assertDocumentEgressConsent.",
|
||||
"app/api/insights/provider-chain/route.ts":
|
||||
"Lists the user's configured chain and which entry is active, for the settings UI. Constructs clients but never calls generateCompletion. No health data in the request or the response.",
|
||||
"app/api/insights/comprehensive/route.ts":
|
||||
"Uses the resolver only as a boolean probe ((await resolveProvider(userId)).type !== 'none') to set hasProvider on the response. The generation path itself lives in lib/insights/comprehensive-generate.ts, which is gated.",
|
||||
|
||||
// --- Connection test: a real provider call, but with no user data. ---
|
||||
"app/api/ai/test/route.ts":
|
||||
"Sends a fixed synthetic prompt to verify credentials resolve and the endpoint answers. No health data is read, so there is no PHI egress for a receipt to authorise. Bounded by its own 5/min rate limit.",
|
||||
};
|
||||
|
||||
describe("every provider-resolving module carries a consent helper", () => {
|
||||
const resolvers = sourceFiles().filter((rel) => resolvesAProvider(read(rel)));
|
||||
|
||||
it("finds the provider-resolving modules at all (the guard is wired up)", () => {
|
||||
// A refactor that renames the resolver or moves the module would otherwise
|
||||
// silently empty the set and make every assertion below vacuous.
|
||||
expect(resolvers.length).toBeGreaterThanOrEqual(8);
|
||||
expect(resolvers).toContain("lib/ai/coach/self-context-questions.ts");
|
||||
expect(resolvers).toContain("lib/jobs/coach-nudge-ai.ts");
|
||||
});
|
||||
|
||||
it("gates every provider-resolving module that is not explicitly allowlisted", () => {
|
||||
const ungated = resolvers.filter(
|
||||
(rel) => !importsAConsentHelper(read(rel)) && !(rel in ALLOWLIST),
|
||||
);
|
||||
expect(ungated).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the allowlist free of stale entries", () => {
|
||||
// An entry that no longer resolves a provider is dead weight that hides
|
||||
// the next real one. Delete it rather than letting it rot.
|
||||
const stale = Object.keys(ALLOWLIST).filter(
|
||||
(rel) => !resolvers.includes(rel),
|
||||
);
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
|
||||
it("requires a substantive reason on every allowlist entry", () => {
|
||||
const thin = Object.entries(ALLOWLIST)
|
||||
.filter(([, reason]) => reason.trim().length < 40)
|
||||
.map(([rel]) => rel);
|
||||
expect(thin).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the consent guard itself stays the single source of the policy", () => {
|
||||
it("keeps the server-managed provider set defined in exactly one place", () => {
|
||||
// Both operator-credential tags must be classified in consent-guard.ts and
|
||||
// nowhere else — a second copy of this list is how the policy drifts.
|
||||
const guard = read("lib/ai/consent-guard.ts");
|
||||
expect(guard).toContain('"admin-openai"');
|
||||
expect(guard).toContain('"admin-codex"');
|
||||
|
||||
const copies = sourceFiles().filter(
|
||||
(rel) =>
|
||||
rel !== "lib/ai/consent-guard.ts" &&
|
||||
/SERVER_MANAGED_PROVIDER_TYPES/.test(read(rel)),
|
||||
);
|
||||
expect(copies).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -38,13 +38,7 @@ import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
|
||||
* `coach-route-gate-inventory.test.ts`; listed here so
|
||||
* coach-bearing routes are not flagged as ungated.
|
||||
*
|
||||
* 3. BUILDER-GATED — the route delegates its whole payload to an
|
||||
* aggregator that resolves `resolveModuleMap(userId)` once and
|
||||
* excludes disabled-module sections/resources from the build
|
||||
* (`collectDoctorReportData`, `loadFhirContext`). The exclusion is
|
||||
* structural at the builder, so the route carries no per-key call.
|
||||
*
|
||||
* 4. EXEMPT — an explicit, COMMENTED allowlist of routes that serve a
|
||||
* 3. EXEMPT — an explicit, COMMENTED allowlist of routes that serve a
|
||||
* toggleable domain but are deliberately NOT gated, each with the
|
||||
* reason at the entry. The two reasons in play:
|
||||
* - DATA LAYER: raw CRUD over the domain's own rows. Writing or
|
||||
@@ -56,9 +50,30 @@ import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
|
||||
* - INFRA / UI-ONLY: settings, availability probes, and the static
|
||||
* FHIR CapabilityStatement carry no module data.
|
||||
*
|
||||
* Anything that doesn't fit one of the four buckets is an orphan and
|
||||
* Anything that doesn't fit one of the three buckets is an orphan and
|
||||
* fails the test BY NAME so the fix is one search-and-add: gate the route
|
||||
* or move it onto the EXEMPT allowlist with a documented reason.
|
||||
*
|
||||
* WHAT THIS TEST DOES NOT PROVE, AND WHO PROVES IT
|
||||
* -----------------------------------------------
|
||||
* This file is a DISCOVERY test: it proves no route escapes classification.
|
||||
* It cannot prove a gate actually refuses, because it only reads source text.
|
||||
*
|
||||
* That distinction used to be blurred. A fourth "BUILDER-GATED" bucket
|
||||
* accepted the mere MENTION of an aggregator's name (`loadFhirContext`,
|
||||
* `collectDoctorReportData`) as proof that the route was gated. It was not
|
||||
* proof of anything: `loadFhirContext` filtered per-domain modules but never
|
||||
* evaluated `doctorReport`, so all five `/api/fhir/*` data routes sat in a
|
||||
* green bucket while serving the whole record — including the decrypted
|
||||
* insurance number — to an account that had the doctor-report module off.
|
||||
* The bucket is gone. A route is now only counted as gated when it CALLS a
|
||||
* gate, and `REQUIRED_TREE_MODULE` below pins which key it must name.
|
||||
*
|
||||
* The behaviour itself — a disabled module actually producing a 403 or an
|
||||
* omission — is proven by the per-surface tests listed in
|
||||
* `BEHAVIOURAL_GATE_TESTS`. This test asserts those files still exist, so
|
||||
* deleting the behavioural coverage fails here with a pointer to what was
|
||||
* lost rather than silently leaving only a text-match behind.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -99,9 +114,10 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
|
||||
"src/app/api/medications",
|
||||
// v1.18.0 B3 — the legacy `/api/doctor-report` tree (JSON + server-PDF +
|
||||
// availability probe) was orphaned dead code (no production caller) and
|
||||
// removed. The live doctor-report / FHIR surface is `/api/export/health-record`,
|
||||
// which gates on the `doctorReport` module directly AND through the
|
||||
// `collectDoctorReportData` builder.
|
||||
// removed. The doctor-report surface is `/api/export/health-record` plus
|
||||
// the FHIR REST face below; both gate on `doctorReport` directly. The FHIR
|
||||
// routes previously relied on the removed builder-mention bucket and were
|
||||
// in practice ungated — see the header note.
|
||||
"src/app/api/fhir",
|
||||
// v1.18.0 (B2) — the AI-narrative insights tree. Every status / cards /
|
||||
// correlations / derived / narrative / pregenerate / rhythm-events route
|
||||
@@ -273,12 +289,55 @@ const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")';
|
||||
// `requireCycleEnabled`. Recognised as a delegated gate so illness routes
|
||||
// are not flagged as ungated.
|
||||
const ILLNESS_GATE_NEEDLE = "requireIllnessEnabled(";
|
||||
// Builder aggregators that resolve `resolveModuleMap` once and exclude
|
||||
// disabled-module sections/resources at the build boundary.
|
||||
const BUILDER_GATE_NEEDLES: ReadonlyArray<string> = [
|
||||
"collectDoctorReportData",
|
||||
"loadFhirContext",
|
||||
];
|
||||
|
||||
/**
|
||||
* Trees whose routes must gate on ONE specific module key. Naming the key
|
||||
* here means a future route in the tree cannot satisfy the inventory by
|
||||
* gating on some other, more permissive module — the failure mode that a
|
||||
* generic "is there any gate?" check cannot see.
|
||||
*
|
||||
* `/api/fhir` is the entry that matters: every data route there serves the
|
||||
* whole-record doctor-report aggregate, so `doctorReport` is the only
|
||||
* correct key.
|
||||
*/
|
||||
const REQUIRED_TREE_MODULE: Readonly<Record<string, ModuleKey>> = {
|
||||
"src/app/api/fhir": "doctorReport",
|
||||
};
|
||||
|
||||
/**
|
||||
* Surfaces whose module behaviour is proven by a real behavioural test
|
||||
* (module off ⇒ 403 or omission; module on ⇒ served). Listed so that
|
||||
* deleting the proof fails the inventory instead of quietly downgrading the
|
||||
* guarantee back to a text match.
|
||||
*
|
||||
* The three aggregate surfaces below are the ones that re-served per-domain
|
||||
* data ungated: two of them (`/api/sync/changes`, `/api/dashboard/summary`)
|
||||
* are cross-domain feeds that live outside `MODULE_ROUTE_TREES` entirely, so
|
||||
* without this list nothing in the inventory would notice them at all.
|
||||
*/
|
||||
const BEHAVIOURAL_GATE_TESTS: ReadonlyArray<{ path: string; proves: string }> =
|
||||
[
|
||||
{
|
||||
path: "src/app/api/fhir/__tests__/module-gate.test.ts",
|
||||
proves:
|
||||
"every /api/fhir data route 403s with module.disabled when doctorReport is off",
|
||||
},
|
||||
{
|
||||
path: "src/lib/fhir/__tests__/rest-module-backstop.test.ts",
|
||||
proves:
|
||||
"loadFhirContext itself refuses to assemble the record when doctorReport is off",
|
||||
},
|
||||
{
|
||||
path: "src/app/api/sync/changes/__tests__/module-gate.test.ts",
|
||||
proves:
|
||||
"the sync delta feed omits cycleDays/cycles (and never queries them) when cycle is off",
|
||||
},
|
||||
{
|
||||
path: "src/app/api/dashboard/summary/__tests__/module-gate.test.ts",
|
||||
proves:
|
||||
"the dashboard summary drops disabled-module metric cards while core vitals still ship",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* True when the file contains the needle on a line that is NOT a pure
|
||||
@@ -373,7 +432,7 @@ describe("module API route gate inventory", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("every toggleable-module route is gated, delegated, builder-gated, or explicitly exempt", () => {
|
||||
it("every toggleable-module route is gated, delegated, or explicitly exempt", () => {
|
||||
const routes = findAllModuleRouteFiles();
|
||||
expect(routes.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -387,15 +446,16 @@ describe("module API route gate inventory", () => {
|
||||
if (fileHasCall(text, CYCLE_GATE_NEEDLE)) continue;
|
||||
if (fileHasCall(text, COACH_GATE_NEEDLE)) continue;
|
||||
if (fileHasCall(text, ILLNESS_GATE_NEEDLE)) continue;
|
||||
if (BUILDER_GATE_NEEDLES.some((n) => fileHasCall(text, n))) continue;
|
||||
|
||||
if (exempt.has(path)) continue;
|
||||
|
||||
orphans.push({
|
||||
path,
|
||||
reason:
|
||||
'no module gate found — add requireModuleEnabled("<key>"), a delegated gate, ' +
|
||||
"a module-aware builder, or move the route onto EXEMPT_ROUTES with a documented reason",
|
||||
'no module gate found — add requireModuleEnabled("<key>") or a delegated gate, ' +
|
||||
"or move the route onto EXEMPT_ROUTES with a documented reason. " +
|
||||
"Importing a module-aware builder is NOT a gate: a builder can filter some " +
|
||||
"modules and never evaluate yours.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -432,6 +492,55 @@ describe("module API route gate inventory", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("routes in a key-pinned tree gate on that tree's module key", () => {
|
||||
// A generic "is there any gate?" check cannot tell `doctorReport` from
|
||||
// some other, more permissive key. Where a whole tree serves exactly one
|
||||
// module, name it.
|
||||
const wrong: Array<{ path: string; found: string[] }> = [];
|
||||
|
||||
for (const [tree, requiredKey] of Object.entries(REQUIRED_TREE_MODULE)) {
|
||||
for (const path of findRouteFiles(tree)) {
|
||||
if (EXEMPT_ROUTES.includes(path)) continue;
|
||||
const text = readFileSync(resolve(repoRoot, path), "utf8");
|
||||
const { keys } = extractModuleKeys(text);
|
||||
if (!keys.has(requiredKey)) {
|
||||
wrong.push({ path, found: [...keys] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
wrong,
|
||||
[
|
||||
"Routes that must gate on their tree's module key but do not:",
|
||||
...wrong.map(
|
||||
(w) =>
|
||||
` - ${w.path}: found [${w.found.join(", ") || "no literal key"}]`,
|
||||
),
|
||||
].join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("the behavioural gate tests that prove refusal still exist", () => {
|
||||
// This inventory only reads source text. The listed files are what
|
||||
// actually exercise a disabled module end to end; losing one silently
|
||||
// downgrades the guarantee back to a text match, which is precisely the
|
||||
// failure this test was rewritten to prevent.
|
||||
const missing = BEHAVIOURAL_GATE_TESTS.filter(
|
||||
(t) => !existsSync(resolve(repoRoot, t.path)),
|
||||
);
|
||||
|
||||
expect(
|
||||
missing,
|
||||
[
|
||||
"Behavioural module-gate coverage has gone missing.",
|
||||
"This inventory cannot prove a gate refuses — these tests do:",
|
||||
...missing.map((m) => ` - ${m.path}\n proves: ${m.proves}`),
|
||||
"Restore the file, or drop the entry and say what replaces it.",
|
||||
].join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("EXEMPT_ROUTES does not reference deleted route files", () => {
|
||||
const known = new Set(findAllModuleRouteFiles());
|
||||
const stale = EXEMPT_ROUTES.filter((p) => !known.has(p));
|
||||
@@ -457,8 +566,7 @@ describe("module API route gate inventory", () => {
|
||||
fileHasCall(text, MODULE_GATE_NEEDLE) ||
|
||||
fileHasCall(text, CYCLE_GATE_NEEDLE) ||
|
||||
fileHasCall(text, COACH_GATE_NEEDLE) ||
|
||||
fileHasCall(text, ILLNESS_GATE_NEEDLE) ||
|
||||
BUILDER_GATE_NEEDLES.some((n) => fileHasCall(text, n))
|
||||
fileHasCall(text, ILLNESS_GATE_NEEDLE)
|
||||
) {
|
||||
stillGated.push(path);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* `GET /api/dashboard/summary` — module gate.
|
||||
*
|
||||
* This route builds its own payload instead of going through
|
||||
* `readDashboardSnapshotCached`, so it never picked up the gate the shared
|
||||
* snapshot builder applies: it emitted glucose and sleep cards on data
|
||||
* presence alone while `/api/dashboard/snapshot` correctly withheld them for
|
||||
* the very same account.
|
||||
*
|
||||
* OMIT, not 403 — the payload spans ten metric cards across several domains,
|
||||
* and blanking an iOS dashboard because one module is off would be the wrong
|
||||
* trade. Core vitals must keep flowing.
|
||||
*
|
||||
* Behavioural: the assertions read the emitted card list, so reverting the
|
||||
* filter in the route turns them red.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
// The gate resolver runs for real; only its data sources are stubbed, so the
|
||||
// module state is driven the way production drives it.
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/modules/operator-availability", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@/lib/modules/operator-availability")
|
||||
>();
|
||||
return { ...actual, getOperatorModuleAvailability: vi.fn() };
|
||||
});
|
||||
vi.mock("@/lib/feature-flags", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/feature-flags")>();
|
||||
return { ...actual, getAssistantFlags: vi.fn() };
|
||||
});
|
||||
// The heavy builder is stubbed: this test pins the GATE, not the aggregate
|
||||
// (which has its own coverage). It returns one card per emitted kind.
|
||||
vi.mock("@/lib/cache/server-cache", () => ({
|
||||
caches: { analytics: {} },
|
||||
cachedSwr: vi.fn(),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { cachedSwr } from "@/lib/cache/server-cache";
|
||||
import { MODULE_KEYS } from "@/lib/modules/gate";
|
||||
import { getOperatorModuleAvailability } from "@/lib/modules/operator-availability";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "tester",
|
||||
role: "USER" as const,
|
||||
displayName: null,
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
},
|
||||
};
|
||||
|
||||
/** Every card kind the builder can emit, one card each. */
|
||||
const ALL_KINDS = [
|
||||
"weight",
|
||||
"bloodPressure",
|
||||
"pulse",
|
||||
"bodyFat",
|
||||
"glucose",
|
||||
"sleep",
|
||||
"steps",
|
||||
"totalBodyWater",
|
||||
"boneMass",
|
||||
"oxygenSaturation",
|
||||
] as const;
|
||||
|
||||
function card(kind: string) {
|
||||
return {
|
||||
id: kind,
|
||||
kind,
|
||||
titleKey: `dashboard.metric.title.${kind}`,
|
||||
latestValue: 1,
|
||||
secondaryValue: null,
|
||||
unitKey: `dashboard.metric.unit.${kind}`,
|
||||
unit: null,
|
||||
sleepStages: null,
|
||||
sleepSourceDiscrepancy: null,
|
||||
trend: "flat",
|
||||
sparkline: [1],
|
||||
updatedAt: new Date().toISOString(),
|
||||
allTimeCount: 5,
|
||||
lastSeenAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Drive the real gate through the persisted disabled-allowlist. */
|
||||
function setDisabledModules(disabled: string[]): void {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
gender: null,
|
||||
disableCoach: false,
|
||||
modulePreferencesJson: Object.fromEntries(disabled.map((k) => [k, false])),
|
||||
} as never);
|
||||
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
|
||||
}
|
||||
|
||||
async function emittedKinds(): Promise<string[]> {
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { metrics: Array<{ kind: string }> };
|
||||
};
|
||||
return body.data.metrics.map((m) => m.kind);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(getOperatorModuleAvailability).mockResolvedValue(
|
||||
Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as never,
|
||||
);
|
||||
vi.mocked(getAssistantFlags).mockResolvedValue({ coach: true } as never);
|
||||
vi.mocked(cachedSwr).mockImplementation((async () => ({
|
||||
greeting: { salutation: "hi", date: new Date().toISOString() },
|
||||
streak: { currentDays: 0, longest: 0, label: "" },
|
||||
compliance: { scheduledToday: 0, takenToday: 0 },
|
||||
highlightInsight: null,
|
||||
metrics: ALL_KINDS.map(card),
|
||||
sleepRhythm: null,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
})) as never);
|
||||
});
|
||||
|
||||
describe("GET /api/dashboard/summary — module gate", () => {
|
||||
it("emits every card when no module is disabled", async () => {
|
||||
setDisabledModules([]);
|
||||
expect(await emittedKinds()).toEqual([...ALL_KINDS]);
|
||||
});
|
||||
|
||||
it("drops the glucose card when the glucose module is off", async () => {
|
||||
setDisabledModules(["glucose"]);
|
||||
const kinds = await emittedKinds();
|
||||
|
||||
expect(kinds).not.toContain("glucose");
|
||||
// Everything else survives — proves an omission, not a refusal.
|
||||
expect(kinds).toContain("weight");
|
||||
expect(kinds).toContain("sleep");
|
||||
expect(kinds).toHaveLength(ALL_KINDS.length - 1);
|
||||
});
|
||||
|
||||
it("drops the sleep card when the sleep module is off", async () => {
|
||||
setDisabledModules(["sleep"]);
|
||||
const kinds = await emittedKinds();
|
||||
|
||||
expect(kinds).not.toContain("sleep");
|
||||
expect(kinds).toContain("glucose");
|
||||
expect(kinds).toHaveLength(ALL_KINDS.length - 1);
|
||||
});
|
||||
|
||||
it("drops both when both modules are off", async () => {
|
||||
setDisabledModules(["glucose", "sleep"]);
|
||||
const kinds = await emittedKinds();
|
||||
|
||||
expect(kinds).not.toContain("glucose");
|
||||
expect(kinds).not.toContain("sleep");
|
||||
expect(kinds).toHaveLength(ALL_KINDS.length - 2);
|
||||
});
|
||||
|
||||
it("never drops a core vital, whatever is disabled", async () => {
|
||||
// Core metrics carry no SUMMARY_TYPE_MODULE entry and must always ship.
|
||||
setDisabledModules([...MODULE_KEYS]);
|
||||
const kinds = await emittedKinds();
|
||||
|
||||
for (const core of [
|
||||
"weight",
|
||||
"bloodPressure",
|
||||
"pulse",
|
||||
"bodyFat",
|
||||
"steps",
|
||||
"totalBodyWater",
|
||||
"boneMass",
|
||||
"oxygenSaturation",
|
||||
]) {
|
||||
expect(kinds).toContain(core);
|
||||
}
|
||||
});
|
||||
|
||||
it("gates the cached body, so a toggle takes effect on the next request", async () => {
|
||||
// The analytics LRU is not evicted by a module toggle. The filter must
|
||||
// therefore run on the way out, not inside the cached build.
|
||||
setDisabledModules([]);
|
||||
expect(await emittedKinds()).toContain("glucose");
|
||||
|
||||
// Same cached body, module now off.
|
||||
setDisabledModules(["glucose"]);
|
||||
expect(await emittedKinds()).not.toContain("glucose");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,19 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// Every module is on for the aggregate assertions below; the gate's own
|
||||
// behaviour (which cards a disabled module drops) is pinned in the sibling
|
||||
// `module-gate.test.ts`.
|
||||
vi.mock("@/lib/modules/gate", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/modules/gate")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveModuleMap: vi.fn(async () =>
|
||||
Object.fromEntries(actual.MODULE_KEYS.map((k) => [k, true])),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
// v1.11.4 — `findMany` reads the raw per-stage SLEEP_DURATION rows for
|
||||
|
||||
@@ -44,6 +44,12 @@
|
||||
* subsequent iOS polls inside the window hit memory. Invalidation runs
|
||||
* via the existing `invalidateUserMeasurements` + the v1.4.38-extended
|
||||
* `invalidateUserMedications` hooks.
|
||||
*
|
||||
* Module gate: the emitted `metrics` array is filtered against the account's
|
||||
* module map before it leaves the handler, so a card whose measurement type
|
||||
* belongs to a disabled module never ships. The decision comes from the same
|
||||
* map the shared snapshot builder uses (`disabledSummaryTypes`); the filter
|
||||
* runs OUTSIDE the cache because a module toggle does not evict it.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
@@ -57,6 +63,8 @@ import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
|
||||
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { gateMetricCardsByModules } from "@/lib/dashboard/widget-modules";
|
||||
import {
|
||||
summarizeSleepNights,
|
||||
reconstructSleepNights,
|
||||
@@ -364,7 +372,35 @@ export const GET = apiHandler(async () => {
|
||||
annotate,
|
||||
);
|
||||
|
||||
return apiSuccess(body);
|
||||
// ── module gate ───────────────────────────────────────────────────
|
||||
// This route builds its own payload instead of going through
|
||||
// `readDashboardSnapshotCached`, so it never picked up the module gate
|
||||
// the shared snapshot builder applies — it emitted glucose and sleep
|
||||
// tiles on data presence alone, while `/api/dashboard/snapshot` correctly
|
||||
// withheld them for the same account.
|
||||
//
|
||||
// OMIT, NOT 403 — matching the snapshot sibling and the sync feed. This is
|
||||
// an aggregate of ten metric cards across several domains; refusing the
|
||||
// whole request would blank the iOS dashboard because one module is off.
|
||||
// The card simply does not appear, which is exactly what a disabled module
|
||||
// means on every other dashboard surface.
|
||||
//
|
||||
// Applied AFTER the cache read, deliberately. The analytics LRU is keyed
|
||||
// `<userId>|dashboard-summary` with a 60 s fresh / 1 h stale window, and
|
||||
// toggling a module does not evict it — gating inside the builder would
|
||||
// leave the disabled tile served for up to an hour after the user turned
|
||||
// it off. Filtering the cached body makes the toggle effective on the very
|
||||
// next request.
|
||||
const modules = await resolveModuleMap(user.id);
|
||||
const metrics = gateMetricCardsByModules(body.metrics, modules);
|
||||
annotate({
|
||||
meta: {
|
||||
metrics_emitted: metrics.length,
|
||||
metrics_gated: body.metrics.length - metrics.length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ ...body, metrics });
|
||||
});
|
||||
|
||||
interface SummaryBuilderContext {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
@@ -32,6 +33,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth(FHIR_READ_SCOPE);
|
||||
annotate({ action: { name: "fhir.everything.read" } });
|
||||
|
||||
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
|
||||
// `/api/export/health-record` (the doctor-report builder), right down to
|
||||
// the decrypted insurance number on the Patient resource. That export
|
||||
// gates on the `doctorReport` module; this surface did not, so the module
|
||||
// could be off and `/api/fhir/*` still handed out the full record.
|
||||
//
|
||||
// REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is
|
||||
// a whole-record export, not an incremental feed: there is no partial
|
||||
// answer that is still a truthful FHIR Bundle, and no background client
|
||||
// depends on it draining to stay consistent. Mirroring the sibling export's
|
||||
// 403 `module.disabled` envelope (rather than an OperationOutcome) keeps
|
||||
// the errorCode the clients already branch on for a disabled module.
|
||||
const gate = await requireModuleEnabled(user.id, "doctorReport");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return operationOutcome(429, "throttled", "Rate limit exceeded");
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { medicationAdministrationsFromReportData } from "@/lib/fhir/resources";
|
||||
@@ -24,6 +25,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth(FHIR_READ_SCOPE);
|
||||
annotate({ action: { name: "fhir.medicationadministration.search" } });
|
||||
|
||||
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
|
||||
// `/api/export/health-record` (the doctor-report builder), right down to
|
||||
// the decrypted insurance number on the Patient resource. That export
|
||||
// gates on the `doctorReport` module; this surface did not, so the module
|
||||
// could be off and `/api/fhir/*` still handed out the full record.
|
||||
//
|
||||
// REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is
|
||||
// a whole-record export, not an incremental feed: there is no partial
|
||||
// answer that is still a truthful FHIR Bundle, and no background client
|
||||
// depends on it draining to stay consistent. Mirroring the sibling export's
|
||||
// 403 `module.disabled` envelope (rather than an OperationOutcome) keeps
|
||||
// the errorCode the clients already branch on for a disabled module.
|
||||
const gate = await requireModuleEnabled(user.id, "doctorReport");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return operationOutcome(429, "throttled", "Rate limit exceeded");
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { medicationStatementsFromReportData } from "@/lib/fhir/resources";
|
||||
@@ -25,6 +26,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth(FHIR_READ_SCOPE);
|
||||
annotate({ action: { name: "fhir.medicationstatement.search" } });
|
||||
|
||||
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
|
||||
// `/api/export/health-record` (the doctor-report builder), right down to
|
||||
// the decrypted insurance number on the Patient resource. That export
|
||||
// gates on the `doctorReport` module; this surface did not, so the module
|
||||
// could be off and `/api/fhir/*` still handed out the full record.
|
||||
//
|
||||
// REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is
|
||||
// a whole-record export, not an incremental feed: there is no partial
|
||||
// answer that is still a truthful FHIR Bundle, and no background client
|
||||
// depends on it draining to stay consistent. Mirroring the sibling export's
|
||||
// 403 `module.disabled` envelope (rather than an OperationOutcome) keeps
|
||||
// the errorCode the clients already branch on for a disabled module.
|
||||
const gate = await requireModuleEnabled(user.id, "doctorReport");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return operationOutcome(429, "throttled", "Rate limit exceeded");
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { observationsFromReportData } from "@/lib/fhir/resources";
|
||||
@@ -25,6 +26,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth(FHIR_READ_SCOPE);
|
||||
annotate({ action: { name: "fhir.observation.search" } });
|
||||
|
||||
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
|
||||
// `/api/export/health-record` (the doctor-report builder), right down to
|
||||
// the decrypted insurance number on the Patient resource. That export
|
||||
// gates on the `doctorReport` module; this surface did not, so the module
|
||||
// could be off and `/api/fhir/*` still handed out the full record.
|
||||
//
|
||||
// REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is
|
||||
// a whole-record export, not an incremental feed: there is no partial
|
||||
// answer that is still a truthful FHIR Bundle, and no background client
|
||||
// depends on it draining to stay consistent. Mirroring the sibling export's
|
||||
// 403 `module.disabled` envelope (rather than an OperationOutcome) keeps
|
||||
// the errorCode the clients already branch on for a disabled module.
|
||||
const gate = await requireModuleEnabled(user.id, "doctorReport");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return operationOutcome(429, "throttled", "Rate limit exceeded");
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { patientResource } from "@/lib/fhir/resources";
|
||||
@@ -24,6 +25,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth(FHIR_READ_SCOPE);
|
||||
annotate({ action: { name: "fhir.patient.search" } });
|
||||
|
||||
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
|
||||
// `/api/export/health-record` (the doctor-report builder), right down to
|
||||
// the decrypted insurance number on the Patient resource. That export
|
||||
// gates on the `doctorReport` module; this surface did not, so the module
|
||||
// could be off and `/api/fhir/*` still handed out the full record.
|
||||
//
|
||||
// REFUSE, NOT OMIT — deliberately, and unlike the sync delta feed. This is
|
||||
// a whole-record export, not an incremental feed: there is no partial
|
||||
// answer that is still a truthful FHIR Bundle, and no background client
|
||||
// depends on it draining to stay consistent. Mirroring the sibling export's
|
||||
// 403 `module.disabled` envelope (rather than an OperationOutcome) keeps
|
||||
// the errorCode the clients already branch on for a disabled module.
|
||||
const gate = await requireModuleEnabled(user.id, "doctorReport");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(`fhir:${user.id}`, 120, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return operationOutcome(429, "throttled", "Rate limit exceeded");
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* `/api/fhir/*` — doctorReport module gate.
|
||||
*
|
||||
* The FHIR REST face serves the same whole-record aggregate as
|
||||
* `/api/export/health-record`, including the decrypted insurance number on
|
||||
* the Patient resource. The export gates on the `doctorReport` module; the
|
||||
* FHIR routes did not, so the module could be off and `$everything` still
|
||||
* returned the full Bundle to the same token.
|
||||
*
|
||||
* REFUSE, not omit — a whole-record export has no truthful partial answer.
|
||||
* Every data route 403s with the shared `module.disabled` envelope; only the
|
||||
* static CapabilityStatement at `/api/fhir/metadata` stays open (server
|
||||
* metadata, no user data).
|
||||
*
|
||||
* Behavioural: the assertions read status codes and check that no aggregate
|
||||
* was ever loaded, so removing a gate turns them red.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
// The module gate itself is NOT mocked — the whole point is to exercise the
|
||||
// real resolver and the real 403 envelope. Only its data sources are stubbed,
|
||||
// so the module state is driven the same way production drives it: through
|
||||
// the persisted `modulePreferencesJson` allowlist.
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/modules/operator-availability", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@/lib/modules/operator-availability")
|
||||
>();
|
||||
return { ...actual, getOperatorModuleAvailability: vi.fn() };
|
||||
});
|
||||
vi.mock("@/lib/fhir/rest", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/fhir/rest")>();
|
||||
return { ...actual, loadFhirContext: vi.fn() };
|
||||
});
|
||||
vi.mock("@/lib/fhir/resources", () => ({
|
||||
GERMAN_ATC_DEFAULT_LOCALES: ["de"],
|
||||
patientResource: vi.fn(() => ({ resourceType: "Patient", id: "p-1" })),
|
||||
coverageResource: vi.fn(() => null),
|
||||
observationsFromReportData: vi.fn(() => []),
|
||||
medicationStatementsFromReportData: vi.fn(() => []),
|
||||
medicationAdministrationsFromReportData: vi.fn(() => []),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET as patientGet } from "../Patient/route";
|
||||
import { GET as observationGet } from "../Observation/route";
|
||||
import { GET as medStatementGet } from "../MedicationStatement/route";
|
||||
import { GET as medAdminGet } from "../MedicationAdministration/route";
|
||||
import { GET as everythingGet } from "../$everything/route";
|
||||
import { GET as metadataGet } from "../metadata/route";
|
||||
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { MODULE_DISABLED_ERROR_CODE, MODULE_KEYS } from "@/lib/modules/gate";
|
||||
import { getOperatorModuleAvailability } from "@/lib/modules/operator-availability";
|
||||
import { loadFhirContext } from "@/lib/fhir/rest";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Drive the REAL gate the way production does — through the persisted
|
||||
* `modulePreferencesJson` disabled-allowlist, where only a literal `false`
|
||||
* turns a module off.
|
||||
*/
|
||||
function setDoctorReportModule(enabled: boolean): void {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
gender: null,
|
||||
disableCoach: false,
|
||||
modulePreferencesJson: enabled ? {} : { doctorReport: false },
|
||||
} as never);
|
||||
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
|
||||
}
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
/** Every data route under `/api/fhir`, by the path a caller would hit. */
|
||||
const DATA_ROUTES: ReadonlyArray<
|
||||
[string, (req: NextRequest) => Promise<Response>]
|
||||
> = [
|
||||
["Patient", patientGet],
|
||||
["Observation", observationGet],
|
||||
["MedicationStatement", medStatementGet],
|
||||
["MedicationAdministration", medAdminGet],
|
||||
["$everything", everythingGet],
|
||||
];
|
||||
|
||||
function req(path: string): NextRequest {
|
||||
return new NextRequest(`http://localhost/api/fhir/${path}`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
count: 1,
|
||||
resetAt: Date.now(),
|
||||
} as never);
|
||||
// Operator layer available for every module; the per-user layer is what
|
||||
// each test drives.
|
||||
vi.mocked(getOperatorModuleAvailability).mockResolvedValue(
|
||||
Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as never,
|
||||
);
|
||||
vi.mocked(loadFhirContext).mockResolvedValue({
|
||||
data: {} as never,
|
||||
identity: { insuranceNumber: "A123456789" },
|
||||
germanAtc: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/fhir/* — doctorReport module gate", () => {
|
||||
describe.each(DATA_ROUTES)("GET /api/fhir/%s", (path, handler) => {
|
||||
it("403s with module.disabled when the module is off", async () => {
|
||||
setDoctorReportModule(false);
|
||||
|
||||
const res = await handler(req(path));
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: unknown;
|
||||
error: string;
|
||||
meta?: { errorCode?: string; module?: string };
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.meta?.errorCode).toBe(MODULE_DISABLED_ERROR_CODE);
|
||||
expect(body.meta?.module).toBe("doctorReport");
|
||||
|
||||
// Refused before any record was assembled — the insurance number
|
||||
// never left the store.
|
||||
expect(loadFhirContext).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(body)).not.toContain("A123456789");
|
||||
});
|
||||
|
||||
it("serves the Bundle when the module is on", async () => {
|
||||
setDoctorReportModule(true);
|
||||
|
||||
const res = await handler(req(path));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toContain(
|
||||
"application/fhir+json",
|
||||
);
|
||||
|
||||
const bundle = (await res.json()) as { resourceType: string };
|
||||
expect(bundle.resourceType).toBe("Bundle");
|
||||
expect(loadFhirContext).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the static CapabilityStatement reachable while the module is off", async () => {
|
||||
// Server metadata, no user data — the one FHIR route that stays open.
|
||||
setDoctorReportModule(false);
|
||||
const res = await metadataGet();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { resourceType: string };
|
||||
expect(body.resourceType).toBe("CapabilityStatement");
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,10 @@ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(),
|
||||
isModuleEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/fhir/rest", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/fhir/rest")>();
|
||||
return { ...actual, loadFhirContext: vi.fn() };
|
||||
@@ -36,6 +40,7 @@ import { GET as observationGet } from "../Observation/route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { loadFhirContext, MAX_COUNT } from "@/lib/fhir/rest";
|
||||
import { requireModuleEnabled, isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { observationsFromReportData } from "@/lib/fhir/resources";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -65,6 +70,10 @@ beforeEach(() => {
|
||||
count: 1,
|
||||
resetAt: Date.now(),
|
||||
} as never);
|
||||
// The doctorReport module is ON for the contract assertions below; the
|
||||
// OFF behaviour is pinned in `module-gate.test.ts`.
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
|
||||
vi.mocked(isModuleEnabled).mockResolvedValue(true);
|
||||
vi.mocked(loadFhirContext).mockResolvedValue({
|
||||
data: {} as never,
|
||||
identity: { insuranceNumber: null },
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* `GET /api/sync/changes` — cycle module gate.
|
||||
*
|
||||
* The delta feed multiplexes five domains over one cursor and used to serve
|
||||
* `cycleDays` / `cycles` ungated, so an account with cycle tracking off got
|
||||
* period days and per-day symptom rows over the very token that
|
||||
* `/api/cycle/*` refuses with a 403.
|
||||
*
|
||||
* Behavioural, not structural: the assertions read the response body and the
|
||||
* Prisma call log, so reverting the gate in the route turns them red.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
cycleDayLog: { findMany: vi.fn() },
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decodeCursor } from "@/lib/sync/cursor";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
const NOW = new Date("2026-07-01T12:00:00.000Z");
|
||||
|
||||
/** One live cycle-day row carrying a symptom — the payload that must not leak. */
|
||||
const CYCLE_DAY_ROW = {
|
||||
id: "cd-1",
|
||||
userId: "user-1",
|
||||
externalId: "ext-cd-1",
|
||||
date: NOW,
|
||||
flow: "MEDIUM",
|
||||
notes: null,
|
||||
notesEncrypted: null,
|
||||
syncVersion: 3,
|
||||
deletedAt: null,
|
||||
updatedAt: NOW,
|
||||
createdAt: NOW,
|
||||
symptoms: [{ id: "sym-1", symptom: "CRAMPS", severity: 2 }],
|
||||
};
|
||||
|
||||
const CYCLE_ROW = {
|
||||
id: "mc-1",
|
||||
userId: "user-1",
|
||||
startDate: NOW,
|
||||
endDate: null,
|
||||
cycleLength: 28,
|
||||
periodLength: 5,
|
||||
predicted: false,
|
||||
syncVersion: 2,
|
||||
deletedAt: null,
|
||||
updatedAt: NOW,
|
||||
createdAt: NOW,
|
||||
};
|
||||
|
||||
interface FeedBody {
|
||||
data: {
|
||||
changes: {
|
||||
measurements: { upserts: unknown[]; tombstones: unknown[] };
|
||||
cycleDays: { upserts: unknown[]; tombstones: unknown[] };
|
||||
cycles: { upserts: unknown[]; tombstones: unknown[] };
|
||||
};
|
||||
cursor: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
function req(query = ""): NextRequest {
|
||||
return new NextRequest(`http://localhost/api/sync/changes${query}`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
count: 1,
|
||||
resetAt: Date.now(),
|
||||
} as never);
|
||||
// Non-cycle domains always return rows so a 403/empty-everything result
|
||||
// cannot be mistaken for a passing "cycle omitted" assertion.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "m-1",
|
||||
externalId: "ext-m-1",
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: NOW,
|
||||
source: "MANUAL",
|
||||
notes: null,
|
||||
notesEncrypted: null,
|
||||
syncVersion: 1,
|
||||
deletedAt: null,
|
||||
updatedAt: NOW,
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([
|
||||
CYCLE_DAY_ROW,
|
||||
] as never);
|
||||
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([
|
||||
CYCLE_ROW,
|
||||
] as never);
|
||||
});
|
||||
|
||||
describe("GET /api/sync/changes — cycle module gate", () => {
|
||||
it("omits cycle rows and never queries them when the module is off", async () => {
|
||||
vi.mocked(isModuleEnabled).mockResolvedValue(false);
|
||||
|
||||
const res = await GET(req());
|
||||
// OMIT, not refuse — the other four domains must keep syncing.
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as FeedBody;
|
||||
expect(body.data.changes.cycleDays.upserts).toEqual([]);
|
||||
expect(body.data.changes.cycleDays.tombstones).toEqual([]);
|
||||
expect(body.data.changes.cycles.upserts).toEqual([]);
|
||||
expect(body.data.changes.cycles.tombstones).toEqual([]);
|
||||
|
||||
// The rest of the feed is untouched — proves the empty cycle blocks are
|
||||
// the gate, not a wholesale refusal.
|
||||
expect(body.data.changes.measurements.upserts).toHaveLength(1);
|
||||
|
||||
// Not read out of Postgres at all.
|
||||
expect(prisma.cycleDayLog.findMany).not.toHaveBeenCalled();
|
||||
expect(prisma.menstrualCycle.findMany).not.toHaveBeenCalled();
|
||||
|
||||
// No symptom string anywhere in the serialised payload.
|
||||
expect(JSON.stringify(body)).not.toContain("CRAMPS");
|
||||
expect(JSON.stringify(body)).not.toContain("ext-cd-1");
|
||||
});
|
||||
|
||||
it("serves cycle rows when the module is on", async () => {
|
||||
vi.mocked(isModuleEnabled).mockResolvedValue(true);
|
||||
|
||||
const res = await GET(req());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as FeedBody;
|
||||
expect(body.data.changes.cycleDays.upserts).toHaveLength(1);
|
||||
expect(body.data.changes.cycles.upserts).toHaveLength(1);
|
||||
expect(prisma.cycleDayLog.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.menstrualCycle.findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("gates on the cycle module key specifically", async () => {
|
||||
vi.mocked(isModuleEnabled).mockResolvedValue(true);
|
||||
await GET(req());
|
||||
expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "cycle");
|
||||
});
|
||||
|
||||
it("leaves the cycle cursor watermarks untouched while the module is off", async () => {
|
||||
// A skipped domain must not advance its watermark, so re-enabling
|
||||
// resumes from where the client left off rather than skipping the rows
|
||||
// that changed while the module was hidden.
|
||||
vi.mocked(isModuleEnabled).mockResolvedValue(false);
|
||||
const res = await GET(req());
|
||||
const body = (await res.json()) as FeedBody;
|
||||
|
||||
const cursor = decodeCursor(body.data.cursor as string);
|
||||
expect(cursor).not.toBeNull();
|
||||
expect(cursor!.cycleDays).toBeUndefined();
|
||||
expect(cursor!.cycles).toBeUndefined();
|
||||
// The domain that did return rows advanced normally.
|
||||
expect(cursor!.measurements).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,9 @@
|
||||
* been pruned, so an incremental delta could silently miss it).
|
||||
* - `syncVersion` is echoed per row so the client can keep its mirror's
|
||||
* version monotonic.
|
||||
* - The `cycleDays` / `cycles` blocks are MODULE-GATED: an account with
|
||||
* cycle tracking off gets both blocks empty (never a 403 — see the
|
||||
* gate note in the handler) and their cursor watermarks untouched.
|
||||
*
|
||||
* `apiHandler` + `requireAuth` (cookie OR Bearer; iOS uses Bearer).
|
||||
* Read-only — no idempotency, no write side-effect (unlike the legacy
|
||||
@@ -67,6 +70,7 @@ import {
|
||||
type CycleDayLogDTO,
|
||||
type MenstrualCycleDTO,
|
||||
} from "@/lib/cycle/dto";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
const MAX_LIMIT = 500;
|
||||
@@ -192,6 +196,36 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const serverNow = new Date();
|
||||
|
||||
// ── module gate: cycle ────────────────────────────────────────────
|
||||
// The `cycleDays` / `cycles` domains carry period days and per-day
|
||||
// symptom rows — the same data every `/api/cycle/*` route refuses with a
|
||||
// 403 when the module is off. Without this the delta feed re-served them
|
||||
// ungated to the very same token.
|
||||
//
|
||||
// OMIT, NOT 403 — deliberately, and unlike the whole-record FHIR export.
|
||||
// This feed multiplexes five domains over one cursor: refusing the whole
|
||||
// request would wedge measurement / mood / intake catch-up for every
|
||||
// account with cycle tracking off, which (the gate delegates to
|
||||
// `isCycleEnabled`) is the default for most accounts. A partial payload
|
||||
// is the only outcome that keeps the client syncing while still honouring
|
||||
// the promise that a disabled module hands out nothing.
|
||||
//
|
||||
// The two cycle watermarks are deliberately NOT advanced while the module
|
||||
// is off (the queries are skipped, so `advanceCursor` sees an empty page
|
||||
// and leaves the prior watermark untouched). Re-enabling therefore resumes
|
||||
// from where the client actually left off instead of silently skipping the
|
||||
// rows that changed while the module was hidden.
|
||||
//
|
||||
// KNOWN GAP, out of scope here: a client that synced cycle rows BEFORE the
|
||||
// module was turned off keeps its stale local mirror. Emitting tombstones
|
||||
// for them would be wrong — a tombstone means deleted, and disabling a
|
||||
// module must leave the rows intact so re-enabling finds a complete
|
||||
// history. Purging a paired client's mirror needs an explicit
|
||||
// "module went dark" signal in the feed contract plus client support for
|
||||
// it; that is an additive wire change and an iOS-client change, not a
|
||||
// server-side gate fix, so it is left for a coordinated contract cycle.
|
||||
const cycleEnabled = await isModuleEnabled(user.id, "cycle");
|
||||
|
||||
// The retention horizon: tombstones older than this may have been
|
||||
// pruned by the cleanup job, so a cursor that predates it can no longer
|
||||
// be trusted to deliver every deletion incrementally.
|
||||
@@ -291,17 +325,23 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.cycleDayLog.findMany({
|
||||
where: { userId: user.id, ...keysetFilter(cursor.cycleDays) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
include: dayLogSymptomInclude,
|
||||
}),
|
||||
prisma.menstrualCycle.findMany({
|
||||
where: { userId: user.id, ...keysetFilter(cursor.cycles) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
}),
|
||||
// Skipped outright when the module is off — not filtered after the
|
||||
// fact — so the disabled rows are never read out of Postgres at all.
|
||||
cycleEnabled
|
||||
? prisma.cycleDayLog.findMany({
|
||||
where: { userId: user.id, ...keysetFilter(cursor.cycleDays) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
include: dayLogSymptomInclude,
|
||||
})
|
||||
: [],
|
||||
cycleEnabled
|
||||
? prisma.menstrualCycle.findMany({
|
||||
where: { userId: user.id, ...keysetFilter(cursor.cycles) },
|
||||
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||
take: limit + 1,
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
const nextCursor: SyncCursor = { ...cursor };
|
||||
@@ -464,6 +504,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
cycle_day_tombstones: cycleDayTombstones.length,
|
||||
cycle_upserts: cycleUpserts.length,
|
||||
cycle_tombstones: cycleTombstones.length,
|
||||
cycle_module_enabled: cycleEnabled,
|
||||
has_more: hasMore,
|
||||
cursor_present: Boolean(parsed.data.cursor),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Consent + budget contract for the self-context clarifying questions.
|
||||
*
|
||||
* The questions prompt ships the COMPLETE Coach snapshot, so it is a real PHI
|
||||
* egress and carries the same receipt requirement as the Coach chat. These
|
||||
* tests pin the two halves of that contract:
|
||||
*
|
||||
* - a chain that could reach the operator's server-managed credential
|
||||
* (`admin-openai` / `admin-codex`) refuses without an active receipt and
|
||||
* lands on the deterministic hints — the provider is never called, and the
|
||||
* snapshot is never even built;
|
||||
* - the same chain proceeds once a receipt is on file;
|
||||
* - a BYOK / local chain stays ungated (the user's own egress);
|
||||
* - the budget runs through the ATOMIC reserve/reconcile pair, not a
|
||||
* read-then-write check.
|
||||
*
|
||||
* `@/lib/consent/receipts` is mocked rather than the consent guard itself, so
|
||||
* the real `chainRequiresServerManagedConsent` + `hasActiveConsentForSurface`
|
||||
* logic is what these tests exercise.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
|
||||
const latestActiveReceipt = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/consent/receipts", () => ({ latestActiveReceipt }));
|
||||
vi.mock("@/lib/documents/document-settings", () => ({
|
||||
documentAutoReadEnabled: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
const providerMocks = vi.hoisted(() => ({
|
||||
hasAnyConfiguredProvider: vi.fn(async () => true),
|
||||
resolveProvider: vi.fn(async () => ({ type: "none" })),
|
||||
resolveProviderChain: vi.fn(async () => [] as unknown[]),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider", () => providerMocks);
|
||||
|
||||
const budgetMocks = vi.hoisted(() => ({
|
||||
buildDateKey: vi.fn(() => "2026-07-18"),
|
||||
reserveBudget: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
reserved: 300,
|
||||
totalAfter: 300,
|
||||
})),
|
||||
reconcileSpend: vi.fn(async () => {}),
|
||||
resolveDailyCap: vi.fn(() => 200_000),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => budgetMocks);
|
||||
|
||||
const buildCoachSnapshot = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/ai/coach/snapshot", () => ({ buildCoachSnapshot }));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
import { deriveClarifyingQuestions } from "../self-context-questions";
|
||||
import type { SelfContext } from "../about-me";
|
||||
|
||||
const ctx: SelfContext = {
|
||||
aboutMe: "Shift work, half-marathon training.",
|
||||
conditions: "Hypertension",
|
||||
allergies: null,
|
||||
coachFocus: null,
|
||||
};
|
||||
|
||||
/** A provider whose completion returns three usable questions. */
|
||||
function makeProvider() {
|
||||
return {
|
||||
type: "openai",
|
||||
generateCompletion: vi.fn(async () => ({
|
||||
content: '["Question one?","Question two?"]',
|
||||
model: "gpt-4o",
|
||||
tokensUsed: 120,
|
||||
cachedInputTokens: 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
let provider: ReturnType<typeof makeProvider>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
provider = makeProvider();
|
||||
latestActiveReceipt.mockResolvedValue(null);
|
||||
providerMocks.hasAnyConfiguredProvider.mockResolvedValue(true);
|
||||
providerMocks.resolveProvider.mockResolvedValue({ type: "none" });
|
||||
budgetMocks.reserveBudget.mockResolvedValue({
|
||||
allowed: true,
|
||||
reserved: 300,
|
||||
totalAfter: 300,
|
||||
});
|
||||
buildCoachSnapshot.mockResolvedValue({ snapshotJson: '{"weight":[]}' });
|
||||
});
|
||||
|
||||
describe("deriveClarifyingQuestions — server-managed consent gate", () => {
|
||||
it("refuses the AI path on an operator-managed chain with no receipt", async () => {
|
||||
providerMocks.resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "admin-openai", instance: provider },
|
||||
]);
|
||||
|
||||
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
|
||||
|
||||
expect(out.source).toBe("fallback");
|
||||
// The snapshot is the PHI: it must never even be built, let alone sent.
|
||||
expect(provider.generateCompletion).not.toHaveBeenCalled();
|
||||
expect(buildCoachSnapshot).not.toHaveBeenCalled();
|
||||
// And no budget is consumed by a refused request.
|
||||
expect(budgetMocks.reserveBudget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses on the operator-shared central Codex with no receipt", async () => {
|
||||
providerMocks.resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "admin-codex", instance: provider },
|
||||
]);
|
||||
|
||||
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
|
||||
|
||||
expect(out.source).toBe("fallback");
|
||||
expect(provider.generateCompletion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses when the legacy single-provider fallback serves the request", async () => {
|
||||
// Empty chain → `resolveProvider` fallback, which is tagged `admin-openai`
|
||||
// because an admin-key `OpenAIClient` is indistinguishable from a BYOK one.
|
||||
providerMocks.resolveProviderChain.mockResolvedValue([]);
|
||||
providerMocks.resolveProvider.mockResolvedValue(provider);
|
||||
|
||||
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
|
||||
|
||||
expect(out.source).toBe("fallback");
|
||||
expect(provider.generateCompletion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("proceeds on an operator-managed chain once an ai_coach receipt is active", async () => {
|
||||
providerMocks.resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "admin-openai", instance: provider },
|
||||
]);
|
||||
latestActiveReceipt.mockImplementation(async (_userId, kind) =>
|
||||
kind === "ai_coach" ? { id: "receipt-1" } : null,
|
||||
);
|
||||
|
||||
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
|
||||
|
||||
expect(out.source).toBe("ai");
|
||||
expect(out.questions).toEqual(["Question one?", "Question two?"]);
|
||||
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("accepts the master ai_full grant for the coach surface", async () => {
|
||||
providerMocks.resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "admin-openai", instance: provider },
|
||||
]);
|
||||
latestActiveReceipt.mockImplementation(async (_userId, kind) =>
|
||||
kind === "ai_full" ? { id: "receipt-2" } : null,
|
||||
);
|
||||
|
||||
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
|
||||
|
||||
expect(out.source).toBe("ai");
|
||||
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves a BYOK chain ungated — the user's own egress needs no receipt", async () => {
|
||||
providerMocks.resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "openai", instance: provider },
|
||||
]);
|
||||
|
||||
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
|
||||
|
||||
expect(out.source).toBe("ai");
|
||||
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
|
||||
expect(latestActiveReceipt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves a local chain ungated", async () => {
|
||||
providerMocks.resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "local", instance: provider },
|
||||
]);
|
||||
|
||||
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
|
||||
|
||||
expect(out.source).toBe("ai");
|
||||
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails closed when a server-managed entry sits BEHIND a BYOK primary", async () => {
|
||||
// The runner may cascade to the admin key on a primary failure, so the
|
||||
// whole chain is what the gate judges — not just its head.
|
||||
providerMocks.resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "openai", instance: provider },
|
||||
{ providerType: "admin-openai", instance: provider },
|
||||
]);
|
||||
|
||||
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
|
||||
|
||||
expect(out.source).toBe("fallback");
|
||||
expect(provider.generateCompletion).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveClarifyingQuestions — atomic budget", () => {
|
||||
beforeEach(() => {
|
||||
providerMocks.resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "openai", instance: provider },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reserves before the call and reconciles the actual spend after", async () => {
|
||||
await deriveClarifyingQuestions("user-1", ctx, "en");
|
||||
|
||||
expect(budgetMocks.reserveBudget).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
300,
|
||||
"2026-07-18",
|
||||
200_000,
|
||||
);
|
||||
expect(budgetMocks.reconcileSpend).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
300,
|
||||
120,
|
||||
"2026-07-18",
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back without calling the provider when the reservation is refused", async () => {
|
||||
budgetMocks.reserveBudget.mockResolvedValue({
|
||||
allowed: false,
|
||||
reserved: 300,
|
||||
totalAfter: 200_000,
|
||||
});
|
||||
|
||||
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
|
||||
|
||||
expect(out.source).toBe("fallback");
|
||||
expect(provider.generateCompletion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reconciles the reservation to zero when the provider throws", async () => {
|
||||
provider.generateCompletion.mockRejectedValue(new Error("upstream down"));
|
||||
|
||||
const out = await deriveClarifyingQuestions("user-1", ctx, "en");
|
||||
|
||||
expect(out.source).toBe("fallback");
|
||||
expect(budgetMocks.reconcileSpend).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
300,
|
||||
0,
|
||||
"2026-07-18",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -10,24 +10,42 @@
|
||||
*
|
||||
* Two paths, gated in order:
|
||||
* 1. AI path — only when the user has a working provider
|
||||
* (`hasAnyConfiguredProvider`) AND the daily Coach token budget
|
||||
* is not exhausted. One single-shot completion (no fallback
|
||||
* chain — this is a nicety, not a core flow); spend is recorded
|
||||
* against the same per-day ledger the Coach uses.
|
||||
* (`hasAnyConfiguredProvider`), an active consent receipt covers
|
||||
* the egress, AND the daily Coach token budget is not exhausted.
|
||||
* One single-shot completion (no fallback chain — this is a
|
||||
* nicety, not a core flow); spend rides the same per-day ledger
|
||||
* the Coach uses.
|
||||
* 2. Deterministic path — two static completion hints for the first
|
||||
* two unanswered fields, localised through the server translator.
|
||||
* Also the landing spot for every AI-path failure (no provider,
|
||||
* budget gone, model error, unparseable output).
|
||||
* consent missing, budget gone, model error, unparseable output).
|
||||
*
|
||||
* The prompt carries the FULL Coach snapshot, so this is a genuine PHI
|
||||
* egress and it is gated exactly like every other Coach surface: a chain
|
||||
* that could reach the operator's server-managed credential requires an
|
||||
* active `ai_coach` / `ai_full` receipt. The gate is SKIP-shaped, not
|
||||
* throw-shaped — a missing receipt lands on the deterministic hints, which
|
||||
* is what the caller (`PUT /api/coach/about-me`) already expects for every
|
||||
* other AI-path miss.
|
||||
*
|
||||
* Server-only — reads `@/lib/db` through the provider resolver.
|
||||
*/
|
||||
import { hasAnyConfiguredProvider, resolveProvider } from "@/lib/ai/provider";
|
||||
import {
|
||||
hasAnyConfiguredProvider,
|
||||
resolveProvider,
|
||||
resolveProviderChain,
|
||||
} from "@/lib/ai/provider";
|
||||
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
|
||||
import {
|
||||
chainRequiresServerManagedConsent,
|
||||
hasActiveConsentForSurface,
|
||||
} from "@/lib/ai/consent-guard";
|
||||
import { singleUserTurn } from "@/lib/ai/types";
|
||||
import {
|
||||
buildDateKey,
|
||||
getDailyTokenSpend,
|
||||
OPERATOR_COST_CAP,
|
||||
recordSpend,
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveDailyCap,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import {
|
||||
clampPendingQuestions,
|
||||
@@ -116,6 +134,27 @@ export function buildPrompts(
|
||||
return { systemPrompt, userPrompt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the chain that will serve this generation, so the consent gate and
|
||||
* the daily cap both see the SAME provider set the call will actually use.
|
||||
* Mirrors `resolveStatusChain` in `@/lib/insights/status-provider`: the chain
|
||||
* first, then the legacy single provider tagged `admin-openai` — the
|
||||
* conservative tag, because `resolveProvider`'s admin fallback returns a bare
|
||||
* `OpenAIClient` that is indistinguishable from a BYOK key at the instance
|
||||
* level. Tagging it as server-managed makes the gate fail closed. Returns null
|
||||
* when nothing can serve.
|
||||
*/
|
||||
async function resolveQuestionsChain(
|
||||
userId: string,
|
||||
): Promise<ProviderChainResolved[] | null> {
|
||||
const chain = await resolveProviderChain(userId);
|
||||
if (chain.length > 0) return chain;
|
||||
|
||||
const legacy = await resolveProvider(userId);
|
||||
if (legacy.type === "none") return null;
|
||||
return [{ providerType: "admin-openai", instance: legacy }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the pending questions for a freshly saved self-context.
|
||||
* Never throws — every failure path lands on the deterministic
|
||||
@@ -134,12 +173,42 @@ export async function deriveClarifyingQuestions(
|
||||
try {
|
||||
if (!(await hasAnyConfiguredProvider(userId))) return fallback();
|
||||
|
||||
// Budget gate — same per-day ledger the Coach chat enforces.
|
||||
const dateKey = buildDateKey();
|
||||
const spent = await getDailyTokenSpend(userId, dateKey);
|
||||
if (spent >= OPERATOR_COST_CAP) return fallback();
|
||||
const chain = await resolveQuestionsChain(userId);
|
||||
if (chain === null) return fallback();
|
||||
|
||||
const provider = await resolveProvider(userId);
|
||||
// Consent gate — BEFORE the snapshot is built, let alone sent. This
|
||||
// prompt ships the complete Coach snapshot, so a chain that could egress
|
||||
// via the operator's server-managed credential needs an active
|
||||
// `ai_coach` / `ai_full` receipt. BYOK / local / ChatGPT-OAuth chains are
|
||||
// the user's own egress and stay ungated, matching every other surface.
|
||||
if (
|
||||
chainRequiresServerManagedConsent(chain) &&
|
||||
!(await hasActiveConsentForSurface(userId, "coach"))
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "coach.self_context.consent_required" },
|
||||
meta: { self_context_questions_source: "fallback" },
|
||||
});
|
||||
return fallback();
|
||||
}
|
||||
|
||||
// Budget gate — the same per-day ledger the Coach chat enforces, via the
|
||||
// ATOMIC reserve/reconcile pair. The prior read-then-write
|
||||
// (`getDailyTokenSpend` then `recordSpend`) let two concurrent saves both
|
||||
// observe a sub-cap spend and both call the provider; the upsert-increment
|
||||
// serialises them on the row's unique key instead. The cap follows the
|
||||
// chain's cost owner, so a user on their own plan is not held to the
|
||||
// operator-cost ceiling.
|
||||
const dateKey = buildDateKey();
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
QUESTIONS_MAX_TOKENS,
|
||||
dateKey,
|
||||
resolveDailyCap(chain),
|
||||
);
|
||||
if (!reservation.allowed) return fallback();
|
||||
|
||||
const provider = chain[0].instance;
|
||||
// v1.16.6 — same snapshot the Coach chat rides; lets the model
|
||||
// ask about what the user actually tracks. Best-effort: a snapshot
|
||||
// failure must not cost the AI path, the prompt just stays
|
||||
@@ -152,18 +221,34 @@ export async function deriveClarifyingQuestions(
|
||||
resolveLocale(locale),
|
||||
snapshotJson,
|
||||
);
|
||||
const result = await provider.generateCompletion(
|
||||
singleUserTurn({
|
||||
system: systemPrompt,
|
||||
user: userPrompt,
|
||||
temperature: 0.4,
|
||||
maxTokens: QUESTIONS_MAX_TOKENS,
|
||||
}),
|
||||
);
|
||||
if (typeof result.tokensUsed === "number" && result.tokensUsed > 0) {
|
||||
await recordSpend({ userId, tokens: result.tokensUsed, dateKey });
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await provider.generateCompletion(
|
||||
singleUserTurn({
|
||||
system: systemPrompt,
|
||||
user: userPrompt,
|
||||
temperature: 0.4,
|
||||
maxTokens: QUESTIONS_MAX_TOKENS,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
// Reconcile the reservation down to zero actual spend before the
|
||||
// failure lands on the deterministic fallback.
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey).catch(
|
||||
() => {},
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
reservation.reserved,
|
||||
result.tokensUsed ?? 0,
|
||||
dateKey,
|
||||
result.cachedInputTokens ?? 0,
|
||||
).catch(() => {});
|
||||
|
||||
const questions = parseQuestionsReply(result.content);
|
||||
if (questions.length === 0) return fallback();
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ export {
|
||||
} from "@/lib/dashboard/widget-modules";
|
||||
import {
|
||||
WIDGET_MODULE_BY_ID,
|
||||
SUMMARY_TYPE_MODULE,
|
||||
disabledSummaryTypes,
|
||||
} from "@/lib/dashboard/widget-modules";
|
||||
|
||||
const GLUCOSE_CONTEXTS = [
|
||||
@@ -965,10 +965,9 @@ function gateSummariesByModules(
|
||||
summaries: Record<string, DataSummary>;
|
||||
lastSeenByType: Record<string, { lastSeenAt: string } | null>;
|
||||
} {
|
||||
const dropped = new Set<string>();
|
||||
for (const [type, moduleKey] of Object.entries(SUMMARY_TYPE_MODULE)) {
|
||||
if (moduleKey && modules[moduleKey] === false) dropped.add(type);
|
||||
}
|
||||
// The drop decision is shared with `GET /api/dashboard/summary` — see
|
||||
// `disabledSummaryTypes`. Only the filtering below is snapshot-shaped.
|
||||
const dropped = disabledSummaryTypes(modules);
|
||||
if (dropped.size === 0) return { summaries, lastSeenByType };
|
||||
const outSummaries: Record<string, DataSummary> = {};
|
||||
for (const [type, summary] of Object.entries(summaries)) {
|
||||
|
||||
@@ -75,3 +75,70 @@ export const SUMMARY_TYPE_MODULE: Partial<Record<string, ModuleKey>> = {
|
||||
// on the `nutrients` module like the rest of that store's surfaces.
|
||||
NUTRIENT_WATER: "nutrients",
|
||||
};
|
||||
|
||||
/**
|
||||
* The summary types a module map turns off. Lifted out of
|
||||
* `gateSummariesByModules` in `@/lib/dashboard/snapshot` so the two
|
||||
* dashboard aggregates that both surface per-metric data — the snapshot
|
||||
* builder and the iOS `GET /api/dashboard/summary` payload — decide from
|
||||
* ONE map instead of each carrying its own copy.
|
||||
*
|
||||
* The two callers shape their payloads differently (the snapshot keys
|
||||
* summaries by `MeasurementType`; the summary route emits an array of
|
||||
* metric cards keyed by an iOS `MetricKind`), so what is shared is this
|
||||
* decision, not the filtering itself.
|
||||
*/
|
||||
export function disabledSummaryTypes(
|
||||
modules: Partial<Record<ModuleKey, boolean>>,
|
||||
): Set<string> {
|
||||
const dropped = new Set<string>();
|
||||
for (const [type, moduleKey] of Object.entries(SUMMARY_TYPE_MODULE)) {
|
||||
if (moduleKey && modules[moduleKey] === false) dropped.add(type);
|
||||
}
|
||||
return dropped;
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS `MetricKind` (the `kind` on a `GET /api/dashboard/summary` metric
|
||||
* card) → the `MeasurementType` it is built from. The summary route emits
|
||||
* cards by kind, but module membership is defined per measurement type in
|
||||
* `SUMMARY_TYPE_MODULE` above; this map is the join between the two so the
|
||||
* summary payload gates off the SAME source of truth as the snapshot rather
|
||||
* than a second, drift-prone kind→module list.
|
||||
*
|
||||
* Kinds whose type carries no `SUMMARY_TYPE_MODULE` entry (weight, blood
|
||||
* pressure, pulse, body fat, steps, body water, bone mass, SpO₂) are core
|
||||
* vitals and always pass through — they are listed anyway so a reader can
|
||||
* see the full emitted set and so a new card cannot be added without
|
||||
* deciding which type backs it.
|
||||
*/
|
||||
export const SUMMARY_METRIC_TYPE_BY_KIND: Record<string, string> = {
|
||||
weight: "WEIGHT",
|
||||
bloodPressure: "BLOOD_PRESSURE_SYS",
|
||||
pulse: "PULSE",
|
||||
bodyFat: "BODY_FAT",
|
||||
glucose: "BLOOD_GLUCOSE",
|
||||
sleep: "SLEEP_DURATION",
|
||||
steps: "ACTIVITY_STEPS",
|
||||
totalBodyWater: "TOTAL_BODY_WATER",
|
||||
boneMass: "BONE_MASS",
|
||||
oxygenSaturation: "OXYGEN_SATURATION",
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop the metric cards whose backing measurement type belongs to a module
|
||||
* the account turned off. A card whose kind is absent from
|
||||
* `SUMMARY_METRIC_TYPE_BY_KIND` is kept — an unmapped kind is a core metric
|
||||
* or a new one, and silently hiding it would be worse than surfacing it.
|
||||
*/
|
||||
export function gateMetricCardsByModules<T extends { kind: string }>(
|
||||
cards: ReadonlyArray<T>,
|
||||
modules: Partial<Record<ModuleKey, boolean>>,
|
||||
): T[] {
|
||||
const dropped = disabledSummaryTypes(modules);
|
||||
if (dropped.size === 0) return [...cards];
|
||||
return cards.filter((card) => {
|
||||
const type = SUMMARY_METRIC_TYPE_BY_KIND[card.kind];
|
||||
return !type || !dropped.has(type);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* `loadFhirContext` — doctorReport module backstop.
|
||||
*
|
||||
* The loader is the one door to the whole-record aggregate the FHIR face
|
||||
* serves, including the decrypted insurance number. Every `/api/fhir/*` data
|
||||
* route carries its own `requireModuleEnabled` gate (that is what produces the
|
||||
* clean 403); this test pins the SECOND layer — the loader refuses outright
|
||||
* rather than trusting that every present and future caller remembered.
|
||||
*
|
||||
* The gate resolver is exercised for real; only its data sources are stubbed.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/modules/operator-availability", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@/lib/modules/operator-availability")
|
||||
>();
|
||||
return { ...actual, getOperatorModuleAvailability: vi.fn() };
|
||||
});
|
||||
vi.mock("@/lib/doctor-report-data", () => ({
|
||||
collectDoctorReportData: vi.fn(),
|
||||
normaliseDateRange: vi.fn(() => ({ from: new Date(0), to: new Date() })),
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "A123456789") }));
|
||||
|
||||
import { loadFhirContext } from "@/lib/fhir/rest";
|
||||
import { MODULE_KEYS } from "@/lib/modules/gate";
|
||||
import { getOperatorModuleAvailability } from "@/lib/modules/operator-availability";
|
||||
import { collectDoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
function setDoctorReportModule(enabled: boolean): void {
|
||||
vi.mocked(prisma.user.findUnique).mockImplementation((async (args: {
|
||||
select?: Record<string, boolean>;
|
||||
}) => {
|
||||
// The gate's own read and the loader's identity read hit the same model.
|
||||
if (args?.select?.modulePreferencesJson) {
|
||||
return {
|
||||
gender: null,
|
||||
disableCoach: false,
|
||||
modulePreferencesJson: enabled ? {} : { doctorReport: false },
|
||||
};
|
||||
}
|
||||
return { insuranceNumberEncrypted: "cipher", locale: "de" };
|
||||
}) as never);
|
||||
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getOperatorModuleAvailability).mockResolvedValue(
|
||||
Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as never,
|
||||
);
|
||||
vi.mocked(collectDoctorReportData).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("loadFhirContext — doctorReport backstop", () => {
|
||||
it("refuses to assemble the record when the module is off", async () => {
|
||||
setDoctorReportModule(false);
|
||||
|
||||
await expect(loadFhirContext("user-1")).rejects.toThrow(
|
||||
/doctorReport.*disabled/,
|
||||
);
|
||||
|
||||
// Nothing was aggregated and nothing was decrypted.
|
||||
expect(collectDoctorReportData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("assembles the record when the module is on", async () => {
|
||||
setDoctorReportModule(true);
|
||||
|
||||
const ctx = await loadFhirContext("user-1");
|
||||
expect(collectDoctorReportData).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.identity.insuranceNumber).toBe("A123456789");
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type DoctorReportData,
|
||||
} from "@/lib/doctor-report-data";
|
||||
import { GERMAN_ATC_DEFAULT_LOCALES } from "@/lib/fhir/resources";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import type {
|
||||
FhirBundleEntry,
|
||||
FhirBundleLink,
|
||||
@@ -182,12 +183,28 @@ export function operationOutcome(
|
||||
* The window matches the document export's default (`normaliseDateRange`
|
||||
* with no override). KVNR decryption is fail-soft: a key-rotation gap on a
|
||||
* single row omits the identifier rather than 500-ing the read.
|
||||
*
|
||||
* MODULE BACKSTOP. Every `/api/fhir/*` data route calls
|
||||
* `requireModuleEnabled(userId, "doctorReport")` itself, which is what
|
||||
* produces the clean 403 envelope. This assertion is the second layer: the
|
||||
* loader is the one door to the whole-record aggregate (including the
|
||||
* decrypted insurance number), so it refuses outright rather than trusting
|
||||
* that every present and future caller remembered the gate. It throws
|
||||
* instead of returning an envelope because reaching it means a caller is
|
||||
* missing its gate — a bug to surface, not a flow to serve.
|
||||
*/
|
||||
export async function loadFhirContext(userId: string): Promise<{
|
||||
data: DoctorReportData;
|
||||
identity: { insuranceNumber: string | null };
|
||||
germanAtc: boolean;
|
||||
}> {
|
||||
if (!(await isModuleEnabled(userId, "doctorReport"))) {
|
||||
throw new Error(
|
||||
'loadFhirContext called with the "doctorReport" module disabled — ' +
|
||||
"the calling route is missing its requireModuleEnabled gate",
|
||||
);
|
||||
}
|
||||
|
||||
const range = normaliseDateRange(undefined);
|
||||
const [data, userRow] = await Promise.all([
|
||||
collectDoctorReportData(userId, range, {}),
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Consent contract for the AI-composed proactive Coach nudge.
|
||||
*
|
||||
* The nudge sends less PHI than the Coach chat (an abstract trigger topic plus
|
||||
* the deterministic template body — never the user's own words or figures), but
|
||||
* it is still the user's health situation leaving the server, it runs
|
||||
* unattended on the 05:15 tick, and the chain can end at the operator's
|
||||
* server-managed credential. So it carries the same receipt requirement.
|
||||
*
|
||||
* The gate is skip-shaped: no receipt → `null` → the caller ships the
|
||||
* deterministic template, so the nudge itself is never lost.
|
||||
*
|
||||
* `@/lib/consent/receipts` is mocked rather than the consent guard, so the real
|
||||
* `chainRequiresServerManagedConsent` + `hasActiveConsentForSurface` logic runs.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
|
||||
const latestActiveReceipt = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/consent/receipts", () => ({ latestActiveReceipt }));
|
||||
vi.mock("@/lib/documents/document-settings", () => ({
|
||||
documentAutoReadEnabled: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
const resolveProviderChain = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain }));
|
||||
|
||||
const budgetMocks = vi.hoisted(() => ({
|
||||
buildDateKey: vi.fn(() => "2026-07-18"),
|
||||
reserveBudget: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
reserved: 160,
|
||||
totalAfter: 160,
|
||||
})),
|
||||
reconcileSpend: vi.fn(async () => {}),
|
||||
resolveDailyCap: vi.fn(() => 200_000),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => budgetMocks);
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
import {
|
||||
composeNudgeWithAI,
|
||||
createNudgeAiTickBudget,
|
||||
type ComposeNudgeParams,
|
||||
} from "../coach-nudge-ai";
|
||||
|
||||
function makeProvider() {
|
||||
return {
|
||||
type: "openai",
|
||||
generateCompletion: vi.fn(async () => ({
|
||||
content: "Your rhythm has shifted a little this week — worth a look?",
|
||||
model: "gpt-4o",
|
||||
tokensUsed: 90,
|
||||
cachedInputTokens: 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
let provider: ReturnType<typeof makeProvider>;
|
||||
|
||||
function params(): ComposeNudgeParams {
|
||||
return {
|
||||
userId: "user-1",
|
||||
trigger: "compliance",
|
||||
locale: "en",
|
||||
name: "A",
|
||||
hasCoachFocus: false,
|
||||
template: { title: "Morning", body: "A gentle deterministic body." },
|
||||
tickBudget: createNudgeAiTickBudget(),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
provider = makeProvider();
|
||||
latestActiveReceipt.mockResolvedValue(null);
|
||||
budgetMocks.reserveBudget.mockResolvedValue({
|
||||
allowed: true,
|
||||
reserved: 160,
|
||||
totalAfter: 160,
|
||||
});
|
||||
});
|
||||
|
||||
describe("composeNudgeWithAI — server-managed consent gate", () => {
|
||||
it("skips AI composition on an operator-managed chain with no receipt", async () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "admin-openai", instance: provider },
|
||||
]);
|
||||
|
||||
const out = await composeNudgeWithAI(params());
|
||||
|
||||
// null = the caller keeps the deterministic template.
|
||||
expect(out).toBeNull();
|
||||
expect(provider.generateCompletion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("burns neither a budget reservation nor a per-tick slot when refused", async () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "admin-openai", instance: provider },
|
||||
]);
|
||||
const p = params();
|
||||
|
||||
await composeNudgeWithAI(p);
|
||||
|
||||
expect(budgetMocks.reserveBudget).not.toHaveBeenCalled();
|
||||
expect(p.tickBudget.remainingCount).toBe(
|
||||
createNudgeAiTickBudget().remainingCount,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips on the operator-shared central Codex with no receipt", async () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "admin-codex", instance: provider },
|
||||
]);
|
||||
|
||||
expect(await composeNudgeWithAI(params())).toBeNull();
|
||||
expect(provider.generateCompletion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("composes once an ai_coach receipt is active", async () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "admin-openai", instance: provider },
|
||||
]);
|
||||
latestActiveReceipt.mockImplementation(async (_userId, kind) =>
|
||||
kind === "ai_coach" ? { id: "receipt-1" } : null,
|
||||
);
|
||||
|
||||
const out = await composeNudgeWithAI(params());
|
||||
|
||||
expect(out).not.toBeNull();
|
||||
expect(out?.body).toBe(
|
||||
"Your rhythm has shifted a little this week — worth a look?",
|
||||
);
|
||||
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("accepts the master ai_full grant", async () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "admin-openai", instance: provider },
|
||||
]);
|
||||
latestActiveReceipt.mockImplementation(async (_userId, kind) =>
|
||||
kind === "ai_full" ? { id: "receipt-2" } : null,
|
||||
);
|
||||
|
||||
expect(await composeNudgeWithAI(params())).not.toBeNull();
|
||||
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves a BYOK chain ungated — the user's own egress needs no receipt", async () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "openai", instance: provider },
|
||||
]);
|
||||
|
||||
expect(await composeNudgeWithAI(params())).not.toBeNull();
|
||||
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
|
||||
expect(latestActiveReceipt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when a server-managed entry sits BEHIND a BYOK primary", async () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "openai", instance: provider },
|
||||
{ providerType: "admin-openai", instance: provider },
|
||||
]);
|
||||
|
||||
expect(await composeNudgeWithAI(params())).toBeNull();
|
||||
expect(provider.generateCompletion).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,13 @@
|
||||
* model's output.
|
||||
*
|
||||
* Hard guards (non-negotiable):
|
||||
* - a CONSENT gate: a chain that could egress via the operator's
|
||||
* server-managed credential requires an active `ai_coach` / `ai_full`
|
||||
* receipt. The PHI volume here is small (an abstract trigger topic plus
|
||||
* the deterministic template body) but it is still the user's health
|
||||
* situation leaving the server on a credential they did not contract, and
|
||||
* this tick runs unattended — so it carries the same receipt requirement
|
||||
* as the interactive Coach;
|
||||
* - per-user daily BUDGET gate (the same reserve/reconcile ledger the Coach
|
||||
* chat uses, with the chain-aware daily cap);
|
||||
* - a tight per-call TIMEOUT (≤ ~9 s) on both the provider `timeoutMs` and a
|
||||
@@ -28,6 +35,10 @@
|
||||
import type { CoachNudgeTrigger } from "@/lib/jobs/coach-nudge";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { resolveProviderChain } from "@/lib/ai/provider";
|
||||
import {
|
||||
chainRequiresServerManagedConsent,
|
||||
hasActiveConsentForSurface,
|
||||
} from "@/lib/ai/consent-guard";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
@@ -156,6 +167,19 @@ export const composeNudgeWithAI: ComposeNudgeWithAI = async (params) => {
|
||||
const chain = await resolveProviderChain(params.userId);
|
||||
if (chain.length === 0) return null;
|
||||
|
||||
// Consent gate — before the budget reservation, so a user without a
|
||||
// receipt never spends a slot or a token. Skip-shaped like every other
|
||||
// guard here: no receipt → return null and the caller ships the
|
||||
// deterministic template, so the nudge itself is never lost. BYOK / local
|
||||
// / ChatGPT-OAuth chains are the user's own egress and stay ungated.
|
||||
if (
|
||||
chainRequiresServerManagedConsent(chain) &&
|
||||
!(await hasActiveConsentForSurface(params.userId, "coach"))
|
||||
) {
|
||||
annotate({ action: { name: "coach.nudge.ai.consent_required" } });
|
||||
return null;
|
||||
}
|
||||
|
||||
const budget = AI_BUDGETS.coachNudge;
|
||||
const maxTokens = budget.maxTokens ?? 160;
|
||||
const dailyCap = resolveDailyCap(chain);
|
||||
|
||||
Reference in new issue
Block a user