From d1089ba232858c79049cf05d9f92e31fc3a1c261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 20:53:54 +0200 Subject: [PATCH] feat(modules): add the canonical module registry and enforcement gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit registry.ts is the single source of truth for the toggleable secondary domains (cycle, mood, sleep, glucose, workouts, recovery, labs, achievements, coach, insights, doctorReport) plus the always-on core domain denylist. Each entry carries a stable key, label/description i18n keys, a category, and an optional delegatesTo marker. gate.ts is the one enforcement point: isModuleEnabled / requireModuleEnabled (403 module.disabled envelope) plus resolveModuleMap for the /api/auth/me projection. No double source of truth — cycle delegates to isCycleEnabled and coach delegates to disableCoach AND the operator assistant master flag; every other module resolves from the disabled allowlist. Default-on, fail-open per key, per-request memoised reads. insights gates only the AI-analysis surfaces (briefing, status cards, correlation narration, score explainer) — never the raw measurement charts, which are core and render regardless. --- src/lib/modules/__tests__/gate.test.ts | 189 ++++++++++++++++++++++ src/lib/modules/gate.ts | 201 +++++++++++++++++++++++ src/lib/modules/registry.ts | 215 +++++++++++++++++++++++++ 3 files changed, 605 insertions(+) create mode 100644 src/lib/modules/__tests__/gate.test.ts create mode 100644 src/lib/modules/gate.ts create mode 100644 src/lib/modules/registry.ts diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts new file mode 100644 index 000000000..14ee98578 --- /dev/null +++ b/src/lib/modules/__tests__/gate.test.ts @@ -0,0 +1,189 @@ +/** + * v1.18.0 — module enable/disable gate resolver. + * + * Exercises the PURE resolver (`resolveModuleEnabled`) + the registry + * helpers, so the disabled-allowlist + delegation logic is asserted + * without a DB. The async `isModuleEnabled` / `requireModuleEnabled` + * thin wrappers are covered by the route test. + */ +import { describe, it, expect } from "vitest"; + +import { + resolveModuleEnabled, + normalisePrefs, + MODULE_DISABLED_ERROR_CODE, + type ModuleGateInputs, +} from "../gate"; +import { + isCoreDomain, + isModuleKey, + moduleDelegatesTo, + MODULE_KEYS, + CORE_DOMAIN_KEYS, +} from "../registry"; + +function inputs(over: Partial = {}): ModuleGateInputs { + return { + gender: null, + disableCoach: false, + modulePreferences: {}, + cycleTrackingEnabled: null, + ...over, + }; +} + +describe("resolveModuleEnabled — disabled allowlist (default-on)", () => { + it("enables a module when no preference is recorded", () => { + expect(resolveModuleEnabled("mood", inputs(), true)).toBe(true); + expect(resolveModuleEnabled("sleep", inputs(), true)).toBe(true); + expect(resolveModuleEnabled("labs", inputs(), true)).toBe(true); + }); + + it("enables a module when the key is present and true", () => { + expect( + resolveModuleEnabled("mood", inputs({ modulePreferences: { mood: true } }), true), + ).toBe(true); + }); + + it("disables a module ONLY on an explicit false", () => { + expect( + resolveModuleEnabled( + "glucose", + inputs({ modulePreferences: { glucose: false } }), + true, + ), + ).toBe(false); + }); + + it("isolates per-module: disabling one leaves siblings on", () => { + const i = inputs({ modulePreferences: { workouts: false } }); + expect(resolveModuleEnabled("workouts", i, true)).toBe(false); + expect(resolveModuleEnabled("recovery", i, true)).toBe(true); + expect(resolveModuleEnabled("labs", i, true)).toBe(true); + }); +}); + +describe("resolveModuleEnabled — cycle delegation", () => { + it("ignores the module blob and reads the cycle gate (gender-derived)", () => { + // Even a crafted `{ cycle: false }` blob cannot override the real + // FEMALE-derived enablement — the gate delegates to isCycleEnabled. + const i = inputs({ + gender: "FEMALE", + cycleTrackingEnabled: null, + modulePreferences: { cycle: false }, + }); + expect(resolveModuleEnabled("cycle", i, true)).toBe(true); + }); + + it("respects the cycle toggle when set", () => { + expect( + resolveModuleEnabled( + "cycle", + inputs({ gender: "MALE", cycleTrackingEnabled: true }), + true, + ), + ).toBe(true); + expect( + resolveModuleEnabled( + "cycle", + inputs({ gender: "FEMALE", cycleTrackingEnabled: false }), + true, + ), + ).toBe(false); + }); + + it("a MALE account with a null toggle is cycle-disabled", () => { + expect( + resolveModuleEnabled("cycle", inputs({ gender: "MALE" }), true), + ).toBe(false); + }); +}); + +describe("resolveModuleEnabled — coach delegation (two-layer)", () => { + it("is enabled only when the operator master flag AND !disableCoach", () => { + expect( + resolveModuleEnabled("coach", inputs({ disableCoach: false }), true), + ).toBe(true); + }); + + it("is disabled when the per-user opt-out is set", () => { + expect( + resolveModuleEnabled("coach", inputs({ disableCoach: true }), true), + ).toBe(false); + }); + + it("is disabled when the operator master flag is off", () => { + expect( + resolveModuleEnabled("coach", inputs({ disableCoach: false }), false), + ).toBe(false); + }); + + it("ignores the module blob for coach", () => { + // A crafted `{ coach: true }` cannot re-enable against a disabled + // operator flag or a user opt-out. + const i = inputs({ disableCoach: true, modulePreferences: { coach: true } }); + expect(resolveModuleEnabled("coach", i, true)).toBe(false); + }); +}); + +describe("insights — narrative layer, not a delegated module", () => { + it("resolves from the disabled allowlist like the other secondary domains", () => { + expect(resolveModuleEnabled("insights", inputs(), true)).toBe(true); + expect( + resolveModuleEnabled( + "insights", + inputs({ modulePreferences: { insights: false } }), + true, + ), + ).toBe(false); + }); + + it("is not delegated (no double source of truth marker)", () => { + expect(moduleDelegatesTo("insights")).toBeUndefined(); + }); +}); + +describe("normalisePrefs — fail-open coercion", () => { + it("treats null / non-object / array as empty (all-on)", () => { + expect(normalisePrefs(null)).toEqual({}); + expect(normalisePrefs(undefined)).toEqual({}); + expect(normalisePrefs("nope")).toEqual({}); + expect(normalisePrefs(["mood"])).toEqual({}); + }); + + it("keeps only boolean values, dropping junk entries", () => { + expect( + normalisePrefs({ mood: false, sleep: "yes", glucose: 0, labs: true }), + ).toEqual({ mood: false, labs: true }); + }); +}); + +describe("registry — core domains can never be disabled", () => { + it("the four core domains are not toggleable module keys", () => { + for (const core of CORE_DOMAIN_KEYS) { + expect(isModuleKey(core)).toBe(false); + expect(isCoreDomain(core)).toBe(true); + } + }); + + it("a crafted core-domain key is inert (never read as a module)", () => { + // `weight: false` in the blob can't matter: weight is not a ModuleKey, + // so the gate never consults it. The resolver only ever runs for the + // 11 declared toggleable keys. + expect(isModuleKey("weight")).toBe(false); + expect(isModuleKey("medications")).toBe(false); + }); + + it("every toggleable key is a known module and not a core domain", () => { + for (const key of MODULE_KEYS) { + expect(isModuleKey(key)).toBe(true); + expect(isCoreDomain(key)).toBe(false); + } + }); +}); + +describe("wire contract", () => { + it("exports the iOS-classifier errorCode", () => { + expect(MODULE_DISABLED_ERROR_CODE).toBe("module.disabled"); + }); +}); diff --git a/src/lib/modules/gate.ts b/src/lib/modules/gate.ts new file mode 100644 index 000000000..58658e73b --- /dev/null +++ b/src/lib/modules/gate.ts @@ -0,0 +1,201 @@ +/** + * v1.18.0 — module enable/disable gate. + * + * The ONE enforcement point for the module foundation. Every leak-point + * (nav, dashboard tiles, insights sections, coach snapshot, status + * routes, reminder jobs, achievements, doctor-report / FHIR, search, + * quick-add) resolves its decision through `isModuleEnabled(userId, key)` + * and `requireModuleEnabled(userId, key)`. Mirrors the cycle gate + * (`src/lib/cycle/gate.ts`) and the assistant feature flags + * (`src/lib/feature-flags/index.ts`). + * + * Source-of-truth discipline (NO double source of truth): + * + * - `cycle` delegates to `isCycleEnabled(gender, CycleProfile)` — the + * existing cycle gate. `modulePreferencesJson.cycle` is + * ignored on purpose. + * - `coach` delegates to `User.disableCoach` AND the operator-level + * assistant master flag (`getAssistantFlags().coach`). Both + * must agree, matching the two-layer model the client gates + * already enforce. + * - every other module resolves purely from `modulePreferencesJson` + * as a DISABLED allowlist (absent / empty / `true` ⇒ on; an + * explicit `false` ⇒ off). Default-on. + * + * Fail-closed posture: the resolver only ever DISABLES a module when it + * sees an explicit, well-typed `false` (or, for delegated modules, the + * delegated source returning disabled). A malformed blob, a missing + * column, an unknown key — none of these can silently disable a module + * (default-on), and none can enable a delegated one against its real + * source. A crafted `{ "weight": false }` blob is inert because `weight` + * is a core domain, not a `ModuleKey`. + * + * Reads are memoised per-request (the assistant-flags pattern) so a + * single request that gates several modules hits the row once. + */ +import { apiError } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { isCycleEnabled } from "@/lib/cycle/gate"; +import { getAssistantFlags } from "@/lib/feature-flags"; +import { memoizePerRequest } from "@/lib/request-cache"; +import { + MODULE_KEYS, + moduleDelegatesTo, + type ModuleKey, +} from "./registry"; + +export { MODULE_KEYS } from "./registry"; +export type { ModuleKey } from "./registry"; + +/** Wire `errorCode` the iOS retry classifier branches on. */ +export const MODULE_DISABLED_ERROR_CODE = "module.disabled"; + +/** + * The per-user fields the gate needs. Bundled so the resolver does a + * single round-trip and the memo caches one row per request. + */ +export interface ModuleGateInputs { + gender: string | null; + disableCoach: boolean; + modulePreferences: Record; + /** `CycleProfile.cycleTrackingEnabled`; NULL ⇒ derive from gender. */ + cycleTrackingEnabled: boolean | null; +} + +/** + * Coerce the persisted blob into a plain `Record`, + * keeping only boolean values. A null column, a non-object, or junk + * entries all collapse to an empty / partial map — which the + * disabled-allowlist semantics read as "all on". Fail-open per key: + * only a literal `false` disables. + */ +export function normalisePrefs(raw: unknown): Record { + if (raw == null || typeof raw !== "object" || Array.isArray(raw)) { + return {}; + } + const out: Record = {}; + for (const [k, v] of Object.entries(raw as Record)) { + if (typeof v === "boolean") { + out[k] = v; + } + } + return out; +} + +/** + * Pure resolver — exposed for unit tests so the disabled-allowlist + + * delegation logic can be asserted without a DB. `assistantCoach` is the + * resolved operator-level `getAssistantFlags().coach`; pass it in so this + * stays synchronous and side-effect-free. + */ +export function resolveModuleEnabled( + key: ModuleKey, + inputs: ModuleGateInputs, + assistantCoach: boolean, +): boolean { + const delegate = moduleDelegatesTo(key); + + if (delegate === "cycle") { + // Single source of truth: the cycle gate. The module blob is ignored. + return isCycleEnabled(inputs.gender, { + cycleTrackingEnabled: inputs.cycleTrackingEnabled, + }); + } + + if (delegate === "coach") { + // Two-layer model: operator master flag AND per-user opt-out. + return assistantCoach && !inputs.disableCoach; + } + + // Disabled allowlist: only an explicit `false` turns the module off. + return inputs.modulePreferences[key] !== false; +} + +/** Internal: load the gate inputs once per request. */ +function loadInputs(userId: string): Promise { + return memoizePerRequest(`module-gate-inputs:${userId}`, async () => { + const [user, cycleProfile] = await Promise.all([ + prisma.user.findUnique({ + where: { id: userId }, + select: { + gender: true, + disableCoach: true, + modulePreferencesJson: true, + }, + }), + prisma.cycleProfile.findUnique({ + where: { userId }, + select: { cycleTrackingEnabled: true }, + }), + ]); + return { + gender: user?.gender ?? null, + disableCoach: user?.disableCoach ?? false, + modulePreferences: normalisePrefs(user?.modulePreferencesJson), + cycleTrackingEnabled: cycleProfile?.cycleTrackingEnabled ?? null, + }; + }); +} + +/** + * Resolve whether a module is enabled for an account. Default-on: + * an unknown / absent / non-`false` state reads as enabled. Delegated + * modules (`cycle`, `coach`) resolve through their real source. + */ +export async function isModuleEnabled( + userId: string, + moduleKey: ModuleKey, +): Promise { + const inputs = await loadInputs(userId); + const delegate = moduleDelegatesTo(moduleKey); + // Only pay the assistant-flags read when resolving the coach delegate. + const assistantCoach = + delegate === "coach" ? (await getAssistantFlags()).coach : false; + return resolveModuleEnabled(moduleKey, inputs, assistantCoach); +} + +/** + * Resolve the full `{ : boolean }` map for every toggleable module, + * suitable for the `GET /api/auth/me` projection. cycle/coach reflect + * their real delegated state. + */ +export async function resolveModuleMap( + userId: string, +): Promise> { + const inputs = await loadInputs(userId); + const assistantCoach = (await getAssistantFlags()).coach; + const out = {} as Record; + for (const key of MODULE_KEYS) { + out[key] = resolveModuleEnabled(key, inputs, assistantCoach); + } + return out; +} + +/** + * Route-guard outcome: either a clear pass or a ready 403 envelope to + * return verbatim — mirrors `CycleGateResult`. + */ +export type ModuleGateResult = + | { enabled: true } + | { enabled: false; response: Response }; + +/** + * Enforce the gate for a module-scoped route. Returns a 403 + * `module.disabled` envelope (carrying the module key in `meta`) when the + * account has the module turned off — even with a valid Bearer token. + */ +export async function requireModuleEnabled( + userId: string, + moduleKey: ModuleKey, +): Promise { + if (await isModuleEnabled(userId, moduleKey)) { + return { enabled: true }; + } + return { + enabled: false, + response: apiError(`Module "${moduleKey}" is not enabled`, 403, { + errorCode: MODULE_DISABLED_ERROR_CODE, + module: moduleKey, + }), + }; +} diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts new file mode 100644 index 000000000..45f2781c1 --- /dev/null +++ b/src/lib/modules/registry.ts @@ -0,0 +1,215 @@ +/** + * v1.18.0 — canonical module registry. + * + * The single source of truth for "what is a module" in HealthLog's + * enable/disable foundation. Every leak-point (nav, dashboard tiles, + * insights sections, coach snapshot, status routes, reminder jobs, + * achievements, doctor-report / FHIR, search, quick-add) reads its gate + * decision through `src/lib/modules/gate.ts`, which keys off the entries + * declared here. + * + * Two classes of domain: + * + * - CORE (always-on): weight, blood pressure, pulse, medications — the + * measurement engine + meds. These are NOT in `MODULE_KEYS` and have + * NO registry entry. They can never be disabled; the gate has no key + * to flip and the write endpoint refuses them. This is structural, + * not a runtime check future code can soften. + * + * - TOGGLEABLE (the "secondary domains"): the maintainer-chosen scope + * below. Each carries a stable key, an i18n label + description key, + * a category for the Settings hub grouping, and an optional + * `delegatesTo` marker for the two modules whose enabled-state is + * owned elsewhere (no double source of truth). + * + * The persisted shape (`User.modulePreferencesJson`) is a DISABLED + * allowlist: absent / empty / `true` ⇒ enabled, an explicit `false` ⇒ + * disabled. Default-on, no backfill. + */ + +/** + * Where a module's enabled-state is resolved when it is NOT owned by + * `modulePreferencesJson` directly. Keeps a single source of truth: + * + * - "cycle" → `isCycleEnabled(gender, CycleProfile)` (the existing + * cycle gate; `modulePreferencesJson.cycle` is ignored). + * - "coach" → `User.disableCoach` AND the operator-level assistant + * master flag (`getAssistantFlags().coach`); again the + * module blob is ignored for this key. + * + * Every other toggleable module has no `delegatesTo` and resolves purely + * from `modulePreferencesJson`. + */ +export type ModuleDelegation = "cycle" | "coach"; + +/** Coarse grouping for the Settings "What you track" hub. */ +export type ModuleCategory = + | "tracking" // user-logged domains (mood, cycle, glucose, labs) + | "device" // device / passive-sync domains (sleep, workouts, recovery) + | "engagement" // gamification (achievements) + | "intelligence" // AI-driven surfaces (coach, insights) + | "export"; // outbound reporting (doctor report / FHIR) + +export interface ModuleDefinition { + /** Stable key — the literal used in `modulePreferencesJson` + the API. */ + key: ModuleKey; + /** i18n key for the human-readable module name (messages/*.json). */ + labelKey: string; + /** i18n key for the one-line "what turning this off does" explainer. */ + descriptionKey: string; + /** Settings-hub grouping. */ + category: ModuleCategory; + /** + * When set, the module's enabled-state is NOT resolved from + * `modulePreferencesJson` — the gate delegates to the named source so + * there is exactly one source of truth. See `ModuleDelegation`. + */ + delegatesTo?: ModuleDelegation; +} + +/** + * The toggleable modules — the maintainer's "secondary domains" scope. + * + * Declared as a const tuple so `ModuleKey` is the exact union and the + * registry stays exhaustively typed. + */ +export const MODULE_KEYS = [ + "cycle", + "mood", + "sleep", + "glucose", + "workouts", + "recovery", + "labs", + "achievements", + "coach", + "insights", + "doctorReport", +] as const; + +export type ModuleKey = (typeof MODULE_KEYS)[number]; + +/** + * CORE domains — the always-on measurement engine + medications. Listed + * here for documentation + the write-endpoint denylist; they have NO + * `ModuleDefinition` and can never appear as a toggle. A crafted + * `{ "weight": false }` blob is inert: `weight` is not a `ModuleKey`, so + * the gate never reads it and the PATCH validator rejects it. + */ +export const CORE_DOMAIN_KEYS = [ + "weight", + "bloodPressure", + "pulse", + "medications", +] as const; + +export type CoreDomainKey = (typeof CORE_DOMAIN_KEYS)[number]; + +const CORE_DOMAIN_SET: ReadonlySet = new Set(CORE_DOMAIN_KEYS); + +/** True for the four always-on core domains (never disableable). */ +export function isCoreDomain(key: string): key is CoreDomainKey { + return CORE_DOMAIN_SET.has(key); +} + +/** + * The registry. Order is the Settings-hub display order (grouped by + * category in the UI; this flat order is the canonical iteration order). + * + * `insights` decision (documented): disabling `insights` hides only the + * AI-ANALYSIS surfaces — the Daily Briefing, the per-metric AI status + * cards, the correlation narration, the Health-Score explainer. It does + * NOT hide the raw weight / BP / pulse charts or any measurement data: + * those are core and render from live measurements regardless. `insights` + * is the narrative layer, not the data layer. (The operator-level + * assistant master flag is a separate, server-wide kill-switch; this + * per-user toggle sits below it.) + * + * `coach` is the per-user opt-out half of the existing two-layer model + * (`User.disableCoach` AND the operator master flag). It is surfaced here + * so the Modules hub lists it alongside its siblings, but it delegates — + * the module blob never owns it. + */ +export const MODULE_REGISTRY: Readonly> = + Object.freeze({ + cycle: { + key: "cycle", + labelKey: "modules.cycle.label", + descriptionKey: "modules.cycle.description", + category: "tracking", + delegatesTo: "cycle", + }, + mood: { + key: "mood", + labelKey: "modules.mood.label", + descriptionKey: "modules.mood.description", + category: "tracking", + }, + glucose: { + key: "glucose", + labelKey: "modules.glucose.label", + descriptionKey: "modules.glucose.description", + category: "tracking", + }, + labs: { + key: "labs", + labelKey: "modules.labs.label", + descriptionKey: "modules.labs.description", + category: "tracking", + }, + sleep: { + key: "sleep", + labelKey: "modules.sleep.label", + descriptionKey: "modules.sleep.description", + category: "device", + }, + workouts: { + key: "workouts", + labelKey: "modules.workouts.label", + descriptionKey: "modules.workouts.description", + category: "device", + }, + recovery: { + key: "recovery", + labelKey: "modules.recovery.label", + descriptionKey: "modules.recovery.description", + category: "device", + }, + achievements: { + key: "achievements", + labelKey: "modules.achievements.label", + descriptionKey: "modules.achievements.description", + category: "engagement", + }, + coach: { + key: "coach", + labelKey: "modules.coach.label", + descriptionKey: "modules.coach.description", + category: "intelligence", + delegatesTo: "coach", + }, + insights: { + key: "insights", + labelKey: "modules.insights.label", + descriptionKey: "modules.insights.description", + category: "intelligence", + }, + doctorReport: { + key: "doctorReport", + labelKey: "modules.doctorReport.label", + descriptionKey: "modules.doctorReport.description", + category: "export", + }, + }); + +const MODULE_KEY_SET: ReadonlySet = new Set(MODULE_KEYS); + +/** True when `key` is a known toggleable module. */ +export function isModuleKey(key: string): key is ModuleKey { + return MODULE_KEY_SET.has(key); +} + +/** The two delegated keys, resolved by their existing source of truth. */ +export function moduleDelegatesTo(key: ModuleKey): ModuleDelegation | undefined { + return MODULE_REGISTRY[key].delegatesTo; +}