diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts new file mode 100644 index 000000000..4a4bbf87f --- /dev/null +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -0,0 +1,322 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry"; + +/** + * v1.18.0 — module API route gate inventory. + * + * The v1.18.0 module retrofit can hide a toggleable module across the + * nav, dashboard tiles, Insights pills, reminder jobs, doctor-report + * sections, and the AI surfaces. The ONE server-side enforcement point + * is `@/lib/modules/gate` — `requireModuleEnabled(userId, key)` in API + * routes, `isModuleEnabled` / `resolveModuleMap` in builders + jobs + + * components. Without a discovery test, a future contributor who lands a + * new per-domain route for a toggleable module (a new mood-analysis read, + * a new sleep view, a new glucose panel) would have to remember the gate + * by hand. A silent miss leaks the surface over a Bearer token even when + * the account — or the operator — turned the module off. + * + * This test walks every per-domain route tree that serves a TOGGLEABLE + * module and pins each `route.ts` into exactly one of these buckets: + * + * 1. MODULE-GATED — the file calls `requireModuleEnabled(...)` for the + * right key (mood / sleep / glucose / workouts / recovery / labs / + * achievements). This is the direct gate. + * + * 2. DELEGATED — the module's enabled-state is owned elsewhere and the + * route gates on that single source of truth instead of re-deriving + * it (no double source of truth, mirroring the registry's + * `delegatesTo`): + * - cycle → `requireCycleEnabled(...)` (the cycle gate the + * `cycle` ModuleKey delegates to). + * - coach → `requireAssistantSurface("coach")` (the assistant + * master flag + per-user opt-out the `coach` ModuleKey + * delegates to). Covered in depth by the sibling + * `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 + * 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 + * reading a disabled module's data is harmless — the module gate + * governs whether the domain SURFACES (nav, tiles, analysis, + * reports), not whether the row store accepts writes. Disabling + * a module must not wedge an importer / sync / the user's ability + * to clean up data, and re-enabling must find the data intact. + * - 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 + * 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. + */ + +/** + * The per-domain route trees that serve a toggleable module. Each entry + * is a directory under `src/app/api`; every `route.ts` beneath it is + * enumerated and classified. A domain that is owned entirely by one + * builder (doctor-report / FHIR) or one delegated gate (cycle) is still + * walked so a NEW route in the tree must justify itself. + */ +const MODULE_ROUTE_TREES: ReadonlyArray = [ + "src/app/api/mood", + "src/app/api/mood-entries", + "src/app/api/sleep", + "src/app/api/workouts", + "src/app/api/labs", + "src/app/api/cycle", + "src/app/api/gamification", + "src/app/api/doctor-report", + "src/app/api/fhir", +]; + +/** + * EXEMPT — routes that serve a toggleable domain but are deliberately + * ungated. Each carries its reason inline per the bucket-4 contract. + */ +const EXEMPT_ROUTES: ReadonlyArray = [ + // ── DATA LAYER (mood) ───────────────────────────────────────────── + // Raw MoodEntry CRUD. The module gate governs whether mood SURFACES + // (nav, dashboard tile, analysis, doctor report), not whether the row + // store accepts writes. An importer / the moodLog sync / the user's + // ability to delete or restore entries must keep working while the + // module is off, and re-enabling must find the rows intact. + "src/app/api/mood-entries/route.ts", + "src/app/api/mood-entries/[id]/route.ts", + "src/app/api/mood-entries/bulk/route.ts", + "src/app/api/mood-entries/bulk-delete/route.ts", + "src/app/api/mood-entries/restore/route.ts", + // Mood tag taxonomy CRUD — the user's tag library + per-tag layout. + // Pure configuration over the mood vocabulary, not analysis prose; + // editing the taxonomy while the module is off must not break. + "src/app/api/mood/tags/route.ts", + "src/app/api/mood/tags/layout/route.ts", + "src/app/api/mood/tags/custom/route.ts", + "src/app/api/mood/tags/custom/[key]/route.ts", + "src/app/api/mood/tags/groups/route.ts", + "src/app/api/mood/tags/groups/[key]/route.ts", + "src/app/api/mood/tags/[key]/hidden/route.ts", + // ── DATA LAYER (workouts / labs) ────────────────────────────────── + // Raw Workout + LabResult CRUD + the iOS batch ingest. Same data-layer + // reasoning: the workouts / labs modules gate the surfaces (Insights + // pill, dashboard tile, doctor-report section), not the row store. + "src/app/api/workouts/route.ts", + "src/app/api/workouts/[id]/route.ts", + "src/app/api/workouts/batch/route.ts", + "src/app/api/labs/route.ts", + "src/app/api/labs/[id]/route.ts", + // ── INFRA / UI-ONLY ─────────────────────────────────────────────── + // Section-availability presence probe for the doctor-report dialog — + // returns per-section row-count booleans, no module data. The dialog + // itself is hidden when the doctorReport module is off. + "src/app/api/doctor-report/availability/route.ts", + // Static FHIR CapabilityStatement — server metadata, no user data. + "src/app/api/fhir/metadata/route.ts", +]; + +const MODULE_GATE_NEEDLE = "requireModuleEnabled("; +const CYCLE_GATE_NEEDLE = "requireCycleEnabled("; +const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")'; +// Builder aggregators that resolve `resolveModuleMap` once and exclude +// disabled-module sections/resources at the build boundary. +const BUILDER_GATE_NEEDLES: ReadonlyArray = [ + "collectDoctorReportData", + "loadFhirContext", +]; + +/** + * True when the file contains the needle on a line that is NOT a pure + * comment. A docstring that merely MENTIONS the gate must not satisfy the + * presence check — otherwise deleting the real call but leaving the + * comment would slip through. + */ +function fileHasCall(text: string, needle: string): boolean { + return text.split("\n").some((line) => { + if (!line.includes(needle)) return false; + const trimmed = line.trim(); + if (trimmed.startsWith("//")) return false; + if (trimmed.startsWith("*")) return false; + return true; + }); +} + +/** + * Extract the module key from a `requireModuleEnabled(user.id, "")` + * call. Returns the literal key, or null when the key is computed (the + * parameterised insights routes resolve it from a metric map — those gate + * dynamically and are accepted as gated without a literal-key assertion). + */ +function extractModuleKeys(text: string): { hasLiteral: boolean; keys: Set } { + const keys = new Set(); + let hasLiteral = false; + const re = /requireModuleEnabled\([^,]+,\s*"([a-zA-Z]+)"\s*\)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + hasLiteral = true; + keys.add(m[1]); + } + return { hasLiteral, keys }; +} + +const repoRoot = resolve(__dirname, "..", "..", "..", ".."); + +/** Walk one route tree, returning POSIX paths relative to the repo root. */ +function findRouteFiles(treeRel: string): string[] { + const root = resolve(repoRoot, treeRel); + const hits: string[] = []; + + function walk(dir: string): void { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const stat = statSync(full); + if (stat.isDirectory()) { + if (entry === "__tests__" || entry === "node_modules") continue; + walk(full); + continue; + } + if (entry !== "route.ts") continue; + hits.push(relative(repoRoot, full).split(/[\\/]/).join("/")); + } + } + + walk(root); + return hits; +} + +function findAllModuleRouteFiles(): string[] { + return MODULE_ROUTE_TREES.flatMap(findRouteFiles).sort(); +} + +describe("module API route gate inventory", () => { + it("the module registry covers the keys this inventory reasons about", () => { + // Guard against a registry key being added without the inventory + // gaining an opinion about its routes. Every gated/delegated key the + // test pins must be a real ModuleKey. + const known = new Set(MODULE_KEYS); + for (const key of [ + "mood", + "sleep", + "glucose", + "workouts", + "recovery", + "labs", + "achievements", + "cycle", + "coach", + "doctorReport", + "insights", + ] as const) { + expect(known.has(key), `unknown module key in inventory: ${key}`).toBe( + true, + ); + } + }); + + it("every toggleable-module route is gated, delegated, builder-gated, or explicitly exempt", () => { + const routes = findAllModuleRouteFiles(); + expect(routes.length).toBeGreaterThan(0); + + const exempt = new Set(EXEMPT_ROUTES); + const orphans: Array<{ path: string; reason: string }> = []; + + for (const path of routes) { + const text = readFileSync(resolve(repoRoot, path), "utf8"); + + if (fileHasCall(text, MODULE_GATE_NEEDLE)) continue; + if (fileHasCall(text, CYCLE_GATE_NEEDLE)) continue; + if (fileHasCall(text, COACH_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(""), a delegated gate, ' + + "a module-aware builder, or move the route onto EXEMPT_ROUTES with a documented reason", + }); + } + + expect( + orphans, + [ + "Module API route gate inventory found unaccounted-for handler(s):", + ...orphans.map((o) => ` - ${o.path}: ${o.reason}`), + ].join("\n"), + ).toEqual([]); + }); + + it("directly module-gated routes pin a real module key", () => { + const routes = findAllModuleRouteFiles(); + const known = new Set(MODULE_KEYS); + const bad: Array<{ path: string; key: string }> = []; + + for (const path of routes) { + const text = readFileSync(resolve(repoRoot, path), "utf8"); + if (!fileHasCall(text, MODULE_GATE_NEEDLE)) continue; + const { keys } = extractModuleKeys(text); + // Parameterised routes (computed key) carry no literal — accepted. + for (const key of keys) { + if (!known.has(key)) bad.push({ path, key }); + } + } + + expect( + bad, + [ + "requireModuleEnabled() called with an unknown module key:", + ...bad.map((b) => ` - ${b.path}: "${b.key}"`), + ].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)); + + expect( + stale, + [ + "EXEMPT_ROUTES points to files that no longer exist —", + "delete the stale entries from", + "`src/app/api/__tests__/module-route-gate-inventory.test.ts`:", + ...stale.map((p) => ` - ${p}`), + ].join("\n"), + ).toEqual([]); + }); + + it("EXEMPT_ROUTES does not hide a route that actually carries a gate", () => { + // A route that gained a real gate should be removed from the exempt + // list so the list stays an honest record of the ungated surfaces. + const stillGated: string[] = []; + for (const path of EXEMPT_ROUTES) { + const text = readFileSync(resolve(repoRoot, path), "utf8"); + if ( + fileHasCall(text, MODULE_GATE_NEEDLE) || + fileHasCall(text, CYCLE_GATE_NEEDLE) || + fileHasCall(text, COACH_GATE_NEEDLE) || + BUILDER_GATE_NEEDLES.some((n) => fileHasCall(text, n)) + ) { + stillGated.push(path); + } + } + + expect( + stillGated, + [ + "These EXEMPT_ROUTES now carry a gate — drop them from the exempt list:", + ...stillGated.map((p) => ` - ${p}`), + ].join("\n"), + ).toEqual([]); + }); +}); diff --git a/src/app/api/mood/analytics/__tests__/route.test.ts b/src/app/api/mood/analytics/__tests__/route.test.ts index 3884ddcdd..07dc06101 100644 --- a/src/app/api/mood/analytics/__tests__/route.test.ts +++ b/src/app/api/mood/analytics/__tests__/route.test.ts @@ -37,6 +37,12 @@ vi.mock("@/lib/auth/audit", () => ({ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +// v1.18.0 — pin the mood module gate to "enabled" so this suite stays +// focused on the mood-series engine; the gate is covered elsewhere. +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), +})); + vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); diff --git a/src/app/api/mood/analytics/route.ts b/src/app/api/mood/analytics/route.ts index 5774a1095..30b5bbc0a 100644 --- a/src/app/api/mood/analytics/route.ts +++ b/src/app/api/mood/analytics/route.ts @@ -6,12 +6,19 @@ import { buildMoodDailySeries, type MoodDailySeries, } from "@/lib/analytics/mood-series"; +import { requireModuleEnabled } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { const { user } = await requireAuth(); + // Per-domain gate: the mood daily series feeds the mood module's + // analysis surfaces (sparkline + mood page), so it gates on the mood + // module (mirrors `/api/insights/mood-status`). Disabled ⇒ 403. + const gate = await requireModuleEnabled(user.id, "mood"); + if (!gate.enabled) return gate.response; + // v1.17.1 — single mood engine: the route and the dashboard snapshot // both read through `buildMoodDailySeries`, so the dashboard tile, the // insights mood sparkline, and the iOS client all see the same number. diff --git a/src/app/api/mood/insights/route.ts b/src/app/api/mood/insights/route.ts index c6a1b2770..95d37c5c9 100644 --- a/src/app/api/mood/insights/route.ts +++ b/src/app/api/mood/insights/route.ts @@ -6,6 +6,7 @@ import { fetchMoodAggregates, type MoodAggregates, } from "@/lib/insights/mood-aggregates"; +import { requireModuleEnabled } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; @@ -28,6 +29,12 @@ export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { const { user } = await requireAuth(); + // Per-domain gate: the mood-insights aggregate surface only serves the + // mood module's analysis page, so it must be gated on the mood module + // (mirrors `/api/insights/mood-status`). Disabled ⇒ 403 module.disabled. + const gate = await requireModuleEnabled(user.id, "mood"); + if (!gate.enabled) return gate.response; + const result = await cachedSwr( caches.moodInsights as ServerCache, user.id, diff --git a/src/app/api/sleep/night/__tests__/route.test.ts b/src/app/api/sleep/night/__tests__/route.test.ts index aef6583ef..519e795d5 100644 --- a/src/app/api/sleep/night/__tests__/route.test.ts +++ b/src/app/api/sleep/night/__tests__/route.test.ts @@ -21,6 +21,13 @@ vi.mock("@/lib/rollups/measurement-read", () => ({ loadUserSourcePriority: vi.fn(async () => null), })); +// v1.18.0 — pin the sleep module gate to "enabled" so this suite stays +// focused on the hypnogram read; the gate itself is covered by the module +// gate + route-gate-inventory suites. +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), +})); + vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); diff --git a/src/app/api/sleep/night/route.ts b/src/app/api/sleep/night/route.ts index 29d8874f0..cded37eef 100644 --- a/src/app/api/sleep/night/route.ts +++ b/src/app/api/sleep/night/route.ts @@ -38,6 +38,7 @@ import { } from "@/lib/analytics/sleep-night"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { requireModuleEnabled } from "@/lib/modules/gate"; /** Sleep is read row-per-stage, so the window is bounded to one year. */ const SLEEP_NIGHT_MAX_DAYS = 365; @@ -93,6 +94,11 @@ function serializeSession(s: SleepSession) { export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + // Per-domain gate: the hypnogram read serves only the sleep module's + // view surfaces, so it gates on the sleep module. Disabled ⇒ 403. + const gate = await requireModuleEnabled(user.id, "sleep"); + if (!gate.enabled) return gate.response; + const parsed = querySchema.safeParse( Object.fromEntries(request.nextUrl.searchParams), ); diff --git a/src/app/api/sleep/rhythm/__tests__/route.test.ts b/src/app/api/sleep/rhythm/__tests__/route.test.ts index 85d94cdc5..a817425b4 100644 --- a/src/app/api/sleep/rhythm/__tests__/route.test.ts +++ b/src/app/api/sleep/rhythm/__tests__/route.test.ts @@ -22,6 +22,12 @@ vi.mock("@/lib/rollups/measurement-read", () => ({ loadUserSourcePriority: vi.fn(async () => null), })); +// v1.18.0 — pin the sleep module gate to "enabled" so this suite stays +// focused on the sleep-rhythm read; the gate is covered elsewhere. +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), +})); + vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); diff --git a/src/app/api/sleep/rhythm/route.ts b/src/app/api/sleep/rhythm/route.ts index 8eb6c1502..50623ac82 100644 --- a/src/app/api/sleep/rhythm/route.ts +++ b/src/app/api/sleep/rhythm/route.ts @@ -22,10 +22,17 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { buildSleepRhythm } from "@/lib/insights/derived/sleep-rhythm"; +import { requireModuleEnabled } from "@/lib/modules/gate"; export const GET = apiHandler(async () => { const { user } = await requireAuth(); + // Per-domain gate: the sleep-rhythm read serves only the sleep module's + // view surfaces (Sleep page + iOS), so it gates on the sleep module. + // Disabled ⇒ 403 module.disabled. + const gate = await requireModuleEnabled(user.id, "sleep"); + if (!gate.enabled) return gate.response; + const rhythm = await buildSleepRhythm(user.id); annotate({