mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
The inbound-documents surface is withdrawn from this release and returns later with more depth. Park its module key (`inboundDocuments`) in a new `CODE_DISABLED_MODULE_KEYS` list so it is hard-off for every account ahead of both the operator-availability and the per-user opt-in layer. The gate short-circuits a code-disabled key to disabled, so the resolved module map reports it off everywhere: the nav entry stays born-gated and hidden, the `/documents` page redirects, and every `/api/documents/inbound/*` route returns the 403 module-disabled envelope. The Modules hub and the operator-availability panel drop the row, and the per-user PATCH surface refuses to write the key (a clean 422 instead of an inert preference). The whole implementation stays intact — routes, the inbound-documents view, the registry entry with its opt-in marker, the validation schemas, and the migration are all preserved. Re-enabling the module is a one-line removal from the list once the rebuild lands. Regenerate the OpenAPI contract: the module-preferences PATCH body no longer advertises the parked key.
376 lines
15 KiB
TypeScript
376 lines
15 KiB
TypeScript
/**
|
|
* 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 — the measurement
|
|
* engine. 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. (Medications was CORE through v1.18.0; D3
|
|
* graduated it to a fail-open toggleable module below.)
|
|
*
|
|
* - 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)
|
|
| "integration"; // external connectivity (the remote MCP endpoint)
|
|
|
|
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;
|
|
/**
|
|
* Inverts the per-user default for this key. Ordinary toggleable modules
|
|
* are a DISABLED allowlist (default-on; only an explicit `false` turns
|
|
* them off). An `optIn` module is the opposite: OFF until the user
|
|
* records an explicit `true`. Used for surfaces that expose a new
|
|
* external attack surface and must ship dark — the remote MCP endpoint
|
|
* (ADR-007 / REQ-OPS-1). The operator-availability layer still applies
|
|
* (an operator can disable it server-wide); this flag only flips the
|
|
* per-user default.
|
|
*/
|
|
optIn?: boolean;
|
|
/**
|
|
* For delegated modules only: where the real on/off control lives, so the
|
|
* Modules hub can render a read-only "managed in X" row that deep-links to
|
|
* the canonical control instead of a dead toggle. `href` is the in-app
|
|
* link; `labelKey` names the destination ("Account", "Coach settings").
|
|
*/
|
|
managedAt?: { href: string; labelKey: string };
|
|
}
|
|
|
|
/**
|
|
* 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",
|
|
"illness",
|
|
"achievements",
|
|
"coach",
|
|
"insights",
|
|
// v1.18.1 (D3) — medications graduated from the always-on CORE set to a
|
|
// toggleable module. Weight / blood pressure / pulse stay CORE (the
|
|
// measurement engine is never disableable); medications is now an opt-out
|
|
// domain so an account that does not track meds can hide the surface.
|
|
// SURFACE-gated (nav entry, dashboard medication widget, the dedicated
|
|
// Medikamente settings entry) — the medication data-layer routes stay
|
|
// exempt like mood/labs so an importer / sync / cleanup keeps working and
|
|
// re-enabling finds the rows intact.
|
|
"medications",
|
|
"doctorReport",
|
|
// v1.25.0 (W-ENV) — environmental-context module. Like `mcp`, it is OPT-IN
|
|
// (off by default): it performs an outbound weather fetch tied to where the
|
|
// user physically is (a coarse home / travel location), so opt-in is the
|
|
// right privacy default. With it off no job runs, no row is written, and no
|
|
// egress happens. The `optIn` marker inverts the per-user default.
|
|
"environment",
|
|
// v1.22.0 — the remote Model Context Protocol endpoint (`/mcp`). Unlike
|
|
// every other module this is OPT-IN (off by default): it exposes a new
|
|
// external-assistant attack surface, so it ships dark and the operator /
|
|
// user turns it on deliberately (ADR-007 / REQ-OPS-1). The `optIn` marker
|
|
// on its registry entry inverts the per-user default; everything else
|
|
// (operator-availability layer, PATCH plumbing, the Modules hub toggle)
|
|
// reuses the existing module machinery unchanged.
|
|
"mcp",
|
|
// v1.25.0 (W-DOCS-IN) — inbound clinical documents (`/documents/inbound`).
|
|
// Like `mcp` this is OPT-IN (off by default): ingesting a doctor report /
|
|
// discharge letter sends the document to the configured OCR/vision provider,
|
|
// so the surface ships dark and the user turns it on deliberately. The
|
|
// `optIn` marker on its registry entry inverts the per-user default;
|
|
// everything else reuses the existing module machinery unchanged.
|
|
"inboundDocuments",
|
|
// v1.25.0 — opt-in mental-health screeners (PHQ-9 / GAD-7), beside mood
|
|
// tracking. OPT-IN (off by default): a depression / anxiety self-assessment
|
|
// is at least as sensitive as mood, so it ships dark and the user turns it on
|
|
// deliberately. The `optIn` marker inverts the per-user default; with it off
|
|
// the `/mental-wellbeing` surface is hidden from nav and the
|
|
// assessment routes refuse server-side. Item answers are always encrypted and
|
|
// always excluded from the AI Coach / MCP regardless of this toggle.
|
|
"mentalHealth",
|
|
] as const;
|
|
|
|
export type ModuleKey = (typeof MODULE_KEYS)[number];
|
|
|
|
/**
|
|
* CORE domains — the always-on measurement engine (weight / blood pressure
|
|
* / pulse). 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. (Medications is a toggleable module since D3 — see `MODULE_KEYS`.)
|
|
*/
|
|
export const CORE_DOMAIN_KEYS = ["weight", "bloodPressure", "pulse"] as const;
|
|
|
|
export type CoreDomainKey = (typeof CORE_DOMAIN_KEYS)[number];
|
|
|
|
const CORE_DOMAIN_SET: ReadonlySet<string> = 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<Record<ModuleKey, ModuleDefinition>> =
|
|
Object.freeze({
|
|
cycle: {
|
|
key: "cycle",
|
|
labelKey: "modules.cycle.label",
|
|
descriptionKey: "modules.cycle.description",
|
|
category: "tracking",
|
|
delegatesTo: "cycle",
|
|
// The real on/off lives in the Account section's cycle-tracking card.
|
|
managedAt: {
|
|
href: "/settings/account#cycle-tracking",
|
|
labelKey: "settings.sections.account.title",
|
|
},
|
|
},
|
|
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",
|
|
},
|
|
illness: {
|
|
key: "illness",
|
|
labelKey: "modules.illness.label",
|
|
descriptionKey: "modules.illness.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",
|
|
// The real on/off lives in the dedicated Coach settings section.
|
|
managedAt: {
|
|
href: "/settings/coach",
|
|
labelKey: "settings.sections.coach.title",
|
|
},
|
|
},
|
|
insights: {
|
|
key: "insights",
|
|
labelKey: "modules.insights.label",
|
|
descriptionKey: "modules.insights.description",
|
|
category: "intelligence",
|
|
},
|
|
medications: {
|
|
key: "medications",
|
|
labelKey: "modules.medications.label",
|
|
descriptionKey: "modules.medications.description",
|
|
category: "tracking",
|
|
},
|
|
doctorReport: {
|
|
key: "doctorReport",
|
|
labelKey: "modules.doctorReport.label",
|
|
descriptionKey: "modules.doctorReport.description",
|
|
category: "export",
|
|
},
|
|
environment: {
|
|
key: "environment",
|
|
labelKey: "modules.environment.label",
|
|
descriptionKey: "modules.environment.description",
|
|
category: "device",
|
|
// Off by default: performs an outbound weather fetch tied to a coarse
|
|
// location, so it ships dark and the user turns it on deliberately. The
|
|
// home location + travel overrides + backfill live on the dedicated
|
|
// Environment settings surface (rendered when the module is on).
|
|
optIn: true,
|
|
},
|
|
mcp: {
|
|
key: "mcp",
|
|
labelKey: "modules.mcp.label",
|
|
descriptionKey: "modules.mcp.description",
|
|
category: "integration",
|
|
// Off by default: exposes a remote external-assistant surface, so it
|
|
// must be turned on deliberately (ADR-007 / REQ-OPS-1).
|
|
optIn: true,
|
|
},
|
|
inboundDocuments: {
|
|
key: "inboundDocuments",
|
|
labelKey: "modules.inboundDocuments.label",
|
|
descriptionKey: "modules.inboundDocuments.description",
|
|
category: "tracking",
|
|
// Off by default: ingesting a clinical document egresses it to the
|
|
// configured OCR/vision provider, so the user turns it on deliberately.
|
|
optIn: true,
|
|
},
|
|
mentalHealth: {
|
|
key: "mentalHealth",
|
|
labelKey: "modules.mentalHealth.label",
|
|
descriptionKey: "modules.mentalHealth.description",
|
|
category: "tracking",
|
|
// Off by default: a depression / anxiety screener is highly sensitive, so
|
|
// the surface ships dark and the user opts in deliberately. Turning it on
|
|
// reveals the `/mental-wellbeing` check-in and lets the screener
|
|
// total ride the doctor-report export; item answers stay encrypted and
|
|
// off the AI Coach / MCP regardless.
|
|
optIn: true,
|
|
},
|
|
});
|
|
|
|
const MODULE_KEY_SET: ReadonlySet<string> = 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);
|
|
}
|
|
|
|
/**
|
|
* Modules switched OFF in code, pending a holistic rebuild.
|
|
*
|
|
* A key listed here is hard-off for every account regardless of the
|
|
* per-user opt-in or the operator-availability layer: the gate short-
|
|
* circuits it to disabled (`resolveModuleEnabled`), the Modules hub and the
|
|
* operator-availability panel drop its row, and the per-user PATCH surface
|
|
* refuses to write it. The whole implementation (routes, components, schema,
|
|
* migration) stays intact — re-enabling the module is a one-line removal
|
|
* from this list once the rebuild lands.
|
|
*
|
|
* v1.25.3 — `inboundDocuments` is parked here: the inbound-documents surface
|
|
* is withdrawn from this release and returns later with more depth. Its
|
|
* registry entry, `optIn` marker, routes, view, and migration 0222 are all
|
|
* preserved; only the user-reachable switches are removed.
|
|
*/
|
|
export const CODE_DISABLED_MODULE_KEYS = ["inboundDocuments"] as const;
|
|
|
|
const CODE_DISABLED_MODULE_SET: ReadonlySet<string> = new Set(
|
|
CODE_DISABLED_MODULE_KEYS,
|
|
);
|
|
|
|
/**
|
|
* True for a module intentionally switched off in code pending a rebuild
|
|
* (see `CODE_DISABLED_MODULE_KEYS`). Such a module is hard-off everywhere:
|
|
* the gate never reports it enabled and the user-facing toggles drop it.
|
|
*/
|
|
export function isCodeDisabledModule(key: ModuleKey): boolean {
|
|
return CODE_DISABLED_MODULE_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;
|
|
}
|
|
|
|
/**
|
|
* True for a module whose per-user default is OFF (opt-in). The gate reads
|
|
* such a key as enabled only on an explicit `true`, the inverse of the
|
|
* disabled-allowlist default-on posture. See `ModuleDefinition.optIn`.
|
|
*/
|
|
export function isOptInModule(key: ModuleKey): boolean {
|
|
return MODULE_REGISTRY[key].optIn === true;
|
|
}
|