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,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),
});
});