From 5d55347dd08edc96e8c25de72a694cd1e1899c36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 22:44:35 +0200 Subject: [PATCH] feat(modules): add operator-level server-wide module availability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second layer of the module enable/disable model: an operator can turn a module off for the whole server, and an operator-disabled module is off for every account regardless of personal preference — mirroring the coach master flag above the per-user opt-out. Storage is a DISABLED allowlist on AppSettings.moduleAvailabilityJson (absent/empty/true = available, explicit false = disabled), resolved by getOperatorModuleAvailability(). The gate now resolves every toggleable module as operatorAvailable(key) AND userEnabled(key); cycle and coach keep their existing delegation but are still AND-ed with operator availability. Core domains are never disableable. GET/PATCH /api/admin/settings/module-availability is cookie-only (requireAdmin); a strict per-module-key schema rejects core domains and unknown keys at the boundary. --- .../migration.sql | 39 ++++ prisma/schema.prisma | 12 ++ .../__tests__/route.test.ts | 166 ++++++++++++++++++ .../settings/module-availability/route.ts | 133 ++++++++++++++ src/lib/modules/__tests__/gate.test.ts | 139 +++++++++++++-- src/lib/modules/gate.ts | 88 ++++++++-- src/lib/modules/operator-availability.ts | 120 +++++++++++++ 7 files changed, 667 insertions(+), 30 deletions(-) create mode 100644 prisma/migrations/0168_operator_module_availability/migration.sql create mode 100644 src/app/api/admin/settings/module-availability/__tests__/route.test.ts create mode 100644 src/app/api/admin/settings/module-availability/route.ts create mode 100644 src/lib/modules/operator-availability.ts diff --git a/prisma/migrations/0168_operator_module_availability/migration.sql b/prisma/migrations/0168_operator_module_availability/migration.sql new file mode 100644 index 000000000..7b0de2c0d --- /dev/null +++ b/prisma/migrations/0168_operator_module_availability/migration.sql @@ -0,0 +1,39 @@ +-- v1.18.0 — operator-level server-wide module availability. +-- +-- A single nullable JSONB blob on `app_settings` carrying a DISABLED +-- allowlist for the toggleable ("secondary") modules — the second layer of +-- the two-layer module model. This sits ABOVE the per-user +-- `users.module_preferences_json`: an operator-disabled module is off for +-- EVERY account regardless of that account's personal preference, exactly +-- like the coach master flag (`assistant_coach_enabled`) sits above +-- `users.disable_coach`. +-- +-- The contract mirrors the per-user column (default-on): +-- +-- * NULL column → every module available +-- * empty object `{}` → every module available +-- * key absent → that module available +-- * key present & `false` → that module DISABLED server-wide +-- * key present & `true` → available (redundant-but-allowed) +-- +-- No backfill — every existing instance keeps every module available. The +-- application resolver (`src/lib/modules/gate.ts`) tolerates a missing +-- column / null row and reads all-available, so a partial deploy where the +-- schema lands ahead of (or behind) the app code keeps working. +-- +-- CORE domains (weight, blood pressure, pulse, medications) are never +-- written here and the admin write endpoint refuses them, so the +-- measurement engine + meds can never be turned off server-wide either. +-- +-- No additional index — the single `app_settings` singleton row is read by +-- primary key, matching the convention of every other JSON column on the +-- table. +-- +-- Idempotent guard (`IF NOT EXISTS`) so reruns are safe on prod. +-- +-- Reversibility: down migration is +-- ALTER TABLE "app_settings" DROP COLUMN IF EXISTS "module_availability_json"; +-- A roll-back loses operator-level opt-outs (every module falls back to +-- available), which is the safe default — no data is destroyed. +ALTER TABLE "app_settings" + ADD COLUMN IF NOT EXISTS "module_availability_json" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 354486506..6c07d244d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2873,6 +2873,18 @@ model AppSettings { assistantCorrelationsEnabled Boolean @default(true) @map("assistant_correlations_enabled") assistantHealthScoreExplainerEnabled Boolean @default(true) @map("assistant_health_score_explainer_enabled") + // v1.18.0 — operator-level server-wide module availability. A DISABLED + // allowlist mirroring the per-user `User.modulePreferencesJson`: absent / + // empty / `true` ⇒ available, an explicit `false` ⇒ disabled for EVERY + // account regardless of personal preference. This is the second layer of + // the two-layer module model (operator AND user), exactly like the coach + // master flag (`assistant_coach_enabled`) sits above `User.disableCoach`. + // NULL = every module available (no admin action needed on upgrade). Core + // domains (weight, BP, pulse, medications) are never written here and the + // admin write endpoint refuses them, so the measurement engine + meds can + // never be disabled. Resolved in `src/lib/modules/gate.ts`. + moduleAvailabilityJson Json? @map("module_availability_json") + @@map("app_settings") } diff --git a/src/app/api/admin/settings/module-availability/__tests__/route.test.ts b/src/app/api/admin/settings/module-availability/__tests__/route.test.ts new file mode 100644 index 000000000..2532ed669 --- /dev/null +++ b/src/app/api/admin/settings/module-availability/__tests__/route.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { + findUnique: vi.fn(), + upsert: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, PATCH } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; + +const ADMIN_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "admin-1", username: "testuser", role: "ADMIN" as const }, +}; +const USER_OK = { + session: { id: "sess-2", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "bob", role: "USER" as const }, +}; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("GET /api/admin/settings/module-availability", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await (GET as unknown as (r: NextRequest) => Promise)( + new NextRequest("http://localhost/api/admin/settings/module-availability"), + ); + expect(res.status).toBe(401); + }); + + it("returns 403 when the caller is not an admin (cookie-only boundary)", async () => { + vi.mocked(getSession).mockResolvedValue(USER_OK as never); + const res = await (GET as unknown as (r: NextRequest) => Promise)( + new NextRequest("http://localhost/api/admin/settings/module-availability"), + ); + expect(res.status).toBe(403); + }); + + it("returns an all-available map for a fresh install (null column)", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null); + const res = await (GET as unknown as (r: NextRequest) => Promise)( + new NextRequest("http://localhost/api/admin/settings/module-availability"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { availability: Record }; + }; + expect(body.data.availability.mood).toBe(true); + expect(body.data.availability.coach).toBe(true); + expect(body.data.availability.doctorReport).toBe(true); + }); + + it("reflects a persisted operator-disabled module", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + moduleAvailabilityJson: { mood: false }, + } as never); + const res = await (GET as unknown as (r: NextRequest) => Promise)( + new NextRequest("http://localhost/api/admin/settings/module-availability"), + ); + const body = (await res.json()) as { + data: { availability: Record }; + }; + expect(body.data.availability.mood).toBe(false); + expect(body.data.availability.sleep).toBe(true); + }); +}); + +describe("PATCH /api/admin/settings/module-availability", () => { + function patchReq(payload: object) { + return new NextRequest( + "http://localhost/api/admin/settings/module-availability", + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }, + ); + } + + it("returns 403 to a non-admin caller", async () => { + vi.mocked(getSession).mockResolvedValue(USER_OK as never); + const res = await PATCH(patchReq({ mood: false })); + expect(res.status).toBe(403); + }); + + it("disables a module server-wide and echoes the resolved map", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null); + vi.mocked(prisma.appSettings.upsert).mockResolvedValue({ + moduleAvailabilityJson: { mood: false }, + } as never); + + const res = await PATCH(patchReq({ mood: false })); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { availability: Record }; + }; + expect(body.data.availability.mood).toBe(false); + expect(body.data.availability.glucose).toBe(true); + }); + + it("merges onto the existing blob (partial patch keeps other keys)", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + moduleAvailabilityJson: { sleep: false }, + } as never); + vi.mocked(prisma.appSettings.upsert).mockResolvedValue({ + moduleAvailabilityJson: { sleep: false, mood: false }, + } as never); + + const res = await PATCH(patchReq({ mood: false })); + expect(res.status).toBe(200); + // The upsert must carry BOTH the pre-existing sleep:false and the new + // mood:false — the partial patch never drops untouched keys. + const upsertArg = vi.mocked(prisma.appSettings.upsert).mock.calls[0][0]; + expect( + (upsertArg.update as { moduleAvailabilityJson: Record }) + .moduleAvailabilityJson, + ).toEqual({ sleep: false, mood: false }); + }); + + it("rejects an empty body", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + const res = await PATCH(patchReq({})); + expect(res.status).toBe(422); + }); + + it("rejects a core domain key strictly (weight can never be disabled)", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + const res = await PATCH(patchReq({ weight: false })); + expect(res.status).toBe(422); + }); + + it("rejects an unknown module key strictly", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + const res = await PATCH(patchReq({ potato: false })); + expect(res.status).toBe(422); + }); +}); diff --git a/src/app/api/admin/settings/module-availability/route.ts b/src/app/api/admin/settings/module-availability/route.ts new file mode 100644 index 000000000..da9dea71a --- /dev/null +++ b/src/app/api/admin/settings/module-availability/route.ts @@ -0,0 +1,133 @@ +/** + * v1.18.0 — `GET` / `PATCH /api/admin/settings/module-availability` — + * operator-side surface for the server-wide module availability matrix. + * + * The SECOND layer of the two-layer module model. A module the operator + * turns off here is off for EVERY account regardless of that account's + * personal preference — mirroring the coach master flag + * (`assistant_coach_enabled`) sitting above the per-user + * `User.disableCoach`. The gate (`src/lib/modules/gate.ts`) AND-s this + * operator availability with the per-user opt-out, and `GET /api/auth/me` + * already projects the resolved (operator AND user) map, so web + iOS need + * no extra logic. + * + * Dedicated endpoint (separate from the generic `/api/admin/settings`) + * so the admin panel can wire an optimistic matrix against a focused + * request/response shape and the audit trail carries a single + * `admin.settings.module-availability.update` action. + * + * Request shape — a partial DISABLED-allowlist patch keyed by toggleable + * module key; every field optional; `true` ⇒ available, `false` ⇒ disabled + * server-wide: + * + * { "mood": false, "glucose": true } + * + * Core domains (weight, BP, pulse, medications) are NOT module keys; the + * `.strict()` schema rejects them, so the measurement engine + meds can + * never be disabled through this route. + * + * `requireAdmin()` gates the route — cookie-only by construction; a Bearer + * token (even `["*"]` scope) cannot reach it. + */ +import type { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { + mergeAvailabilityPatch, + resolveOperatorAvailability, +} from "@/lib/modules/operator-availability"; +import { MODULE_KEYS } from "@/lib/modules/registry"; + +export const dynamic = "force-dynamic"; + +/** + * Strict per-module boolean patch. Building the shape from `MODULE_KEYS` + * keeps the schema exhaustive and rejects any non-module key (including + * the four core domains) at the validation boundary. + */ +const moduleAvailabilitySchema = z + .object( + Object.fromEntries( + MODULE_KEYS.map((key) => [key, z.boolean().optional()]), + ) as Record<(typeof MODULE_KEYS)[number], z.ZodOptional>, + ) + .strict(); + +export const GET = apiHandler(async () => { + await requireAdmin(); + annotate({ action: { name: "admin.settings.module-availability.get" } }); + + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { moduleAvailabilityJson: true }, + }); + + return apiSuccess({ + availability: resolveOperatorAvailability(settings?.moduleAvailabilityJson), + }); +}); + +export const PATCH = apiHandler(async (request: NextRequest) => { + const { user } = await requireAdmin(); + annotate({ action: { name: "admin.settings.module-availability.update" } }); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = moduleAvailabilitySchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422); + } + + const patch: Record = {}; + for (const [key, value] of Object.entries(parsed.data)) { + if (typeof value === "boolean") { + patch[key] = value; + } + } + + if (Object.keys(patch).length === 0) { + return apiError("No valid fields", 422); + } + + // Merge onto the existing blob so a partial PATCH leaves untouched keys + // intact; the merge drops any non-module key defensively. + const existing = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { moduleAvailabilityJson: true }, + }); + const merged = mergeAvailabilityPatch( + existing?.moduleAvailabilityJson, + patch, + ); + + const settings = await prisma.appSettings.upsert({ + where: { id: "singleton" }, + update: { moduleAvailabilityJson: merged }, + create: { id: "singleton", moduleAvailabilityJson: merged }, + select: { moduleAvailabilityJson: true }, + }); + + await auditLog("admin.settings.module-availability.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: patch, + }); + + return apiSuccess({ + availability: resolveOperatorAvailability(settings.moduleAvailabilityJson), + }); +}); diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts index 14ee98578..07ecfbd15 100644 --- a/src/lib/modules/__tests__/gate.test.ts +++ b/src/lib/modules/__tests__/gate.test.ts @@ -14,6 +14,10 @@ import { MODULE_DISABLED_ERROR_CODE, type ModuleGateInputs, } from "../gate"; +import { + resolveOperatorAvailability, + type OperatorModuleAvailability, +} from "../operator-availability"; import { isCoreDomain, isModuleKey, @@ -32,16 +36,26 @@ function inputs(over: Partial = {}): ModuleGateInputs { }; } +/** Operator availability map; default-available, overlay disables. */ +function operator( + disabled: Partial> = {}, +): OperatorModuleAvailability { + return resolveOperatorAvailability(disabled); +} + +/** All-available operator layer for the user-layer-focused cases. */ +const ALL_AVAILABLE = operator(); + 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); + expect(resolveModuleEnabled("mood", inputs(), true, ALL_AVAILABLE)).toBe(true); + expect(resolveModuleEnabled("sleep", inputs(), true, ALL_AVAILABLE)).toBe(true); + expect(resolveModuleEnabled("labs", inputs(), true, ALL_AVAILABLE)).toBe(true); }); it("enables a module when the key is present and true", () => { expect( - resolveModuleEnabled("mood", inputs({ modulePreferences: { mood: true } }), true), + resolveModuleEnabled("mood", inputs({ modulePreferences: { mood: true } }), true, ALL_AVAILABLE), ).toBe(true); }); @@ -51,15 +65,16 @@ describe("resolveModuleEnabled — disabled allowlist (default-on)", () => { "glucose", inputs({ modulePreferences: { glucose: false } }), true, + ALL_AVAILABLE, ), ).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); + expect(resolveModuleEnabled("workouts", i, true, ALL_AVAILABLE)).toBe(false); + expect(resolveModuleEnabled("recovery", i, true, ALL_AVAILABLE)).toBe(true); + expect(resolveModuleEnabled("labs", i, true, ALL_AVAILABLE)).toBe(true); }); }); @@ -72,7 +87,7 @@ describe("resolveModuleEnabled — cycle delegation", () => { cycleTrackingEnabled: null, modulePreferences: { cycle: false }, }); - expect(resolveModuleEnabled("cycle", i, true)).toBe(true); + expect(resolveModuleEnabled("cycle", i, true, ALL_AVAILABLE)).toBe(true); }); it("respects the cycle toggle when set", () => { @@ -81,6 +96,7 @@ describe("resolveModuleEnabled — cycle delegation", () => { "cycle", inputs({ gender: "MALE", cycleTrackingEnabled: true }), true, + ALL_AVAILABLE, ), ).toBe(true); expect( @@ -88,13 +104,14 @@ describe("resolveModuleEnabled — cycle delegation", () => { "cycle", inputs({ gender: "FEMALE", cycleTrackingEnabled: false }), true, + ALL_AVAILABLE, ), ).toBe(false); }); it("a MALE account with a null toggle is cycle-disabled", () => { expect( - resolveModuleEnabled("cycle", inputs({ gender: "MALE" }), true), + resolveModuleEnabled("cycle", inputs({ gender: "MALE" }), true, ALL_AVAILABLE), ).toBe(false); }); }); @@ -102,19 +119,19 @@ describe("resolveModuleEnabled — cycle delegation", () => { describe("resolveModuleEnabled — coach delegation (two-layer)", () => { it("is enabled only when the operator master flag AND !disableCoach", () => { expect( - resolveModuleEnabled("coach", inputs({ disableCoach: false }), true), + resolveModuleEnabled("coach", inputs({ disableCoach: false }), true, ALL_AVAILABLE), ).toBe(true); }); it("is disabled when the per-user opt-out is set", () => { expect( - resolveModuleEnabled("coach", inputs({ disableCoach: true }), true), + resolveModuleEnabled("coach", inputs({ disableCoach: true }), true, ALL_AVAILABLE), ).toBe(false); }); it("is disabled when the operator master flag is off", () => { expect( - resolveModuleEnabled("coach", inputs({ disableCoach: false }), false), + resolveModuleEnabled("coach", inputs({ disableCoach: false }), false, ALL_AVAILABLE), ).toBe(false); }); @@ -122,18 +139,19 @@ describe("resolveModuleEnabled — coach delegation (two-layer)", () => { // 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); + expect(resolveModuleEnabled("coach", i, true, ALL_AVAILABLE)).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(), true, ALL_AVAILABLE)).toBe(true); expect( resolveModuleEnabled( "insights", inputs({ modulePreferences: { insights: false } }), true, + ALL_AVAILABLE, ), ).toBe(false); }); @@ -143,6 +161,99 @@ describe("insights — narrative layer, not a delegated module", () => { }); }); +describe("resolveModuleEnabled — operator layer (two-layer AND)", () => { + it("operator-off ⇒ false even when the user has it on", () => { + // User layer fully on (no opt-out), operator disables it server-wide. + expect( + resolveModuleEnabled( + "mood", + inputs({ modulePreferences: { mood: true } }), + true, + operator({ mood: false }), + ), + ).toBe(false); + }); + + it("operator-on + user-off ⇒ false", () => { + expect( + resolveModuleEnabled( + "glucose", + inputs({ modulePreferences: { glucose: false } }), + true, + operator({ glucose: true }), + ), + ).toBe(false); + }); + + it("operator-on + user-on ⇒ true", () => { + expect( + resolveModuleEnabled("labs", inputs(), true, operator({ labs: true })), + ).toBe(true); + }); + + it("operator-off short-circuits a delegated module (coach) regardless of user state", () => { + // Even with the assistant master flag on and no per-user opt-out, the + // operator module-availability kill-switch wins. + expect( + resolveModuleEnabled( + "coach", + inputs({ disableCoach: false }), + true, + operator({ coach: false }), + ), + ).toBe(false); + }); + + it("operator-off short-circuits cycle even for a FEMALE account", () => { + expect( + resolveModuleEnabled( + "cycle", + inputs({ gender: "FEMALE", cycleTrackingEnabled: true }), + true, + operator({ cycle: false }), + ), + ).toBe(false); + }); + + it("isolates per-module: operator disabling one leaves siblings available", () => { + const op = operator({ workouts: false }); + expect(resolveModuleEnabled("workouts", inputs(), true, op)).toBe(false); + expect(resolveModuleEnabled("recovery", inputs(), true, op)).toBe(true); + expect(resolveModuleEnabled("sleep", inputs(), true, op)).toBe(true); + }); +}); + +describe("resolveOperatorAvailability — disabled allowlist (default-available)", () => { + it("treats null / non-object / array as all-available", () => { + for (const raw of [null, undefined, "nope", ["mood"]]) { + const map = resolveOperatorAvailability(raw); + for (const key of MODULE_KEYS) { + expect(map[key]).toBe(true); + } + } + }); + + it("disables a module ONLY on an explicit false; junk + true read as available", () => { + const map = resolveOperatorAvailability({ + mood: false, + sleep: true, + glucose: "yes", + labs: 0, + }); + expect(map.mood).toBe(false); + expect(map.sleep).toBe(true); + expect(map.glucose).toBe(true); + expect(map.labs).toBe(true); + }); + + it("returns an entry for every toggleable module", () => { + const map = resolveOperatorAvailability({}); + for (const key of MODULE_KEYS) { + expect(typeof map[key]).toBe("boolean"); + } + }); +}); + describe("normalisePrefs — fail-open coercion", () => { it("treats null / non-object / array as empty (all-on)", () => { expect(normalisePrefs(null)).toEqual({}); diff --git a/src/lib/modules/gate.ts b/src/lib/modules/gate.ts index 58658e73b..3e4b8b7a3 100644 --- a/src/lib/modules/gate.ts +++ b/src/lib/modules/gate.ts @@ -9,18 +9,29 @@ * (`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): + * TWO-LAYER model (operator AND user). Every toggleable module's final + * enabled-state is `operatorAvailable(key) && userEnabled(key)`: + * + * - LAYER 1 (operator): the server-wide availability allowlist on + * `AppSettings.moduleAvailabilityJson`, resolved by + * `getOperatorModuleAvailability()`. A module the operator + * turns off is off for EVERY account regardless of personal + * preference — mirroring the coach master flag above + * `User.disableCoach`. Default-available. + * - LAYER 2 (user): the per-account opt-out described below. + * + * Source-of-truth discipline (NO double source of truth) for LAYER 2: * * - `cycle` delegates to `isCycleEnabled(gender, CycleProfile)` — the * existing cycle gate. `modulePreferencesJson.cycle` is - * ignored on purpose. + * ignored on purpose. Still AND-ed with operator availability. * - `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. + * already enforce. Also AND-ed with operator availability. + * - every other module resolves its user-layer state 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 @@ -38,12 +49,21 @@ import { prisma } from "@/lib/db"; import { isCycleEnabled } from "@/lib/cycle/gate"; import { getAssistantFlags } from "@/lib/feature-flags"; import { memoizePerRequest } from "@/lib/request-cache"; +import { + getOperatorModuleAvailability, + type OperatorModuleAvailability, +} from "./operator-availability"; import { MODULE_KEYS, moduleDelegatesTo, type ModuleKey, } from "./registry"; +export { + getOperatorModuleAvailability, + type OperatorModuleAvailability, +} from "./operator-availability"; + export { MODULE_KEYS } from "./registry"; export type { ModuleKey } from "./registry"; @@ -83,16 +103,35 @@ export function normalisePrefs(raw: unknown): Record { } /** - * 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. + * Pure resolver — exposed for unit tests so the two-layer (operator AND + * user) resolution can be asserted without a DB. + * + * `assistantCoach` is the resolved operator-level `getAssistantFlags().coach` + * and `operatorAvailability` is the resolved + * `getOperatorModuleAvailability()` map; both are passed in so this stays + * synchronous and side-effect-free. + * + * Resolution is `operatorAvailable(key) && userEnabled(key)`: + * + * - operator layer: an explicit operator `false` for the key disables it + * server-wide and short-circuits — no per-user state can re-enable it + * (matching the coach master flag winning over the per-user opt-out). + * - user layer: cycle/coach delegate to their real source; every other + * module reads the per-user disabled allowlist. */ export function resolveModuleEnabled( key: ModuleKey, inputs: ModuleGateInputs, assistantCoach: boolean, + operatorAvailability: OperatorModuleAvailability, ): boolean { + // LAYER 1 — operator server-wide availability. An operator-disabled + // module is off for everyone regardless of personal preference. + if (operatorAvailability[key] === false) { + return false; + } + + // LAYER 2 — per-user state. const delegate = moduleDelegatesTo(key); if (delegate === "cycle") { @@ -103,7 +142,7 @@ export function resolveModuleEnabled( } if (delegate === "coach") { - // Two-layer model: operator master flag AND per-user opt-out. + // Two-layer model: assistant master flag AND per-user opt-out. return assistantCoach && !inputs.disableCoach; } @@ -146,12 +185,20 @@ export async function isModuleEnabled( userId: string, moduleKey: ModuleKey, ): Promise { - const inputs = await loadInputs(userId); + const [inputs, operatorAvailability] = await Promise.all([ + loadInputs(userId), + getOperatorModuleAvailability(), + ]); 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); + return resolveModuleEnabled( + moduleKey, + inputs, + assistantCoach, + operatorAvailability, + ); } /** @@ -162,11 +209,20 @@ export async function isModuleEnabled( export async function resolveModuleMap( userId: string, ): Promise> { - const inputs = await loadInputs(userId); - const assistantCoach = (await getAssistantFlags()).coach; + const [inputs, operatorAvailability, assistantFlags] = await Promise.all([ + loadInputs(userId), + getOperatorModuleAvailability(), + getAssistantFlags(), + ]); + const assistantCoach = assistantFlags.coach; const out = {} as Record; for (const key of MODULE_KEYS) { - out[key] = resolveModuleEnabled(key, inputs, assistantCoach); + out[key] = resolveModuleEnabled( + key, + inputs, + assistantCoach, + operatorAvailability, + ); } return out; } diff --git a/src/lib/modules/operator-availability.ts b/src/lib/modules/operator-availability.ts new file mode 100644 index 000000000..74d0c9ec1 --- /dev/null +++ b/src/lib/modules/operator-availability.ts @@ -0,0 +1,120 @@ +/** + * v1.18.0 — operator-level server-wide module availability. + * + * The SECOND layer of the two-layer module model. The first layer is the + * per-user opt-out (`User.modulePreferencesJson`, resolved in + * `./gate.ts`); this layer is the operator's server-wide kill-switch + * (`AppSettings.moduleAvailabilityJson`). A module the operator turns off + * is off for EVERY account regardless of that account's personal + * preference — exactly mirroring how the coach master flag + * (`assistant_coach_enabled`) sits above the per-user `User.disableCoach`. + * + * Persisted shape: a DISABLED allowlist, identical in semantics to the + * per-user blob — + * + * - NULL / empty / key absent ⇒ available + * - key present & `false` ⇒ disabled server-wide + * - key present & `true` ⇒ available (redundant-but-allowed) + * + * Default-available, no backfill: a fresh / upgraded instance keeps every + * module available until an operator flips one off. + * + * Fail-open posture: a null column, a malformed blob, an unknown key, or a + * read error all resolve to "available". Only an explicit, well-typed + * `false` for a known toggleable key disables a module server-wide. This + * matches the per-user resolver's posture so a storage glitch never + * silently hides a domain from the whole server. + * + * CORE domains (weight, BP, pulse, medications) are NOT `ModuleKey`s, are + * never written here, and the admin write endpoint refuses them — the + * measurement engine + meds can never be disabled. + */ +import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; +import { memoizePerRequest } from "@/lib/request-cache"; + +import { MODULE_KEYS, isModuleKey, type ModuleKey } from "./registry"; + +/** + * The resolved operator availability map: one boolean per toggleable + * module. `true` ⇒ available server-wide, `false` ⇒ disabled server-wide. + */ +export type OperatorModuleAvailability = Record; + +/** + * Coerce the persisted blob into a normalised availability map. Every + * known toggleable key gets an entry; absent / non-boolean / unknown keys + * collapse to `true` (available) per the disabled-allowlist contract. + * + * Exposed for unit tests so the coercion + allowlist logic can be asserted + * without a DB. + */ +export function resolveOperatorAvailability( + raw: unknown, +): OperatorModuleAvailability { + const blob = + raw != null && typeof raw === "object" && !Array.isArray(raw) + ? (raw as Record) + : {}; + + const out = {} as OperatorModuleAvailability; + for (const key of MODULE_KEYS) { + // Only an explicit `false` disables; everything else is available. + out[key] = blob[key] !== false; + } + return out; +} + +/** + * Load the operator availability map once per request. Mirrors the + * `getAssistantFlags()` / `getGlobalServiceAvailability()` read-through: + * a missing row, a null column, or a read error all fall back to + * all-available so the modules stay visible on first boot. + */ +export async function getOperatorModuleAvailability(): Promise { + return memoizePerRequest("operator-module-availability", async () => { + try { + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { moduleAvailabilityJson: true }, + }); + return resolveOperatorAvailability(settings?.moduleAvailabilityJson); + } catch { + getEvent()?.addWarning( + "Failed to load operator module availability, defaulting to all-available", + ); + return resolveOperatorAvailability(null); + } + }); +} + +/** + * Narrow + validate an operator write patch (the admin route's parsed + * body) into a sanitised `{ : boolean }` map. Drops any key + * that is not a known toggleable module — so a crafted `{ weight: false }` + * (a core domain) or junk key can never land in the persisted blob. + * + * Returns the merged blob to persist (existing availability overlaid with + * the validated patch) so a partial PATCH leaves untouched keys intact. + */ +export function mergeAvailabilityPatch( + existing: unknown, + patch: Record, +): Record { + // Start from the existing persisted blob, keeping only known boolean + // entries, then overlay the validated patch. + const merged: Record = {}; + if (existing != null && typeof existing === "object" && !Array.isArray(existing)) { + for (const [k, v] of Object.entries(existing as Record)) { + if (isModuleKey(k) && typeof v === "boolean") { + merged[k] = v; + } + } + } + for (const [k, v] of Object.entries(patch)) { + if (isModuleKey(k)) { + merged[k] = v; + } + } + return merged; +}