mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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;
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user