feat(modules): add operator-level server-wide module availability

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 22:44:35 +02:00
1 parent 3cae7beda8
commit 5d55347dd0
7 files changed
+667 -30

No files matched your search

@@ -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;
+12
View File
@@ -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")
}
@@ -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<Response>)(
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<Response>)(
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<Response>)(
new NextRequest("http://localhost/api/admin/settings/module-availability"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { availability: Record<string, boolean> };
};
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<Response>)(
new NextRequest("http://localhost/api/admin/settings/module-availability"),
);
const body = (await res.json()) as {
data: { availability: Record<string, boolean> };
};
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<string, boolean> };
};
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<string, boolean> })
.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);
});
});
@@ -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<z.ZodBoolean>>,
)
.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<string, boolean> = {};
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),
});
});
+125 -14
View File
@@ -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> = {}): ModuleGateInputs {
};
}
/** Operator availability map; default-available, overlay disables. */
function operator(
disabled: Partial<Record<string, boolean>> = {},
): 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({});
+72 -16
View File
@@ -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<string, boolean> {
}
/**
* 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<boolean> {
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<Record<ModuleKey, boolean>> {
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<ModuleKey, boolean>;
for (const key of MODULE_KEYS) {
out[key] = resolveModuleEnabled(key, inputs, assistantCoach);
out[key] = resolveModuleEnabled(
key,
inputs,
assistantCoach,
operatorAvailability,
);
}
return out;
}
+120
View File
@@ -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<ModuleKey, boolean>;
/**
* 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<string, unknown>)
: {};
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<OperatorModuleAvailability> {
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 `{ <ModuleKey>: 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<string, boolean>,
): Record<string, boolean> {
// Start from the existing persisted blob, keeping only known boolean
// entries, then overlay the validated patch.
const merged: Record<string, boolean> = {};
if (existing != null && typeof existing === "object" && !Array.isArray(existing)) {
for (const [k, v] of Object.entries(existing as Record<string, unknown>)) {
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;
}