From 3788ecd9f1a4e32e68abac5ee2dae3418cdebfb2 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Fri, 28 Aug 2026 23:31:47 +0200 Subject: [PATCH] feat(housekeeping): deliver system vertical --- .../task-10-report.md | 94 +++ src/actions/admin-alerts.test.ts | 2 +- src/actions/admin-alerts.ts | 44 +- src/actions/admin-emulator.ts | 56 +- src/actions/admin-maintenance.ts | 59 +- src/actions/admin-settings.ts | 115 ++- src/actions/commandocentrum.ts | 220 ++--- src/actions/permissions.ts | 267 ++---- .../system/commands/system-commands.test.ts | 228 ++++++ .../system/commands/system-commands.ts | 315 ++++++++ .../housekeeping/domains/system/manifest.ts | 3 +- .../domains/system/pages/access.tsx | 193 +++++ .../domains/system/pages/configuration.tsx | 165 ++++ .../domains/system/pages/observability.tsx | 212 +++++ .../domains/system/pages/operations.tsx | 182 +++++ .../system/pages/system-pages.test.tsx | 157 ++++ .../domains/system/queries/access.ts | 257 ++++++ .../domains/system/queries/configuration.ts | 172 ++++ .../domains/system/queries/observability.ts | 427 ++++++++++ .../domains/system/queries/operations.ts | 246 ++++++ .../system/queries/system-queries.test.ts | 207 +++++ .../domains/system/route-handlers.ts | 26 + .../domains/system/routes.test.ts | 147 ++++ .../housekeeping/domains/system/routes.ts | 132 +++ .../services/mutations-production.test.ts | 108 +++ .../domains/system/services/mutations.ts | 763 ++++++++++++++++++ .../foundation/commands/bootstrap.test.ts | 5 + .../foundation/commands/bootstrap.ts | 5 +- .../foundation-source-contract.test.ts | 130 ++- .../housekeeping/foundation/registry.test.ts | 5 +- .../housekeeping/route-handlers.test.ts | 3 +- src/features/housekeeping/route-handlers.ts | 3 +- src/lib/admin/acl-management-contract.test.ts | 23 +- 33 files changed, 4548 insertions(+), 423 deletions(-) create mode 100644 .superpowers/sdd/2026-08-26-housekeeping-completion/task-10-report.md create mode 100644 src/features/housekeeping/domains/system/commands/system-commands.test.ts create mode 100644 src/features/housekeeping/domains/system/commands/system-commands.ts create mode 100644 src/features/housekeeping/domains/system/pages/access.tsx create mode 100644 src/features/housekeeping/domains/system/pages/configuration.tsx create mode 100644 src/features/housekeeping/domains/system/pages/observability.tsx create mode 100644 src/features/housekeeping/domains/system/pages/operations.tsx create mode 100644 src/features/housekeeping/domains/system/pages/system-pages.test.tsx create mode 100644 src/features/housekeeping/domains/system/queries/access.ts create mode 100644 src/features/housekeeping/domains/system/queries/configuration.ts create mode 100644 src/features/housekeeping/domains/system/queries/observability.ts create mode 100644 src/features/housekeeping/domains/system/queries/operations.ts create mode 100644 src/features/housekeeping/domains/system/queries/system-queries.test.ts create mode 100644 src/features/housekeeping/domains/system/route-handlers.ts create mode 100644 src/features/housekeeping/domains/system/routes.test.ts create mode 100644 src/features/housekeeping/domains/system/routes.ts create mode 100644 src/features/housekeeping/domains/system/services/mutations-production.test.ts create mode 100644 src/features/housekeeping/domains/system/services/mutations.ts diff --git a/.superpowers/sdd/2026-08-26-housekeeping-completion/task-10-report.md b/.superpowers/sdd/2026-08-26-housekeeping-completion/task-10-report.md new file mode 100644 index 00000000..d92f7d52 --- /dev/null +++ b/.superpowers/sdd/2026-08-26-housekeeping-completion/task-10-report.md @@ -0,0 +1,94 @@ +# Task 10 report: System vertical + +## Outcome + +Delivered the real System access, configuration, observability, and operations vertical from base `0117b45d74450510187f8f860ee927a193c45a3c` on `codex/housekeeping-complete`. + +- Registered the exact 17 System route IDs, canonical `/ase/system/*` hrefs, labels, and read capabilities from `migration/system.ts`. +- Added injected access, configuration, observability, and operations queries with forbidden, partial, and dependency-unavailable results. +- Extracted redirect-free, server-only, capability-guarded mutation services from the six legacy action modules while retaining their existing permission checks and `/admin` revalidation behavior. +- Registered 29 sensitive System commands through deterministic bootstrap, dispatcher authorization, confirmation, rate limiting, and audit. Reasons are mandatory for ACL/permission changes, global settings, alert broadcast, every RCON operation, and maintenance/global availability. +- Added four query-backed server workflow page modules with loading, empty, partial, error, forbidden, and ready states. +- Added the exact 17 System handlers to the global aggregate. The manifest contains real routes and deliberately keeps providers, search, inbox, and widgets empty for Task 19. + +No database operation, deployment, push, pull request update, Task 11 work, `/admin` or `/mod` cutover, rank-threshold authorization, or placeholder workflow was performed. `.remember/remember.md` remained untracked and untouched. + +## TDD evidence + +All Vitest commands used `--coverage.enabled=false` so each RED/GREEN cycle exercised only the named boundary. + +| Phase | Exact command | RED | GREEN | +| --- | --- | --- | --- | +| Routes | `pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/system/routes.test.ts` | 1 file failed before tests: missing `./routes`. | 1 file, 3 tests passed. | +| Injected queries | `pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/system/queries/system-queries.test.ts` | 1 file failed before tests: missing `./access`. | 1 file, 6 tests passed. | +| Commands and guarded service | `pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/system/commands/system-commands.test.ts` | 1 file failed before tests: missing `../services/mutations`. | 1 file, 6 tests passed at the first command boundary. | +| Legacy alert and maintenance wrappers | `pnpm exec vitest run --coverage.enabled=false src/actions/admin-alerts.test.ts src/actions/admin-maintenance.test.ts` | 1 of 7 tests failed because the existing alert mock granted `notifications.edit` instead of the canonical `admin.notifications.edit`. | 2 files, 7 tests passed after correcting only the stale mock permission. | +| ACL wrapper extraction | `pnpm exec vitest run --coverage.enabled=false src/lib/admin/acl-management-contract.test.ts` | 1 of 2 tests failed before `access.permissions.update` was present. | 1 file, 2 tests passed after the wrapper delegated to the guarded service. | +| Workflow pages | `pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/system/pages/system-pages.test.tsx` | 1 file failed before tests: missing `./access`. | 1 file, 8 tests passed. | +| Handler/bootstrap integration | `pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/route-handlers.test.ts src/features/housekeeping/foundation/commands/bootstrap.test.ts` | 2 tests failed: System had 0 handlers instead of 17 and no 29-command bootstrap registration. | 2 files, 3 tests passed. | +| Review regressions | `pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/system/commands/system-commands.test.ts src/features/housekeeping/domains/system/services/mutations-production.test.ts src/lib/admin/acl-management-contract.test.ts` | 3 files failed; 11 tests failed and 6 passed. Failures proved missing alert reason, six whitespace-only inputs accepted, three alert dependency failures swallowed, and the public/non-strict production boundary. | 3 files, 17 tests passed after the minimal corrections. | + +The first integrated target run passed 17 files and 179 tests. `pnpm typecheck` then exposed four integration-only type errors (a heterogeneous test tuple, a bigint alert identifier, and result-union narrowing); the corrected run passed. The first `pnpm test:housekeeping` exposed exactly three obsolete foundation assertions (40 files/372 tests otherwise passed). The three directly obsolete contracts were updated with strict positive System assertions without relaxing another domain; the focused rerun passed 2 files/38 tests and the then-current full suite passed 42 files/376 tests. + +## Final verification + +- `pnpm exec vitest run --coverage.enabled=false src/actions/admin-alerts.test.ts src/actions/admin-maintenance.test.ts src/lib/admin/acl-management-contract.test.ts src/features/housekeeping/domains/system/routes.test.ts src/features/housekeeping/domains/system/queries/system-queries.test.ts src/features/housekeeping/domains/system/commands/system-commands.test.ts src/features/housekeeping/domains/system/services/mutations-production.test.ts src/features/housekeeping/domains/system/pages/system-pages.test.tsx src/features/housekeeping/migration/system.test.ts src/features/housekeeping/route-handlers.test.ts src/features/housekeeping/foundation/commands/bootstrap.test.ts src/features/housekeeping/foundation/commands/registry.test.ts src/features/housekeeping/foundation/commands/dispatcher.test.ts src/features/housekeeping/foundation/commands/confirmation.test.ts src/features/housekeeping/foundation/commands/audit-envelope.test.ts src/features/housekeeping/foundation/foundation-source-contract.test.ts src/features/housekeeping/foundation/registry.test.ts`  17 files, 185 tests passed. +- `pnpm exec vitest run --coverage.enabled=false src/lib/admin-operations-contract.test.ts src/lib/staff-smoke-contract.test.ts src/lib/admin/authorization-contract.test.ts`  3 files, 97 tests passed. +- `pnpm test:housekeeping`  43 files, 385 tests passed. +- `pnpm typecheck`  passed (`tsc --noEmit`). +- `pnpm exec biome check --formatter-enabled=false src/actions/admin-alerts.test.ts src/actions/admin-alerts.ts src/actions/admin-emulator.ts src/actions/admin-maintenance.ts src/actions/admin-settings.ts src/actions/commandocentrum.ts src/actions/permissions.ts src/features/housekeeping/domains/system src/features/housekeeping/foundation/commands/bootstrap.test.ts src/features/housekeeping/foundation/commands/bootstrap.ts src/features/housekeeping/foundation/foundation-source-contract.test.ts src/features/housekeeping/foundation/registry.test.ts src/features/housekeeping/route-handlers.test.ts src/features/housekeeping/route-handlers.ts src/lib/admin/acl-management-contract.test.ts`  checked 32 files; no fixes applied. +- `git diff --check`  exit 0; only expected Git autocrlf warnings. +- `git diff --cached --check`  exit 0 before staging and rerun after exact staging. +- Independent read-only re-review  0 Critical, 0 Important, 0 Minor; ready verdict. + +Node/pnpm emitted this non-blocking warning during pnpm gates: + +```text +[WARN] Unsupported engine: wanted: {"node":">=26.8.1 <27"} (current: {"node":"v26.7.0","pnpm":"11.24.0"}) +``` + +## Architectural decisions + +- The migration matrix remains the single source of route truth. The System route array is materialized from its exact identifiers and values, and tests assert ordered route/handler equality rather than set-only coverage. +- Query factories accept narrow adapters; production adapters reuse existing ACL, settings, emulator, health, online-user, analytics, log, alert, and maintenance services. This avoided unnecessary edits to `ops-health.ts` and `ops-online-users.ts`. +- `systemMutationService` is the only public production mutation boundary. It is server-only and repeats capability enforcement even when called by an already-guarded legacy action or an authorized dispatcher. The unguarded production adapter is module-private. +- Adapter exceptions and unsuccessful RCON sends become typed `DEPENDENCY_UNAVAILABLE` failures. Rank-delete conflict metadata travels in the standard `fieldErrors` shape; the legacy wrapper reconstructs the prior human-readable `ActionError`, keeping the dispatcher result schema strict. +- Command string schemas use a non-transforming `\S` check to reject whitespace-only values; normalization and trimming remain at the guarded service boundary. This preserves the foundation registry rule that command schemas contain no executable transforms. +- Existing semantic label keys were reused where they matched; missing System labels use stable functional keys without changing i18n catalogs outside the tested scope. +- Foundation source-boundary changes are a narrow source-to-import allowlist for the new System integration edges. Existing forbidden directions for every other domain remain asserted. + +## Changed files + +- `.superpowers/sdd/2026-08-26-housekeeping-completion/task-10-report.md` +- `src/actions/admin-alerts.test.ts` +- `src/actions/admin-alerts.ts` +- `src/actions/admin-emulator.ts` +- `src/actions/admin-maintenance.ts` +- `src/actions/admin-settings.ts` +- `src/actions/commandocentrum.ts` +- `src/actions/permissions.ts` +- `src/features/housekeeping/domains/system/commands/system-commands.test.ts` +- `src/features/housekeeping/domains/system/commands/system-commands.ts` +- `src/features/housekeeping/domains/system/manifest.ts` +- `src/features/housekeeping/domains/system/pages/access.tsx` +- `src/features/housekeeping/domains/system/pages/configuration.tsx` +- `src/features/housekeeping/domains/system/pages/observability.tsx` +- `src/features/housekeeping/domains/system/pages/operations.tsx` +- `src/features/housekeeping/domains/system/pages/system-pages.test.tsx` +- `src/features/housekeeping/domains/system/queries/access.ts` +- `src/features/housekeeping/domains/system/queries/configuration.ts` +- `src/features/housekeeping/domains/system/queries/observability.ts` +- `src/features/housekeeping/domains/system/queries/operations.ts` +- `src/features/housekeeping/domains/system/queries/system-queries.test.ts` +- `src/features/housekeeping/domains/system/route-handlers.ts` +- `src/features/housekeeping/domains/system/routes.test.ts` +- `src/features/housekeeping/domains/system/routes.ts` +- `src/features/housekeeping/domains/system/services/mutations-production.test.ts` +- `src/features/housekeeping/domains/system/services/mutations.ts` +- `src/features/housekeeping/foundation/commands/bootstrap.test.ts` +- `src/features/housekeeping/foundation/commands/bootstrap.ts` +- `src/features/housekeeping/foundation/foundation-source-contract.test.ts` +- `src/features/housekeeping/foundation/registry.test.ts` +- `src/features/housekeeping/route-handlers.test.ts` +- `src/features/housekeeping/route-handlers.ts` +- `src/lib/admin/acl-management-contract.test.ts` diff --git a/src/actions/admin-alerts.test.ts b/src/actions/admin-alerts.test.ts index 253f3831..943c2a57 100644 --- a/src/actions/admin-alerts.test.ts +++ b/src/actions/admin-alerts.test.ts @@ -7,7 +7,7 @@ import { sendHotelAlert } from "./admin-alerts"; vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); vi.mock("@/lib/permissions", () => ({ - PERMS: { NOTIFICATIONS_EDIT: "notifications.edit" }, + PERMS: { NOTIFICATIONS_EDIT: "admin.notifications.edit" }, })); vi.mock("@/lib/db", () => ({ db: { diff --git a/src/actions/admin-alerts.ts b/src/actions/admin-alerts.ts index 74414740..ee34c24e 100644 --- a/src/actions/admin-alerts.ts +++ b/src/actions/admin-alerts.ts @@ -1,11 +1,30 @@ "use server"; -import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { + type SystemMutationContext, + systemMutationService, +} from "@/features/housekeeping/domains/system/services/mutations"; +import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context"; +import { createCorrelationId } from "@/features/housekeeping/foundation/correlation"; import { requirePermission } from "@/lib/admin/guard"; -import { AlertLogs, db } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -import { rcon } from "@/lib/services/rcon"; + +function grantedMutationContext( + staff: { id: number; rank: number; username: string }, + permission: string, +): SystemMutationContext { + const matches = (slug: string) => slug === permission; + return { + capability: createHousekeepingCapabilityContext(staff, { + isSuperAdmin: false, + has: matches, + hasAny: (...slugs) => slugs.some(matches), + hasAll: (...slugs) => slugs.every(matches), + }), + correlationId: createCorrelationId(), + }; +} /** * Broadcast a hotel-wide alert to every online user via RCON. @@ -14,7 +33,7 @@ import { rcon } from "@/lib/services/rcon"; * `message` payload. Staff-gated; the message is trimmed/bounded before send. */ export async function sendHotelAlert(formData: FormData): Promise { - await requirePermission(PERMS.NOTIFICATIONS_EDIT); + const staff = await requirePermission(PERMS.NOTIFICATIONS_EDIT); const message = String(formData.get("message") ?? "") .normalize("NFC") @@ -23,7 +42,11 @@ export async function sendHotelAlert(formData: FormData): Promise { if (!message) return; try { - await rcon.send("hotelalert", { message }); + await systemMutationService.execute( + grantedMutationContext(staff, PERMS.NOTIFICATIONS_EDIT), + "operations.alert.broadcast", + { message }, + ); } catch { // Best-effort delivery (dead socket / emulator offline) — never 500 the // admin page. The emulator writes its own alert_logs row on receipt. @@ -34,12 +57,13 @@ export async function sendHotelAlert(formData: FormData): Promise { /** Mark every unread ops alert as read. */ export async function markAllAlertsRead(): Promise { - await requirePermission(PERMS.NOTIFICATIONS_VIEW); + const staff = await requirePermission(PERMS.NOTIFICATIONS_VIEW); try { - await db - .update(AlertLogs) - .set({ isRead: true, updatedAt: new Date() }) - .where(eq(AlertLogs.isRead, false)); + await systemMutationService.execute( + grantedMutationContext(staff, PERMS.NOTIFICATIONS_VIEW), + "operations.alerts.mark-read", + {}, + ); } catch { /* ignore */ } diff --git a/src/actions/admin-emulator.ts b/src/actions/admin-emulator.ts index 2e08cce7..88660daa 100644 --- a/src/actions/admin-emulator.ts +++ b/src/actions/admin-emulator.ts @@ -1,8 +1,10 @@ "use server"; import { revalidatePath } from "next/cache"; +import { systemMutationService } from "@/features/housekeeping/domains/system/services/mutations"; +import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context"; +import { createCorrelationId } from "@/features/housekeeping/foundation/correlation"; import { requirePermission } from "@/lib/admin/guard"; -import { db, EmulatorSettings, EmulatorTexts } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; // emulator_settings: PK is the string column `key`, payload is `value` (VarChar 512). @@ -10,8 +12,40 @@ import { PERMS } from "@/lib/permissions"; // Both tables are emulator-owned; we only ever read/update existing rows or add new // keys via upsert. We never migrate or drop them. +function mutationContext(staff: { + id: number; + rank: number; + username: string; +}) { + const matches = (slug: string) => slug === PERMS.SETTINGS_EDIT; + return { + capability: createHousekeepingCapabilityContext(staff, { + isSuperAdmin: false, + has: matches, + hasAny: (...slugs: string[]) => slugs.some(matches), + hasAll: (...slugs: string[]) => slugs.every(matches), + }), + correlationId: createCorrelationId(), + }; +} + +async function requireMutation( + staff: { id: number; rank: number; username: string }, + operation: + | "configuration.emulator-setting.update" + | "configuration.emulator-text.update", + input: { key: string; value: string }, +): Promise { + const result = await systemMutationService.execute( + mutationContext(staff), + operation, + input, + ); + if (!result.ok) throw new Error(result.error.messageKey); +} + export async function updateEmulatorSetting(formData: FormData): Promise { - await requirePermission(PERMS.SETTINGS_EDIT); + const staff = await requirePermission(PERMS.SETTINGS_EDIT); const key = String(formData.get("key") ?? "") .normalize("NFC") .trim() @@ -20,15 +54,15 @@ export async function updateEmulatorSetting(formData: FormData): Promise { .normalize("NFC") .slice(0, 512); if (!key) return; - await db - .insert(EmulatorSettings) - .values({ key, value }) - .onDuplicateKeyUpdate({ set: { value } }); + await requireMutation(staff, "configuration.emulator-setting.update", { + key, + value, + }); revalidatePath("/admin/emulator"); } export async function updateEmulatorText(formData: FormData): Promise { - await requirePermission(PERMS.SETTINGS_EDIT); + const staff = await requirePermission(PERMS.SETTINGS_EDIT); const key = String(formData.get("key") ?? "") .normalize("NFC") .trim() @@ -37,9 +71,9 @@ export async function updateEmulatorText(formData: FormData): Promise { .normalize("NFC") .slice(0, 4096); if (!key) return; - await db - .insert(EmulatorTexts) - .values({ key, value }) - .onDuplicateKeyUpdate({ set: { value } }); + await requireMutation(staff, "configuration.emulator-text.update", { + key, + value, + }); revalidatePath("/admin/emulator"); } diff --git a/src/actions/admin-maintenance.ts b/src/actions/admin-maintenance.ts index 25716581..ab947593 100644 --- a/src/actions/admin-maintenance.ts +++ b/src/actions/admin-maintenance.ts @@ -1,10 +1,11 @@ "use server"; import { revalidatePath } from "next/cache"; +import { systemMutationService } from "@/features/housekeeping/domains/system/services/mutations"; +import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context"; +import { createCorrelationId } from "@/features/housekeeping/foundation/correlation"; import { requirePermission } from "@/lib/admin/guard"; -import { db, WebsiteSetting } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -import { siteSettings } from "@/lib/services/site-settings"; // Maintenance mode lives in three CMS-owned website_settings rows (mirrors // AtomCMS's MaintenanceToggle Livewire component): @@ -14,32 +15,25 @@ import { siteSettings } from "@/lib/services/site-settings"; // The Laravel login flow reads these via setting() to gate non-staff logins // while maintenance is on, so the website_settings keys are the source of truth. -const KEY_ENABLED = "maintenance_enabled"; -const KEY_MESSAGE = "maintenance_message"; -const KEY_MIN_RANK = "min_maintenance_login_rank"; - -const COMMENTS: Record = { - [KEY_ENABLED]: "Determines whether maintenance is enabled or not", - [KEY_MESSAGE]: - "The maintenance message displayed to users while maintenance is activated", - [KEY_MIN_RANK]: - "The minimum rank required to login to the hotel during maintenance", -}; - -async function upsertSetting(key: string, value: string): Promise { - await db - .insert(WebsiteSetting) - .values({ - key, - value, - // eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values - comment: COMMENTS[key] ?? null, - }) - .onDuplicateKeyUpdate({ set: { value } }); +function mutationContext(staff: { + id: number; + rank: number; + username: string; +}) { + const matches = (slug: string) => slug === PERMS.SETTINGS_EDIT; + return { + capability: createHousekeepingCapabilityContext(staff, { + isSuperAdmin: false, + has: matches, + hasAny: (...slugs: string[]) => slugs.some(matches), + hasAll: (...slugs: string[]) => slugs.every(matches), + }), + correlationId: createCorrelationId(), + }; } export async function saveMaintenance(formData: FormData): Promise { - await requirePermission(PERMS.SETTINGS_EDIT); + const staff = await requirePermission(PERMS.SETTINGS_EDIT); // Checkbox: present only when ticked. Normalise to the '1'/'0' string the // emulator/Laravel side expects. @@ -56,10 +50,15 @@ export async function saveMaintenance(formData: FormData): Promise { const minRank = Number.isFinite(parsedRank) && parsedRank >= 0 ? parsedRank : 5; - await upsertSetting(KEY_ENABLED, enabled); - await upsertSetting(KEY_MESSAGE, message); - await upsertSetting(KEY_MIN_RANK, String(minRank)); - - siteSettings.reload(); + const result = await systemMutationService.execute( + mutationContext(staff), + "operations.maintenance.update", + { + enabled: enabled === "1", + message, + minimumLoginRank: minRank, + }, + ); + if (!result.ok) throw new Error(result.error.messageKey); revalidatePath("/admin/maintenance"); } diff --git a/src/actions/admin-settings.ts b/src/actions/admin-settings.ts index 27a5396c..f132c40e 100644 --- a/src/actions/admin-settings.ts +++ b/src/actions/admin-settings.ts @@ -1,20 +1,23 @@ "use server"; -import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { MANAGED_SETTING_KEYS } from "@/app/admin/settings/cms-settings-config"; +import { + type SystemMutationContext, + type SystemMutationOperation, + systemMutationService, +} from "@/features/housekeeping/domains/system/services/mutations"; +import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context"; +import { createCorrelationId } from "@/features/housekeeping/foundation/correlation"; import { requirePermissionRateLimited } from "@/lib/admin/guard"; -import { db, WebsiteSetting } from "@/lib/db"; import { actionOk, adminAction } from "@/lib/foundation/action"; +import type { AdminActionContext } from "@/lib/foundation/types"; import { HABBO_GAMEDATA_HOTEL_SETTING_KEY, normalizeHabboGamedataHotel, } from "@/lib/habbo-gamedata-hotel"; import { PERMS } from "@/lib/permissions"; -import { clearOfficialHabboFurnidataCache } from "@/lib/services/habbo-furnidata-cache"; -import { clearBadgeCache } from "@/lib/services/habboassets"; -import { siteSettings } from "@/lib/services/site-settings"; const managedKeySet = new Set(MANAGED_SETTING_KEYS); @@ -25,11 +28,47 @@ function normalizeSettingValue(key: string, value: string): string { return value; } -function bustGamedataCachesIfNeeded(key: string): void { - if (key === HABBO_GAMEDATA_HOTEL_SETTING_KEY) { - clearOfficialHabboFurnidataCache(); - clearBadgeCache(); - } +function actionMutationContext( + ctx: Pick, +): SystemMutationContext { + return { + capability: createHousekeepingCapabilityContext( + { + id: Number(ctx.session.user.id), + rank: Number(ctx.session.user.rank), + username: ctx.session.user.username, + }, + ctx.permissions, + ), + correlationId: String(ctx.requestId), + }; +} + +function checkedMutationContext(staff: { + id: number; + rank: number; + username: string; +}): SystemMutationContext { + const matches = (slug: string) => slug === PERMS.SETTINGS_EDIT; + return { + capability: createHousekeepingCapabilityContext(staff, { + isSuperAdmin: false, + has: matches, + hasAny: (...slugs) => slugs.some(matches), + hasAll: (...slugs) => slugs.every(matches), + }), + correlationId: createCorrelationId(), + }; +} + +async function runMutation( + context: SystemMutationContext, + operation: SystemMutationOperation, + input: unknown, +): Promise { + const result = await systemMutationService.execute(context, operation, input); + if (!result.ok) throw new Error(result.error.messageKey); + return result.data; } const saveManagedSchema = z.object({ @@ -47,27 +86,19 @@ export const saveManagedSettings = adminAction( const entries = Object.entries(ctx.data.settings) .filter(([key]) => managedKeySet.has(key)) .map(([key, value]) => [key, normalizeSettingValue(key, value)] as const); - await Promise.all( - entries.map(([key, value]) => - db - .insert(WebsiteSetting) - .values({ key, value }) - .onDuplicateKeyUpdate({ set: { value } }), - ), - ); - await siteSettings.reload(); - if (entries.some(([key]) => key === HABBO_GAMEDATA_HOTEL_SETTING_KEY)) { - clearOfficialHabboFurnidataCache(); - clearBadgeCache(); - } + const mutation = (await runMutation( + actionMutationContext(ctx), + "configuration.settings.save", + { settings: Object.fromEntries(entries) }, + )) as { saved: number }; revalidatePath("/admin/settings"); revalidatePath("/admin/catalog"); - return actionOk({ saved: entries.length }); + return actionOk({ saved: mutation.saved }); }, ); export async function updateSetting(formData: FormData): Promise { - await requirePermissionRateLimited(PERMS.SETTINGS_EDIT); + const staff = await requirePermissionRateLimited(PERMS.SETTINGS_EDIT); const key = String(formData.get("key") ?? "") .normalize("NFC") .trim(); @@ -76,17 +107,16 @@ export async function updateSetting(formData: FormData): Promise { String(formData.get("value") ?? "").normalize("NFC"), ); if (!key) return; - await db - .insert(WebsiteSetting) - .values({ key, value }) - .onDuplicateKeyUpdate({ set: { value } }); - await siteSettings.reload(); - bustGamedataCachesIfNeeded(key); + await runMutation( + checkedMutationContext(staff), + "configuration.setting.update", + { key, value }, + ); revalidatePath("/admin/settings"); } export async function createSetting(formData: FormData): Promise { - await requirePermissionRateLimited(PERMS.SETTINGS_EDIT); + const staff = await requirePermissionRateLimited(PERMS.SETTINGS_EDIT); const key = String(formData.get("key") ?? "") .normalize("NFC") .trim() @@ -100,23 +130,24 @@ export async function createSetting(formData: FormData): Promise { .trim() .slice(0, 255); if (!key) return; - await db - .insert(WebsiteSetting) - .values({ key, value, comment: comment || null }) - .onDuplicateKeyUpdate({ set: { value } }); - await siteSettings.reload(); - bustGamedataCachesIfNeeded(key); + await runMutation( + checkedMutationContext(staff), + "configuration.setting.create", + { key, value, comment }, + ); revalidatePath("/admin/settings"); } export async function deleteSetting(formData: FormData): Promise { - await requirePermissionRateLimited(PERMS.SETTINGS_EDIT); + const staff = await requirePermissionRateLimited(PERMS.SETTINGS_EDIT); const key = String(formData.get("key") ?? "") .normalize("NFC") .trim(); if (!key) return; - await db.delete(WebsiteSetting).where(eq(WebsiteSetting.key, key)); - await siteSettings.reload(); - bustGamedataCachesIfNeeded(key); + await runMutation( + checkedMutationContext(staff), + "configuration.setting.delete", + { key }, + ); revalidatePath("/admin/settings"); } diff --git a/src/actions/commandocentrum.ts b/src/actions/commandocentrum.ts index 025c497a..bb580dfd 100644 --- a/src/actions/commandocentrum.ts +++ b/src/actions/commandocentrum.ts @@ -1,48 +1,96 @@ "use server"; -import { eq, sql } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { z } from "zod"; -import { db, User } from "@/lib/db"; +import { + type SystemMutationContext, + type SystemMutationOperation, + systemMutationService, +} from "@/features/housekeeping/domains/system/services/mutations"; +import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context"; +import type { AdminActionContext } from "@/lib/foundation/types"; import { PERMS } from "@/lib/permissions"; import { adminAction } from "@/lib/safe-action"; import { ActionError, actionOk } from "@/lib/safe-action-shared"; -import { rcon } from "@/lib/services/rcon"; const PATH = "/admin/commandocentrum"; - const RCON_FAIL = "RCON command failed. Is the emulator running?"; -async function requireRconOk(ok: boolean): Promise { - if (!ok) throw new ActionError(RCON_FAIL); +function mutationContext( + ctx: Pick, +): SystemMutationContext { + return { + capability: createHousekeepingCapabilityContext( + { + id: Number(ctx.session.user.id), + rank: Number(ctx.session.user.rank), + username: ctx.session.user.username, + }, + ctx.permissions, + ), + correlationId: String(ctx.requestId), + }; +} + +async function runRconMutation( + ctx: Pick, + operation: SystemMutationOperation, + input: unknown, +): Promise { + const result = await systemMutationService.execute( + mutationContext(ctx), + operation, + input, + ); + if (result.ok) return; + if (result.error.messageKey === "errors.housekeeping.system.userNotFound") { + throw new ActionError("User not found"); + } + if (result.error.messageKey === "errors.housekeeping.system.rankNotFound") { + throw new ActionError("Rank does not exist"); + } + if ( + result.error.messageKey === + "errors.housekeeping.system.cannotChangePeerRank" + ) { + throw new ActionError("Cannot change rank of a user at or above your rank"); + } + if ( + result.error.messageKey === + "errors.housekeeping.system.cannotAssignPeerRank" + ) { + throw new ActionError("Cannot set a rank equal to or above your own"); + } + throw new ActionError(RCON_FAIL); +} + +function revalidate(): void { + revalidatePath(PATH); } -/** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */ export const updateCatalog = adminAction( { permission: PERMS.RCON_EXECUTE }, - async () => { - await requireRconOk(await rcon.updateCatalog()); - revalidatePath(PATH); + async (ctx) => { + await runRconMutation(ctx, "rcon.update-catalog", {}); + revalidate(); return actionOk(); }, ); -/** Reload the chat word filter on the emulator (rcon: updatewordfilter). */ export const updateWordFilter = adminAction( { permission: PERMS.RCON_EXECUTE }, - async () => { - await requireRconOk(await rcon.updateWordFilter()); - revalidatePath(PATH); + async (ctx) => { + await runRconMutation(ctx, "rcon.update-word-filter", {}); + revalidate(); return actionOk(); }, ); -/** Reload navigator data on the emulator (rcon: updatenavigator, no payload). */ export const updateNavigator = adminAction( { permission: PERMS.RCON_EXECUTE }, - async () => { - await requireRconOk(await rcon.send("updatenavigator", null)); - revalidatePath(PATH); + async (ctx) => { + await runRconMutation(ctx, "rcon.update-navigator", {}); + revalidate(); return actionOk(); }, ); @@ -51,13 +99,13 @@ const hotelAlertSchema = z.object({ message: z.string().trim().min(1).max(512), }); -/** Broadcast a hotel-wide alert to every connected user (rcon: hotelalert). */ export const hotelAlert = adminAction( { permission: PERMS.RCON_EXECUTE, schema: hotelAlertSchema }, async (ctx) => { - const message = ctx.data.message.normalize("NFC"); - await requireRconOk(await rcon.send("hotelalert", { message })); - revalidatePath(PATH); + await runRconMutation(ctx, "rcon.hotel-alert", { + message: ctx.data.message.normalize("NFC"), + }); + revalidate(); return actionOk(); }, ); @@ -67,13 +115,14 @@ const disconnectSchema = z.object({ username: z.string().trim().min(1), }); -/** Disconnect/kick a user from the hotel (rcon: disconnect). */ export const disconnectUser = adminAction( { permission: PERMS.RCON_EXECUTE, schema: disconnectSchema }, async (ctx) => { - const username = ctx.data.username.normalize("NFC"); - await requireRconOk(await rcon.disconnectUser(ctx.data.userId, username)); - revalidatePath(PATH); + await runRconMutation(ctx, "rcon.disconnect-user", { + userId: ctx.data.userId, + username: ctx.data.username.normalize("NFC"), + }); + revalidate(); return actionOk(); }, ); @@ -83,13 +132,14 @@ const alertUserSchema = z.object({ message: z.string().trim().min(1).max(512), }); -/** Send an alert to a specific user (rcon: alertuser). */ export const alertUser = adminAction( { permission: PERMS.RCON_EXECUTE, schema: alertUserSchema }, async (ctx) => { - const message = ctx.data.message.normalize("NFC"); - await requireRconOk(await rcon.alertUser(ctx.data.userId, message)); - revalidatePath(PATH); + await runRconMutation(ctx, "rcon.alert-user", { + userId: ctx.data.userId, + message: ctx.data.message.normalize("NFC"), + }); + revalidate(); return actionOk(); }, ); @@ -99,14 +149,11 @@ const forwardUserSchema = z.object({ roomId: z.coerce.number().int().positive(), }); -/** Forward a user to a specific room (rcon: forwarduser). */ export const forwardUser = adminAction( { permission: PERMS.RCON_EXECUTE, schema: forwardUserSchema }, async (ctx) => { - await requireRconOk( - await rcon.forwardUser(ctx.data.userId, ctx.data.roomId), - ); - revalidatePath(PATH); + await runRconMutation(ctx, "rcon.forward-user", ctx.data); + revalidate(); return actionOk(); }, ); @@ -116,14 +163,14 @@ const giveCreditsSchema = z.object({ credits: z.coerce.number().int().positive(), }); -/** Give credits to a user (rcon: givecredits). */ export const giveCredits = adminAction( { permission: PERMS.RCON_EXECUTE, schema: giveCreditsSchema }, async (ctx) => { - await requireRconOk( - await rcon.giveCredits(ctx.data.userId, ctx.data.credits), - ); - revalidatePath(PATH); + await runRconMutation(ctx, "rcon.give-credits", { + userId: ctx.data.userId, + amount: ctx.data.credits, + }); + revalidate(); return actionOk(); }, ); @@ -133,26 +180,20 @@ const giveAmountSchema = z.object({ amount: z.coerce.number().int().positive(), }); -/** Give duckets to a user (rcon: givepoints type=duckets). */ export const giveDuckets = adminAction( { permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema }, async (ctx) => { - await requireRconOk( - await rcon.giveDuckets(ctx.data.userId, ctx.data.amount), - ); - revalidatePath(PATH); + await runRconMutation(ctx, "rcon.give-duckets", ctx.data); + revalidate(); return actionOk(); }, ); -/** Give diamonds to a user (rcon: givepoints type=diamonds). */ export const giveDiamonds = adminAction( { permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema }, async (ctx) => { - await requireRconOk( - await rcon.giveDiamonds(ctx.data.userId, ctx.data.amount), - ); - revalidatePath(PATH); + await runRconMutation(ctx, "rcon.give-diamonds", ctx.data); + revalidate(); return actionOk(); }, ); @@ -162,13 +203,14 @@ const giveBadgeSchema = z.object({ badge: z.string().trim().min(1).max(32), }); -/** Give a badge to a user (rcon: givebadge). */ export const giveBadge = adminAction( { permission: PERMS.RCON_EXECUTE, schema: giveBadgeSchema }, async (ctx) => { - const badge = ctx.data.badge.normalize("NFC"); - await requireRconOk(await rcon.giveBadge(ctx.data.userId, badge)); - revalidatePath(PATH); + await runRconMutation(ctx, "rcon.give-badge", { + userId: ctx.data.userId, + badge: ctx.data.badge.normalize("NFC"), + }); + revalidate(); return actionOk(); }, ); @@ -178,13 +220,14 @@ const setMottoSchema = z.object({ motto: z.string().trim().min(1).max(127), }); -/** Set a user's motto (rcon: setmotto). */ export const setMotto = adminAction( { permission: PERMS.RCON_EXECUTE, schema: setMottoSchema }, async (ctx) => { - const motto = ctx.data.motto.normalize("NFC"); - await requireRconOk(await rcon.setMotto(ctx.data.userId, motto)); - revalidatePath(PATH); + await runRconMutation(ctx, "rcon.set-motto", { + userId: ctx.data.userId, + motto: ctx.data.motto.normalize("NFC"), + }); + revalidate(); return actionOk(); }, ); @@ -194,47 +237,11 @@ const setRankSchema = z.object({ rank: z.coerce.number().int().min(1).max(9999), }); -/** Set a user's rank (rcon: setrank). */ export const setRank = adminAction( { permission: PERMS.RCON_EXECUTE, schema: setRankSchema }, async (ctx) => { - const staffRank = Number(ctx.session.user.rank); - const isSuper = ctx.permissions.isSuperAdmin; - const [target] = await db - .select({ rank: User.rank }) - .from(User) - .where(eq(User.id, ctx.data.userId)) - .limit(1); - if (!target) throw new ActionError("User not found"); - - let rankExists: { id: number }[] = []; - try { - const [rows] = await db.execute( - sql`SELECT id FROM permission_ranks WHERE id = ${ctx.data.rank} LIMIT 1`, - ); - rankExists = rows as unknown as { id: number }[]; - } catch { - rankExists = []; - } - if (rankExists.length === 0) throw new ActionError("Rank does not exist"); - - if (!isSuper) { - if (target.rank >= staffRank) { - throw new ActionError( - "Cannot change rank of a user at or above your rank", - ); - } - if (ctx.data.rank >= staffRank) { - throw new ActionError("Cannot set a rank equal to or above your own"); - } - } - - await requireRconOk(await rcon.setRank(ctx.data.userId, ctx.data.rank)); - await db - .update(User) - .set({ rank: ctx.data.rank }) - .where(eq(User.id, ctx.data.userId)); - revalidatePath(PATH); + await runRconMutation(ctx, "rcon.set-rank", ctx.data); + revalidate(); return actionOk(); }, ); @@ -244,13 +251,14 @@ const executeCommandSchema = z.object({ command: z.string().trim().min(1).max(100), }); -/** Execute a command as a user (rcon: executecommand). */ export const executeCommand = adminAction( { permission: PERMS.RCON_EXECUTE, schema: executeCommandSchema }, async (ctx) => { - const command = ctx.data.command.normalize("NFC"); - await requireRconOk(await rcon.executeCommand(ctx.data.userId, command)); - revalidatePath(PATH); + await runRconMutation(ctx, "rcon.execute-command", { + userId: ctx.data.userId, + command: ctx.data.command.normalize("NFC"), + }); + revalidate(); return actionOk(); }, ); @@ -261,15 +269,15 @@ const sendGiftSchema = z.object({ message: z.string().trim().max(255).optional().default("Here is a gift."), }); -/** Send a gift to a user (rcon: sendgift). */ export const sendGift = adminAction( { permission: PERMS.RCON_EXECUTE, schema: sendGiftSchema }, async (ctx) => { - const message = ctx.data.message.trim().slice(0, 255) || "Here is a gift."; - await requireRconOk( - await rcon.sendGift(ctx.data.userId, ctx.data.itemId, message), - ); - revalidatePath(PATH); + await runRconMutation(ctx, "rcon.send-gift", { + userId: ctx.data.userId, + itemId: ctx.data.itemId, + message: ctx.data.message.trim().slice(0, 255) || "Here is a gift.", + }); + revalidate(); return actionOk(); }, ); diff --git a/src/actions/permissions.ts b/src/actions/permissions.ts index ac11d18c..f1ad61df 100644 --- a/src/actions/permissions.ts +++ b/src/actions/permissions.ts @@ -1,27 +1,56 @@ "use server"; -import { and, count, eq, inArray, sql } from "drizzle-orm"; -import type { ResultSetHeader } from "mysql2"; import { revalidateTag } from "next/cache"; import { z } from "zod"; import { - AclModelPermission, - AclModelRole, - AclPermission, - AclRole, - db, - User, -} from "@/lib/db"; + type SystemMutationContext, + type SystemMutationOperation, + systemMutationService, +} from "@/features/housekeeping/domains/system/services/mutations"; +import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context"; +import type { AdminActionContext } from "@/lib/foundation/types"; import { PERMS } from "@/lib/permission-slugs"; import { adminAction } from "@/lib/safe-action"; import { ActionError, actionOk } from "@/lib/safe-action-shared"; -import { - createEmulatorRank, - deleteEmulatorRank, - updateEmulatorRank, -} from "@/lib/services/permission-ranks"; -import { rcon } from "@/lib/services/rcon"; -import { logStaffActivity } from "@/lib/services/staff-activity"; + +function mutationContext( + ctx: Pick, +): SystemMutationContext { + return { + capability: createHousekeepingCapabilityContext( + { + id: Number(ctx.session.user.id), + rank: Number(ctx.session.user.rank), + username: ctx.session.user.username, + }, + ctx.permissions, + ), + correlationId: String(ctx.requestId), + }; +} + +async function runAccessMutation( + ctx: Pick, + operation: SystemMutationOperation, + input: unknown, +): Promise { + const result = await systemMutationService.execute( + mutationContext(ctx), + operation, + input, + ); + if (result.ok) return result.data; + if (result.error.messageKey === "errors.housekeeping.system.rankInUse") { + const users = Number(result.error.fieldErrors?.rank?.[0]); + if (Number.isInteger(users) && users > 0) { + throw new ActionError(`Cannot delete: ${users} users have this rank`); + } + } + if (result.error.messageKey === "errors.housekeeping.system.roleNotFound") { + throw new ActionError("Role not found"); + } + throw new ActionError(result.error.messageKey); +} const createRankSchema = z.object({ rank_name: z.string().trim().min(1).max(25), @@ -31,25 +60,12 @@ const createRankSchema = z.object({ export const createRank = adminAction( { schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE }, async (ctx) => { - const id = await createEmulatorRank(db, ctx.data); - await db - .insert(AclRole) - .values({ - slug: `rank_${id}`, - title: ctx.data.rank_name, - description: "CMS role synchronized from permission_ranks", - }) - .onDuplicateKeyUpdate({ set: { title: ctx.data.rank_name } }); - await logStaffActivity({ - staffId: ctx.session.user.id, - action: "rank_create", - description: `Created rank #${id}`, - targetType: "rank", - targetId: id, - }); - await rcon.send("updatepermissions"); + const result = (await runAccessMutation(ctx, "access.rank.create", { + name: ctx.data.rank_name, + level: ctx.data.level, + })) as { id: number }; revalidateTag("permissions", { expire: 0 }); - return actionOk({ id }); + return actionOk({ id: result.id }); }, ); @@ -58,41 +74,7 @@ const deleteRankSchema = z.object({ id: z.coerce.number().int().positive() }); export const deleteRank = adminAction( { schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE }, async (ctx) => { - const [userCount] = await db - .select({ total: count() }) - .from(User) - .where(eq(User.rank, ctx.data.id)); - const users = userCount?.total ?? 0; - if (users > 0) - throw new ActionError(`Cannot delete: ${users} users have this rank`); - const [role] = await db - .select({ id: AclRole.id }) - .from(AclRole) - .where(eq(AclRole.slug, `rank_${ctx.data.id}`)) - .limit(1); - await deleteEmulatorRank(db, ctx.data.id); - if (role) { - await db.transaction(async (tx) => { - await tx - .delete(AclModelPermission) - .where( - and( - eq(AclModelPermission.modelId, role.id), - eq(AclModelPermission.modelType, "Role"), - ), - ); - await tx.delete(AclModelRole).where(eq(AclModelRole.roleId, role.id)); - await tx.delete(AclRole).where(eq(AclRole.id, role.id)); - }); - } - await logStaffActivity({ - staffId: ctx.session.user.id, - action: "rank_delete", - description: `Deleted rank #${ctx.data.id}`, - targetType: "rank", - targetId: ctx.data.id, - }); - await rcon.send("updatepermissions"); + await runAccessMutation(ctx, "access.rank.delete", ctx.data); revalidateTag("permissions", { expire: 0 }); return actionOk(); }, @@ -106,21 +88,7 @@ const saveRankSchema = z.object({ export const saveRank = adminAction( { schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE }, async (ctx) => { - await updateEmulatorRank(db, ctx.data.id, ctx.data.fields); - if (typeof ctx.data.fields.rank_name === "string") { - await db - .update(AclRole) - .set({ title: ctx.data.fields.rank_name }) - .where(eq(AclRole.slug, `rank_${ctx.data.id}`)); - } - await logStaffActivity({ - staffId: ctx.session.user.id, - action: "rank_update", - description: `Updated rank #${ctx.data.id}`, - targetType: "rank", - targetId: ctx.data.id, - }); - await rcon.send("updatepermissions"); + await runAccessMutation(ctx, "access.rank.update", ctx.data); revalidateTag("permissions", { expire: 0 }); return actionOk(); }, @@ -134,138 +102,21 @@ const setCmsPermsSchema = z.object({ export const setCmsPermissions = adminAction( { schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE }, async (ctx) => { - const [role] = await db - .select({ id: AclRole.id, slug: AclRole.slug }) - .from(AclRole) - .where(eq(AclRole.id, ctx.data.roleId)) - .limit(1); - if (!role) throw new ActionError("Role not found"); - const permissions = await db - .select({ id: AclPermission.id }) - .from(AclPermission) - .where(inArray(AclPermission.slug, ctx.data.permissionSlugs)); - await db.transaction(async (tx) => { - await tx - .delete(AclModelPermission) - .where( - and( - eq(AclModelPermission.modelId, role.id), - eq(AclModelPermission.modelType, "Role"), - ), - ); - if (permissions.length) { - await tx.insert(AclModelPermission).values( - permissions.map((permission) => ({ - modelId: role.id, - modelType: "Role", - permissionId: permission.id, - })), - ); - } - }); - await logStaffActivity({ - staffId: ctx.session.user.id, - action: "acl_role_permissions_update", - description: `Updated ${permissions.length} permissions for ${role.slug}`, - targetType: "acl_role", - targetId: role.id, - }); + await runAccessMutation(ctx, "access.permissions.update", ctx.data); revalidateTag("permissions", { expire: 0 }); return actionOk(); }, ); -/** - * Re-apply the same grant repair as migration 0018: - * - ranks with admin.dashboard get all admin.* - * - ranks >= 6 get admin.*.view + dashboard - * - ranks >= 7 get edit/manage/execute tools used by the sidebar - */ export const repairAdminNavAclGrants = adminAction( { permission: PERMS.PERMISSIONS_MANAGE }, async (ctx) => { - const [dashboardFillResult] = await db.execute(sql` - INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`) - SELECT 'Role', ar.id, ap.id - FROM \`acl_roles\` ar - JOIN \`acl_permissions\` ap ON ap.slug LIKE 'admin.%' - WHERE EXISTS ( - SELECT 1 - FROM \`acl_model_permissions\` amp - JOIN \`acl_permissions\` apdash ON apdash.id = amp.permission_id - WHERE amp.model_type = 'Role' - AND amp.model_id = ar.id - AND apdash.slug = 'admin.dashboard' - ) - AND NOT EXISTS ( - SELECT 1 - FROM \`acl_model_permissions\` amp2 - WHERE amp2.model_type = 'Role' - AND amp2.model_id = ar.id - AND amp2.permission_id = ap.id - ) - `); - - const [midRankViewsResult] = await db.execute(sql` - INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`) - SELECT 'Role', ar.id, ap.id - FROM \`permission_ranks\` pr - JOIN \`acl_roles\` ar ON ar.slug = CONCAT('rank_', pr.id) - JOIN \`acl_permissions\` ap ON ( - ap.slug = 'admin.dashboard' - OR (ap.slug LIKE 'admin.%' AND ap.slug LIKE '%.view') - ) - WHERE pr.id >= 6 - AND NOT EXISTS ( - SELECT 1 - FROM \`acl_model_permissions\` amp - WHERE amp.model_type = 'Role' - AND amp.model_id = ar.id - AND amp.permission_id = ap.id - ) - `); - - const [highRankToolsResult] = await db.execute(sql` - INSERT INTO \`acl_model_permissions\` (\`model_type\`, \`model_id\`, \`permission_id\`) - SELECT 'Role', ar.id, ap.id - FROM \`permission_ranks\` pr - JOIN \`acl_roles\` ar ON ar.slug = CONCAT('rank_', pr.id) - JOIN \`acl_permissions\` ap ON ( - (ap.slug LIKE 'admin.%' AND ap.slug LIKE '%.edit') - OR ap.slug IN ( - 'admin.permissions.manage', - 'admin.rcon.execute', - 'admin.assets.import', - 'admin.export', - 'admin.analytics.export', - 'admin.users.ban', - 'admin.users.reset_password', - 'admin.room.delete' - ) - ) - WHERE pr.id >= 7 - AND NOT EXISTS ( - SELECT 1 - FROM \`acl_model_permissions\` amp - WHERE amp.model_type = 'Role' - AND amp.model_id = ar.id - AND amp.permission_id = ap.id - ) - `); - - const inserted = - Number((dashboardFillResult as ResultSetHeader).affectedRows) + - Number((midRankViewsResult as ResultSetHeader).affectedRows) + - Number((highRankToolsResult as ResultSetHeader).affectedRows); - - await logStaffActivity({ - staffId: ctx.session.user.id, - action: "acl_nav_grants_repair", - description: `Repaired admin nav ACL grants (${inserted} rows inserted)`, - targetType: "acl", - targetId: 0, - }); + const result = (await runAccessMutation( + ctx, + "access.permissions.repair", + {}, + )) as { inserted: number }; revalidateTag("permissions", { expire: 0 }); - return actionOk({ inserted }); + return actionOk({ inserted: result.inserted }); }, ); diff --git a/src/features/housekeeping/domains/system/commands/system-commands.test.ts b/src/features/housekeeping/domains/system/commands/system-commands.test.ts new file mode 100644 index 00000000..29a85c57 --- /dev/null +++ b/src/features/housekeeping/domains/system/commands/system-commands.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it, vi } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +import { confirmHousekeepingCommand } from "../../../foundation/commands/confirmation"; +import type { HousekeepingCommand } from "../../../foundation/commands/registry"; +import type { HousekeepingCapabilityContext } from "../../../foundation/contracts"; +import { + createSystemMutationService, + type SystemMutationAdapter, +} from "../services/mutations"; +import { + createSystemCommands, + SYSTEM_COMMAND_IDS, + SYSTEM_COMMANDS, +} from "./system-commands"; + +const expectedCommandIds = [ + "system.access.rank.create", + "system.access.rank.delete", + "system.access.rank.update", + "system.access.permissions.update", + "system.access.permissions.repair", + "system.configuration.settings.save", + "system.configuration.setting.create", + "system.configuration.setting.update", + "system.configuration.setting.delete", + "system.configuration.emulator-setting.update", + "system.configuration.emulator-text.update", + "system.operations.alerts.mark-read", + "system.operations.alert.broadcast", + "system.operations.rcon.update-catalog", + "system.operations.rcon.update-word-filter", + "system.operations.rcon.update-navigator", + "system.operations.rcon.hotel-alert", + "system.operations.rcon.disconnect-user", + "system.operations.rcon.alert-user", + "system.operations.rcon.forward-user", + "system.operations.rcon.give-credits", + "system.operations.rcon.give-duckets", + "system.operations.rcon.give-diamonds", + "system.operations.rcon.give-badge", + "system.operations.rcon.set-motto", + "system.operations.rcon.set-rank", + "system.operations.rcon.execute-command", + "system.operations.rcon.send-gift", + "system.operations.maintenance.update", +] as const; + +const reasonRequiredIds = expectedCommandIds.filter( + (id) => + id.startsWith("system.access.") || + id.startsWith("system.configuration.setting") || + id === "system.configuration.settings.save" || + id === "system.operations.alert.broadcast" || + id.startsWith("system.operations.rcon.") || + id === "system.operations.maintenance.update", +); + +const iterableSystemCommands = + SYSTEM_COMMANDS as unknown as readonly HousekeepingCommand< + unknown, + unknown + >[]; + +function capabilityContext( + granted: readonly string[], +): HousekeepingCapabilityContext { + const permissions = new Set(granted); + return { + actor: { id: 42, username: "operator", rank: 500 }, + isSuperAdmin: false, + has: (slug) => permissions.has(slug), + hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)), + hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)), + }; +} + +describe("SYSTEM_COMMANDS", () => { + it("declares the complete deterministic command list", () => { + expect(SYSTEM_COMMAND_IDS).toEqual(expectedCommandIds); + expect(iterableSystemCommands.map((command) => command.id)).toEqual( + expectedCommandIds, + ); + expect( + iterableSystemCommands.every((command) => command.risk === "sensitive"), + ).toBe(true); + }); + + it("requires confirmation reasons for access, global settings, RCON, and maintenance", () => { + expect( + iterableSystemCommands + .filter((command) => command.requiresReason) + .map((command) => command.id), + ).toEqual(reasonRequiredIds); + + for (const command of iterableSystemCommands.filter( + (command) => command.requiresReason, + )) { + expect( + confirmHousekeepingCommand(command, " ", "correlation-test"), + ).toMatchObject({ + ok: false, + error: { + code: "VALIDATION", + fieldErrors: { reason: ["errors.validation.required"] }, + }, + }); + } + }); + + it("uses the authoritative mutation capability per command family", () => { + const byId = new Map( + iterableSystemCommands.map((command) => [command.id, command]), + ); + expect(byId.get("system.access.rank.create")?.capability.slugs).toEqual([ + PERMS.PERMISSIONS_MANAGE, + ]); + expect( + byId.get("system.configuration.setting.update")?.capability.slugs, + ).toEqual([PERMS.SETTINGS_EDIT]); + expect( + byId.get("system.operations.alerts.mark-read")?.capability.slugs, + ).toEqual([PERMS.NOTIFICATIONS_VIEW]); + expect( + byId.get("system.operations.alert.broadcast")?.capability.slugs, + ).toEqual([PERMS.NOTIFICATIONS_EDIT]); + expect( + byId.get("system.operations.rcon.update-catalog")?.capability.slugs, + ).toEqual([PERMS.RCON_EXECUTE]); + }); + + it.each([ + ["system.access.rank.create", { name: " ", level: 3 }], + ["system.operations.alert.broadcast", { message: " " }], + ["system.operations.rcon.disconnect-user", { userId: 7, username: " " }], + ["system.operations.rcon.give-badge", { userId: 7, badge: " " }], + ["system.operations.rcon.set-motto", { userId: 7, motto: " " }], + ["system.operations.rcon.execute-command", { userId: 7, command: " " }], + ] as const)("rejects whitespace-only text for %s", (commandId, input) => { + const command = iterableSystemCommands.find( + (entry) => entry.id === commandId, + ); + if (!command) throw new Error(`command missing: ${commandId}`); + + expect(command.input.safeParse(input).success).toBe(false); + }); + + it("delegates parsed command input to the shared mutation service", async () => { + const service = { + execute: vi.fn(async (context, operation, input) => ({ + ok: true as const, + data: { context, operation, input }, + correlationId: context.correlationId, + })), + }; + const commands = createSystemCommands( + service, + ) as unknown as readonly HousekeepingCommand[]; + const command = commands.find( + (entry) => entry.id === "system.operations.rcon.give-credits", + ); + if (!command) throw new Error("command missing"); + const parsed = command.input.parse({ userId: 7, amount: 25 }); + const result = await command.execute( + { + capability: capabilityContext([PERMS.RCON_EXECUTE]), + correlationId: "command-correlation", + ipAddress: "198.51.100.8", + }, + parsed, + ); + + expect(result).toMatchObject({ + ok: true, + data: { + operation: "rcon.give-credits", + input: { userId: 7, amount: 25 }, + }, + correlationId: "command-correlation", + }); + }); +}); + +describe("System mutation service boundary", () => { + it("returns FORBIDDEN without invoking the adapter when permission is absent", async () => { + const execute = vi.fn(async () => ({ saved: true })); + const service = createSystemMutationService({ execute }); + + const result = await service.execute( + { + capability: capabilityContext([]), + correlationId: "denied-correlation", + }, + "configuration.setting.update", + { key: "hotel_name", value: "Hotel" }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { code: "FORBIDDEN" }, + correlationId: "denied-correlation", + }); + expect(execute).not.toHaveBeenCalled(); + }); + + it("maps adapter outages to DEPENDENCY_UNAVAILABLE and preserves correlation", async () => { + const adapter: SystemMutationAdapter = { + execute: async () => { + throw new Error("database unavailable"); + }, + }; + const service = createSystemMutationService(adapter); + + const result = await service.execute( + { + capability: capabilityContext([PERMS.SETTINGS_EDIT]), + correlationId: "failure-correlation", + }, + "configuration.setting.update", + { key: "hotel_name", value: "Hotel" }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { code: "DEPENDENCY_UNAVAILABLE" }, + correlationId: "failure-correlation", + }); + }); +}); diff --git a/src/features/housekeeping/domains/system/commands/system-commands.ts b/src/features/housekeeping/domains/system/commands/system-commands.ts new file mode 100644 index 00000000..5548d905 --- /dev/null +++ b/src/features/housekeeping/domains/system/commands/system-commands.ts @@ -0,0 +1,315 @@ +import "server-only"; + +import { z } from "zod"; +import { PERMS } from "@/lib/permission-slugs"; +import type { HousekeepingCommand } from "../../../foundation/commands/registry"; +import { anyCapability } from "../../../foundation/contracts"; +import { + type SystemMutationOperation, + type SystemMutationService, + systemMutationService, +} from "../services/mutations"; + +export const SYSTEM_COMMAND_IDS = [ + "system.access.rank.create", + "system.access.rank.delete", + "system.access.rank.update", + "system.access.permissions.update", + "system.access.permissions.repair", + "system.configuration.settings.save", + "system.configuration.setting.create", + "system.configuration.setting.update", + "system.configuration.setting.delete", + "system.configuration.emulator-setting.update", + "system.configuration.emulator-text.update", + "system.operations.alerts.mark-read", + "system.operations.alert.broadcast", + "system.operations.rcon.update-catalog", + "system.operations.rcon.update-word-filter", + "system.operations.rcon.update-navigator", + "system.operations.rcon.hotel-alert", + "system.operations.rcon.disconnect-user", + "system.operations.rcon.alert-user", + "system.operations.rcon.forward-user", + "system.operations.rcon.give-credits", + "system.operations.rcon.give-duckets", + "system.operations.rcon.give-diamonds", + "system.operations.rcon.give-badge", + "system.operations.rcon.set-motto", + "system.operations.rcon.set-rank", + "system.operations.rcon.execute-command", + "system.operations.rcon.send-gift", + "system.operations.maintenance.update", +] as const; + +interface CommandOptions { + readonly id: (typeof SYSTEM_COMMAND_IDS)[number]; + readonly operation: SystemMutationOperation; + readonly capability: string; + readonly input: z.ZodType; + readonly requiresReason: boolean; + readonly attempts?: number; +} + +function systemCommand( + service: Pick, + options: CommandOptions, +): HousekeepingCommand { + return { + id: options.id, + owner: "system", + risk: "sensitive", + capability: anyCapability(options.capability), + input: options.input, + requiresReason: options.requiresReason, + rateLimit: { attempts: options.attempts ?? 10, windowMs: 60_000 }, + execute: (context, input) => + service.execute( + { + capability: context.capability, + correlationId: context.correlationId, + }, + options.operation, + input, + ), + }; +} + +const empty = z.object({}); +const positiveId = z.number().int().positive(); +const requiredText = (max: number) => z.string().min(1).max(max).regex(/\S/u); +const settingInput = z.object({ + key: requiredText(255), + value: z.string().max(65_535), +}); + +export function createSystemCommands( + service: Pick, +) { + return [ + systemCommand(service, { + id: "system.access.rank.create", + operation: "access.rank.create", + capability: PERMS.PERMISSIONS_MANAGE, + input: z.object({ name: requiredText(25), level: positiveId }), + requiresReason: true, + }), + systemCommand(service, { + id: "system.access.rank.delete", + operation: "access.rank.delete", + capability: PERMS.PERMISSIONS_MANAGE, + input: z.object({ id: positiveId }), + requiresReason: true, + }), + systemCommand(service, { + id: "system.access.rank.update", + operation: "access.rank.update", + capability: PERMS.PERMISSIONS_MANAGE, + input: z.object({ + id: positiveId, + fields: z.record(z.string(), z.union([z.string(), z.number()])), + }), + requiresReason: true, + }), + systemCommand(service, { + id: "system.access.permissions.update", + operation: "access.permissions.update", + capability: PERMS.PERMISSIONS_MANAGE, + input: z.object({ + roleId: positiveId, + permissionSlugs: z.array(requiredText(160)).max(500), + }), + requiresReason: true, + }), + systemCommand(service, { + id: "system.access.permissions.repair", + operation: "access.permissions.repair", + capability: PERMS.PERMISSIONS_MANAGE, + input: empty, + requiresReason: true, + }), + systemCommand(service, { + id: "system.configuration.settings.save", + operation: "configuration.settings.save", + capability: PERMS.SETTINGS_EDIT, + input: z.object({ settings: z.record(z.string(), z.string()) }), + requiresReason: true, + }), + systemCommand(service, { + id: "system.configuration.setting.create", + operation: "configuration.setting.create", + capability: PERMS.SETTINGS_EDIT, + input: settingInput.extend({ comment: z.string().max(255).optional() }), + requiresReason: true, + }), + systemCommand(service, { + id: "system.configuration.setting.update", + operation: "configuration.setting.update", + capability: PERMS.SETTINGS_EDIT, + input: settingInput, + requiresReason: true, + }), + systemCommand(service, { + id: "system.configuration.setting.delete", + operation: "configuration.setting.delete", + capability: PERMS.SETTINGS_EDIT, + input: z.object({ key: requiredText(255) }), + requiresReason: true, + }), + systemCommand(service, { + id: "system.configuration.emulator-setting.update", + operation: "configuration.emulator-setting.update", + capability: PERMS.SETTINGS_EDIT, + input: z.object({ key: requiredText(100), value: z.string().max(512) }), + requiresReason: false, + }), + systemCommand(service, { + id: "system.configuration.emulator-text.update", + operation: "configuration.emulator-text.update", + capability: PERMS.SETTINGS_EDIT, + input: z.object({ key: requiredText(100), value: z.string().max(4096) }), + requiresReason: false, + }), + systemCommand(service, { + id: "system.operations.alerts.mark-read", + operation: "operations.alerts.mark-read", + capability: PERMS.NOTIFICATIONS_VIEW, + input: empty, + requiresReason: false, + }), + systemCommand(service, { + id: "system.operations.alert.broadcast", + operation: "operations.alert.broadcast", + capability: PERMS.NOTIFICATIONS_EDIT, + input: z.object({ message: requiredText(1000) }), + requiresReason: true, + }), + systemCommand(service, { + id: "system.operations.rcon.update-catalog", + operation: "rcon.update-catalog", + capability: PERMS.RCON_EXECUTE, + input: empty, + requiresReason: true, + attempts: 5, + }), + systemCommand(service, { + id: "system.operations.rcon.update-word-filter", + operation: "rcon.update-word-filter", + capability: PERMS.RCON_EXECUTE, + input: empty, + requiresReason: true, + attempts: 5, + }), + systemCommand(service, { + id: "system.operations.rcon.update-navigator", + operation: "rcon.update-navigator", + capability: PERMS.RCON_EXECUTE, + input: empty, + requiresReason: true, + attempts: 5, + }), + systemCommand(service, { + id: "system.operations.rcon.hotel-alert", + operation: "rcon.hotel-alert", + capability: PERMS.RCON_EXECUTE, + input: z.object({ message: requiredText(512) }), + requiresReason: true, + attempts: 5, + }), + systemCommand(service, { + id: "system.operations.rcon.disconnect-user", + operation: "rcon.disconnect-user", + capability: PERMS.RCON_EXECUTE, + input: z.object({ userId: positiveId, username: requiredText(255) }), + requiresReason: true, + attempts: 5, + }), + systemCommand(service, { + id: "system.operations.rcon.alert-user", + operation: "rcon.alert-user", + capability: PERMS.RCON_EXECUTE, + input: z.object({ userId: positiveId, message: requiredText(512) }), + requiresReason: true, + attempts: 5, + }), + systemCommand(service, { + id: "system.operations.rcon.forward-user", + operation: "rcon.forward-user", + capability: PERMS.RCON_EXECUTE, + input: z.object({ userId: positiveId, roomId: positiveId }), + requiresReason: true, + attempts: 5, + }), + ...[ + ["give-credits", "rcon.give-credits"], + ["give-duckets", "rcon.give-duckets"], + ["give-diamonds", "rcon.give-diamonds"], + ].map(([suffix, operation]) => + systemCommand(service, { + id: `system.operations.rcon.${suffix}` as (typeof SYSTEM_COMMAND_IDS)[number], + operation: operation as SystemMutationOperation, + capability: PERMS.RCON_EXECUTE, + input: z.object({ userId: positiveId, amount: positiveId }), + requiresReason: true, + attempts: 5, + }), + ), + systemCommand(service, { + id: "system.operations.rcon.give-badge", + operation: "rcon.give-badge", + capability: PERMS.RCON_EXECUTE, + input: z.object({ userId: positiveId, badge: requiredText(32) }), + requiresReason: true, + attempts: 5, + }), + systemCommand(service, { + id: "system.operations.rcon.set-motto", + operation: "rcon.set-motto", + capability: PERMS.RCON_EXECUTE, + input: z.object({ userId: positiveId, motto: requiredText(127) }), + requiresReason: true, + attempts: 5, + }), + systemCommand(service, { + id: "system.operations.rcon.set-rank", + operation: "rcon.set-rank", + capability: PERMS.RCON_EXECUTE, + input: z.object({ userId: positiveId, rank: positiveId.max(9999) }), + requiresReason: true, + attempts: 5, + }), + systemCommand(service, { + id: "system.operations.rcon.execute-command", + operation: "rcon.execute-command", + capability: PERMS.RCON_EXECUTE, + input: z.object({ userId: positiveId, command: requiredText(100) }), + requiresReason: true, + attempts: 5, + }), + systemCommand(service, { + id: "system.operations.rcon.send-gift", + operation: "rcon.send-gift", + capability: PERMS.RCON_EXECUTE, + input: z.object({ + userId: positiveId, + itemId: positiveId, + message: z.string().max(255).optional(), + }), + requiresReason: true, + attempts: 5, + }), + systemCommand(service, { + id: "system.operations.maintenance.update", + operation: "operations.maintenance.update", + capability: PERMS.SETTINGS_EDIT, + input: z.object({ + enabled: z.boolean(), + message: z.string().max(65_535), + minimumLoginRank: z.number().int().min(0), + }), + requiresReason: true, + }), + ] as const; +} + +export const SYSTEM_COMMANDS = createSystemCommands(systemMutationService); diff --git a/src/features/housekeeping/domains/system/manifest.ts b/src/features/housekeeping/domains/system/manifest.ts index e7fd86bb..410f00d1 100644 --- a/src/features/housekeeping/domains/system/manifest.ts +++ b/src/features/housekeeping/domains/system/manifest.ts @@ -3,6 +3,7 @@ import { anyCapability, type HousekeepingDomainManifest, } from "../../foundation/contracts"; +import { SYSTEM_ROUTES } from "./routes"; export const systemManifest = { id: "system", @@ -21,7 +22,7 @@ export const systemManifest = { PERMS.SETTINGS_EDIT, PERMS.NOTIFICATIONS_EDIT, ), - routes: [], + routes: SYSTEM_ROUTES, searchProviders: [], inboxSources: [], widgets: [], diff --git a/src/features/housekeeping/domains/system/pages/access.tsx b/src/features/housekeeping/domains/system/pages/access.tsx new file mode 100644 index 00000000..627e93c4 --- /dev/null +++ b/src/features/housekeeping/domains/system/pages/access.tsx @@ -0,0 +1,193 @@ +import type { ReactNode } from "react"; +import { + createCorrelationId, + fail, + type HousekeepingResult, +} from "../../../foundation/contracts"; +import { HousekeepingPageShell } from "../../../foundation/page/housekeeping-page-shell"; +import { HousekeepingPageState } from "../../../foundation/page/housekeeping-page-state"; +import type { HousekeepingPageInput } from "../../../route-handlers"; +import { + type SystemAccessQueryData, + type SystemAccessQueryInput, + systemAccessQuery, +} from "../queries/access"; + +interface SystemAccessPageProps { + readonly result?: HousekeepingResult; +} + +function state( + kind: "loading" | "empty" | "error", + title: string, + description: string, +) { + return ( +
+ +
+ ); +} + +function forbidden() { + return ( +
+

Access denied

+

+ Your account cannot manage permissions. +

+
+ ); +} + +function accessContent(data: SystemAccessQueryData) { + if (data.kind === "permission-detail") { + return ( +
+
+

+ {data.rank.name} +

+
+
Rank ID
+
{data.rank.id}
+
Level
+
{data.rank.level}
+
Users
+
{data.rank.userCount}
+
+
+
+

+ CMS permissions +

+
    + {data.rank.cmsRole?.permissionSlugs.map((slug) => ( +
  • {slug}
  • + )) ??
  • No CMS role is linked.
  • } +
+
+
+ ); + } + + return ( +
+
+

+ Permission ranks +

+
    + {data.ranks.map((rank) => ( +
  • + + {rank.name} + + + {rank.userCount} users + +
  • + ))} +
+
+
+

ACL summary

+

+ {data.acl.roles} roles and {data.acl.permissions} permissions +

+
+
+ ); +} + +export function SystemAccessPage({ result }: SystemAccessPageProps) { + let body: ReactNode; + if (!result) { + body = state( + "loading", + "Loading access data", + "Reading ranks and ACL assignments.", + ); + } else if (!result.ok) { + body = + result.error.code === "FORBIDDEN" + ? forbidden() + : state( + "error", + "Access data unavailable", + `Request ${result.correlationId} could not be completed.`, + ); + } else { + const data = result.data; + const empty = + data.kind === "permissions" && + data.ranks.length === 0 && + data.acl.roles === 0 && + data.acl.permissions === 0; + if (empty) { + body = state( + "empty", + "No access data", + "No ranks or ACL assignments were returned.", + ); + } else { + body = ( +
0 ? "partial" : "ready" + } + className="space-y-4" + > + {data.partialDependencies.length > 0 ? ( + + ) : null} + {accessContent(data)} +
+ ); + } + } + + return ( + + {body} + + ); +} + +export async function renderSystemAccessPage(input: HousekeepingPageInput) { + const queryInput: SystemAccessQueryInput | null = + input.match.routeId === "system.access.permissions" + ? { routeId: "system.access.permissions" } + : input.match.routeId === "system.access.permission-detail" + ? { + routeId: "system.access.permission-detail", + rankId: input.match.params.id ?? "", + } + : null; + const result = queryInput + ? await systemAccessQuery.run(input.context, queryInput) + : fail("NOT_FOUND", "errors.housekeeping.notFound", createCorrelationId()); + return ; +} diff --git a/src/features/housekeeping/domains/system/pages/configuration.tsx b/src/features/housekeeping/domains/system/pages/configuration.tsx new file mode 100644 index 00000000..1b7ac371 --- /dev/null +++ b/src/features/housekeeping/domains/system/pages/configuration.tsx @@ -0,0 +1,165 @@ +import type { ReactNode } from "react"; +import { + createCorrelationId, + fail, + type HousekeepingResult, +} from "../../../foundation/contracts"; +import { HousekeepingPageShell } from "../../../foundation/page/housekeeping-page-shell"; +import { HousekeepingPageState } from "../../../foundation/page/housekeeping-page-state"; +import type { HousekeepingPageInput } from "../../../route-handlers"; +import { + type SystemConfigurationQueryData, + type SystemConfigurationQueryInput, + type SystemSettingRow, + systemConfigurationQuery, +} from "../queries/configuration"; + +interface SystemConfigurationPageProps { + readonly result?: HousekeepingResult; +} + +function pageState( + kind: "loading" | "empty" | "error", + title: string, + description: string, +) { + return ( +
+ +
+ ); +} + +function forbiddenState() { + return ( +
+

Access denied

+

+ Your account cannot view system configuration. +

+
+ ); +} + +function settingList(title: string, rows: readonly SystemSettingRow[]) { + return ( +
+

{title}

+
+ {rows.map((row) => ( +
+
{row.key}
+
+ {row.value} +
+
+ ))} +
+
+ ); +} + +function configurationContent(data: SystemConfigurationQueryData) { + return data.kind === "settings" ? ( + settingList("Website settings", data.settings) + ) : ( +
+ {settingList("Emulator settings", data.settings)} +
+ {settingList("Emulator texts", data.texts)} +

+ Showing {data.texts.length} of {data.textsTotal} text entries. +

+
+
+ ); +} + +export function SystemConfigurationPage({ + result, +}: SystemConfigurationPageProps) { + let body: ReactNode; + if (!result) { + body = pageState( + "loading", + "Loading configuration", + "Reading CMS and emulator settings.", + ); + } else if (!result.ok) { + body = + result.error.code === "FORBIDDEN" + ? forbiddenState() + : pageState( + "error", + "Configuration unavailable", + `Request ${result.correlationId} could not be completed.`, + ); + } else { + const data = result.data; + const empty = + data.kind === "settings" + ? data.settings.length === 0 + : data.settings.length === 0 && data.texts.length === 0; + if (empty) { + body = pageState( + "empty", + "No configuration entries", + "The selected configuration source returned no entries.", + ); + } else { + body = ( +
0 ? "partial" : "ready" + } + className="space-y-4" + > + {data.partialDependencies.length > 0 ? ( + + ) : null} + {configurationContent(data)} +
+ ); + } + } + + return ( + + {body} + + ); +} + +export async function renderSystemConfigurationPage( + input: HousekeepingPageInput, +) { + const queryInput: SystemConfigurationQueryInput | null = + input.match.routeId === "system.configuration.settings" + ? { routeId: "system.configuration.settings" } + : input.match.routeId === "system.configuration.emulator" + ? { routeId: "system.configuration.emulator" } + : null; + const result = queryInput + ? await systemConfigurationQuery.run(input.context, queryInput) + : fail("NOT_FOUND", "errors.housekeeping.notFound", createCorrelationId()); + return ; +} diff --git a/src/features/housekeeping/domains/system/pages/observability.tsx b/src/features/housekeeping/domains/system/pages/observability.tsx new file mode 100644 index 00000000..0fb1a568 --- /dev/null +++ b/src/features/housekeeping/domains/system/pages/observability.tsx @@ -0,0 +1,212 @@ +import type { ReactNode } from "react"; +import { + createCorrelationId, + fail, + type HousekeepingResult, +} from "../../../foundation/contracts"; +import { HousekeepingPageShell } from "../../../foundation/page/housekeeping-page-shell"; +import { HousekeepingPageState } from "../../../foundation/page/housekeeping-page-state"; +import type { HousekeepingPageInput } from "../../../route-handlers"; +import { + type SystemObservabilityQueryData, + type SystemObservabilityQueryInput, + systemObservabilityQuery, +} from "../queries/observability"; + +interface SystemObservabilityPageProps { + readonly result?: HousekeepingResult; +} + +function pageState( + kind: "loading" | "empty" | "error", + title: string, + description: string, +) { + return ( +
+ +
+ ); +} + +function forbiddenState() { + return ( +
+

Access denied

+

+ Your account cannot view this observability source. +

+
+ ); +} + +function observabilityContent(data: SystemObservabilityQueryData) { + if (data.kind === "devops") { + return ( +
+
+

+ Service health +

+ {data.health ? ( +
+
Database
+
{data.health.dbOk ? "Available" : "Unavailable"}
+
Redis
+
+ {data.health.redisOk === null + ? "Not configured" + : data.health.redisOk + ? "Available" + : "Unavailable"} +
+
Emulator
+
{data.health.emulatorOk ? "Available" : "Unavailable"}
+
Online users
+
{data.health.onlineUsers}
+
+ ) : ( +

+ Health data is unavailable. +

+ )} +
+ {observationRows("Recent emulator errors", data.errors)} +
+ ); + } + if (data.kind === "devops-errors") { + return observationRows("Emulator errors", data.errors); + } + return ( +
+
+ {data.metrics.map((metric) => ( +
+

+ {metric.label} +

+

+ {metric.value} +

+
+ ))} +
+ {observationRows( + data.kind === "logs" ? "Recent entries" : "Details", + data.rows, + )} +
+ ); +} + +function observationRows( + title: string, + rows: readonly { id: string; primary: string; secondary?: string }[], +) { + return ( +
+

{title}

+
    + {rows.map((row) => ( +
  • +

    + {row.primary} +

    + {row.secondary ? ( +

    + {row.secondary} +

    + ) : null} +
  • + ))} +
+
+ ); +} + +function isEmpty(data: SystemObservabilityQueryData): boolean { + if (data.kind === "devops") + return data.health === null && data.errors.length === 0; + if (data.kind === "devops-errors") return data.errors.length === 0; + return data.metrics.length === 0 && data.rows.length === 0; +} + +export function SystemObservabilityPage({ + result, +}: SystemObservabilityPageProps) { + let body: ReactNode; + if (!result) { + body = pageState( + "loading", + "Loading observability data", + "Reading metrics and operational logs.", + ); + } else if (!result.ok) { + body = + result.error.code === "FORBIDDEN" + ? forbiddenState() + : pageState( + "error", + "Observability data unavailable", + `Request ${result.correlationId} could not be completed.`, + ); + } else if (isEmpty(result.data)) { + body = pageState( + "empty", + "No observations", + "The selected source returned no records.", + ); + } else { + body = ( +
0 ? "partial" : "ready" + } + className="space-y-4" + > + {result.data.partialDependencies.length > 0 ? ( + + ) : null} + {observabilityContent(result.data)} +
+ ); + } + + return ( + + {body} + + ); +} + +export async function renderSystemObservabilityPage( + input: HousekeepingPageInput, +) { + const routeId = input.match + .routeId as SystemObservabilityQueryInput["routeId"]; + const supported = routeId.startsWith("system.observability."); + const result = supported + ? await systemObservabilityQuery.run(input.context, { routeId }) + : fail("NOT_FOUND", "errors.housekeeping.notFound", createCorrelationId()); + return ; +} diff --git a/src/features/housekeeping/domains/system/pages/operations.tsx b/src/features/housekeeping/domains/system/pages/operations.tsx new file mode 100644 index 00000000..9eff1890 --- /dev/null +++ b/src/features/housekeeping/domains/system/pages/operations.tsx @@ -0,0 +1,182 @@ +import type { ReactNode } from "react"; +import { + createCorrelationId, + fail, + type HousekeepingResult, +} from "../../../foundation/contracts"; +import { HousekeepingPageShell } from "../../../foundation/page/housekeeping-page-shell"; +import { HousekeepingPageState } from "../../../foundation/page/housekeeping-page-state"; +import type { HousekeepingPageInput } from "../../../route-handlers"; +import { + type SystemOperationsQueryData, + type SystemOperationsQueryInput, + systemOperationsQuery, +} from "../queries/operations"; + +interface SystemOperationsPageProps { + readonly result?: HousekeepingResult; +} + +function pageState( + kind: "loading" | "empty" | "error", + title: string, + description: string, +) { + return ( +
+ +
+ ); +} + +function forbiddenState() { + return ( +
+

Access denied

+

+ Your account cannot use this system operation. +

+
+ ); +} + +function maintenanceSummary(data: { + readonly enabled: boolean; + readonly message: string; + readonly minimumLoginRank: number; +}) { + return ( +
+

Maintenance

+
+
Status
+
{data.enabled ? "Enabled" : "Disabled"}
+
Minimum login rank
+
{data.minimumLoginRank}
+
Message
+
{data.message || "No message configured"}
+
+
+ ); +} + +function operationsContent(data: SystemOperationsQueryData) { + if (data.kind === "alerts") { + return ( +
+

+ Operational alerts +

+
    + {data.alerts.map((alert) => ( +
  • +

    + {alert.type} - {alert.severity} +

    +

    {alert.message}

    +
  • + ))} +
+
+ ); + } + if (data.kind === "maintenance") return maintenanceSummary(data.maintenance); + return ( +
+
+

Command center

+

+ {data.onlineUsers.count} users online +

+
    + {data.onlineUsers.users.map((user) => ( +
  • {user.username}
  • + ))} +
+
+
+

Service health

+

+ {data.health + ? `Database ${data.health.dbOk ? "available" : "unavailable"}; emulator ${data.health.emulatorOk ? "available" : "unavailable"}.` + : "Health data is unavailable."} +

+
+ {data.maintenance ? maintenanceSummary(data.maintenance) : null} +
+ ); +} + +export function SystemOperationsPage({ result }: SystemOperationsPageProps) { + let body: ReactNode; + if (!result) { + body = pageState( + "loading", + "Loading system operations", + "Reading operational status and queues.", + ); + } else if (!result.ok) { + body = + result.error.code === "FORBIDDEN" + ? forbiddenState() + : pageState( + "error", + "System operations unavailable", + `Request ${result.correlationId} could not be completed.`, + ); + } else if (result.data.kind === "alerts" && result.data.alerts.length === 0) { + body = pageState( + "empty", + "No operational alerts", + "There are no alerts to review.", + ); + } else { + body = ( +
0 ? "partial" : "ready" + } + className="space-y-4" + > + {result.data.partialDependencies.length > 0 ? ( + + ) : null} + {operationsContent(result.data)} +
+ ); + } + + return ( + + {body} + + ); +} + +export async function renderSystemOperationsPage(input: HousekeepingPageInput) { + const routeId = input.match.routeId as SystemOperationsQueryInput["routeId"]; + const supported = + routeId === "system.operations.alerts" || + routeId === "system.operations.command-center" || + routeId === "system.operations.maintenance"; + const result = supported + ? await systemOperationsQuery.run(input.context, { routeId }) + : fail("NOT_FOUND", "errors.housekeeping.notFound", createCorrelationId()); + return ; +} diff --git a/src/features/housekeeping/domains/system/pages/system-pages.test.tsx b/src/features/housekeeping/domains/system/pages/system-pages.test.tsx new file mode 100644 index 00000000..bfcd0efe --- /dev/null +++ b/src/features/housekeeping/domains/system/pages/system-pages.test.tsx @@ -0,0 +1,157 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { + fail, + type HousekeepingResult, + ok, +} from "../../../foundation/contracts"; +import { SystemAccessPage } from "./access"; +import { SystemConfigurationPage } from "./configuration"; +import { SystemObservabilityPage } from "./observability"; +import { SystemOperationsPage } from "./operations"; + +const correlationId = "system-pages-test"; + +type PageCase = { + readonly name: string; + readonly render: (result?: HousekeepingResult) => string; + readonly empty: unknown; + readonly partial: unknown; + readonly ready: unknown; +}; + +const pageCases: readonly PageCase[] = [ + { + name: "access", + render: (result) => + renderToStaticMarkup(), + empty: { + kind: "permissions", + ranks: [], + acl: { roles: 0, permissions: 0 }, + partialDependencies: [], + }, + partial: { + kind: "permissions", + ranks: [{ id: 4, name: "Moderator", level: 4, userCount: 2 }], + acl: { roles: 0, permissions: 0 }, + partialDependencies: ["acl"], + }, + ready: { + kind: "permissions", + ranks: [{ id: 4, name: "Moderator", level: 4, userCount: 2 }], + acl: { roles: 3, permissions: 18 }, + partialDependencies: [], + }, + }, + { + name: "configuration", + render: (result) => + renderToStaticMarkup( + , + ), + empty: { kind: "settings", settings: [], partialDependencies: [] }, + partial: { + kind: "emulator", + settings: [{ key: "hotel.name", value: "Epic" }], + texts: [], + textsTotal: 0, + partialDependencies: ["emulator-texts"], + }, + ready: { + kind: "settings", + settings: [{ key: "hotel.name", value: "Epic" }], + partialDependencies: [], + }, + }, + { + name: "observability", + render: (result) => + renderToStaticMarkup( + , + ), + empty: { + kind: "logs", + metrics: [], + rows: [], + partialDependencies: [], + }, + partial: { + kind: "devops", + health: null, + errors: [{ id: "1", primary: "Connection error" }], + partialDependencies: ["health"], + }, + ready: { + kind: "analytics", + metrics: [{ label: "Online users", value: 12 }], + rows: [], + partialDependencies: [], + }, + }, + { + name: "operations", + render: (result) => + renderToStaticMarkup(), + empty: { kind: "alerts", alerts: [], partialDependencies: [] }, + partial: { + kind: "command-center", + health: null, + onlineUsers: { count: 0, users: [] }, + maintenance: { + enabled: false, + message: "", + minimumLoginRank: 5, + }, + partialDependencies: ["health"], + }, + ready: { + kind: "alerts", + alerts: [ + { + id: 7, + severity: "warning", + type: "emulator", + message: "Connection restored", + isRead: false, + }, + ], + partialDependencies: [], + }, + }, +]; + +describe.each(pageCases)( + "System $name page", + ({ render, empty, partial, ready }) => { + it("renders loading, forbidden, and dependency errors explicitly", () => { + expect(render()).toContain('data-housekeeping-state="loading"'); + expect( + render( + fail("FORBIDDEN", "errors.housekeeping.forbidden", correlationId), + ), + ).toContain('data-housekeeping-state="forbidden"'); + expect( + render( + fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + correlationId, + ), + ), + ).toContain('data-housekeeping-state="error"'); + }); + + it("renders empty, partial, and ready query results explicitly", () => { + expect(render(ok(empty, correlationId))).toContain( + 'data-housekeeping-state="empty"', + ); + expect(render(ok(partial, correlationId))).toContain( + 'data-housekeeping-state="partial"', + ); + expect(render(ok(ready, correlationId))).toContain( + 'data-housekeeping-state="ready"', + ); + }); + }, +); diff --git a/src/features/housekeeping/domains/system/queries/access.ts b/src/features/housekeeping/domains/system/queries/access.ts new file mode 100644 index 00000000..15b7681d --- /dev/null +++ b/src/features/housekeeping/domains/system/queries/access.ts @@ -0,0 +1,257 @@ +import "server-only"; + +import { PERMS } from "@/lib/permission-slugs"; +import { authorizeHousekeeping } from "../../../foundation/authorization"; +import { + anyCapability, + fail, + type HousekeepingQuery, + ok, +} from "../../../foundation/contracts"; + +export interface SystemAccessRank { + readonly id: number; + readonly name: string; + readonly level: number; + readonly userCount: number; +} + +export interface SystemAccessRankDetail extends SystemAccessRank { + readonly badge: string; + readonly permissions: Readonly>; + readonly cmsRole: { + readonly id: number; + readonly slug: string; + readonly title: string; + readonly permissionSlugs: readonly string[]; + } | null; + readonly users: readonly { id: number; username: string }[]; +} + +export interface SystemAclOverview { + readonly roles: number; + readonly permissions: number; +} + +export interface SystemAccessAdapters { + loadRanks(): Promise; + loadRankDetail(rankId: number): Promise; + loadAclOverview(): Promise; +} + +export type SystemAccessQueryInput = + | { readonly routeId: "system.access.permissions" } + | { + readonly routeId: "system.access.permission-detail"; + readonly rankId: string; + }; + +export type SystemAccessQueryData = + | { + readonly kind: "permissions"; + readonly ranks: readonly SystemAccessRank[]; + readonly acl: SystemAclOverview; + readonly partialDependencies: readonly string[]; + } + | { + readonly kind: "permission-detail"; + readonly rank: SystemAccessRankDetail; + readonly partialDependencies: readonly string[]; + }; + +const accessCapability = anyCapability(PERMS.PERMISSIONS_MANAGE); + +function dependencyUnavailable(correlationId: string) { + return fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + correlationId, + ); +} + +export function createSystemAccessQuery( + adapters: SystemAccessAdapters, +): HousekeepingQuery { + return { + id: "system.access.query", + owner: "system", + capability: accessCapability, + async run(context, input) { + const authorization = authorizeHousekeeping(context, accessCapability); + if (!authorization.ok) return authorization; + const correlationId = authorization.correlationId; + + if (input.routeId === "system.access.permission-detail") { + const rankId = Number(input.rankId); + if (!Number.isInteger(rankId) || rankId <= 0) { + return fail( + "VALIDATION", + "errors.housekeeping.validation", + correlationId, + { rankId: ["errors.validation.invalid"] }, + ); + } + + try { + const rank = await adapters.loadRankDetail(rankId); + return rank + ? ok( + { + kind: "permission-detail" as const, + rank, + partialDependencies: [], + }, + correlationId, + ) + : fail("NOT_FOUND", "errors.housekeeping.notFound", correlationId); + } catch { + return dependencyUnavailable(correlationId); + } + } + + const [ranksResult, aclResult] = await Promise.allSettled([ + adapters.loadRanks(), + adapters.loadAclOverview(), + ]); + if ( + ranksResult.status === "rejected" && + aclResult.status === "rejected" + ) { + return dependencyUnavailable(correlationId); + } + + const partialDependencies: string[] = []; + if (ranksResult.status === "rejected") partialDependencies.push("ranks"); + if (aclResult.status === "rejected") partialDependencies.push("acl"); + + return ok( + { + kind: "permissions", + ranks: ranksResult.status === "fulfilled" ? ranksResult.value : [], + acl: + aclResult.status === "fulfilled" + ? aclResult.value + : { roles: 0, permissions: 0 }, + partialDependencies, + }, + correlationId, + ); + }, + }; +} + +export const systemAccessAdapters: SystemAccessAdapters = { + async loadRanks() { + const [{ db }, { sql }, { fetchEmulatorRankSummaries }] = await Promise.all( + [ + import("@/lib/db"), + import("drizzle-orm"), + import("@/lib/services/permission-ranks"), + ], + ); + const [ranks, countResult] = await Promise.all([ + fetchEmulatorRankSummaries(db), + db.execute( + sql`SELECT \`rank\`, COUNT(*) AS total FROM users GROUP BY \`rank\``, + ), + ]); + const countRows = countResult[0] as unknown as { + rank: number | bigint; + total: number | bigint; + }[]; + const counts = new Map( + countRows.map((row) => [Number(row.rank), Number(row.total)]), + ); + return ranks.map((rank) => ({ + id: rank.id, + name: rank.rank_name, + level: rank.level, + userCount: counts.get(rank.id) ?? 0, + })); + }, + async loadRankDetail(rankId) { + const [{ db }, { sql }, { fetchEmulatorRankForEdit }] = await Promise.all([ + import("@/lib/db"), + import("drizzle-orm"), + import("@/lib/services/permission-ranks"), + ]); + const rank = await fetchEmulatorRankForEdit(db, rankId); + if (!rank) return null; + + const [userResult, roleResult] = await Promise.all([ + db.execute(sql` + SELECT id, username + FROM users + WHERE \`rank\` = ${rankId} + ORDER BY username ASC + LIMIT 200 + `), + db.execute(sql` + SELECT ar.id, ar.slug, ar.title, ap.slug AS permission_slug + FROM acl_roles ar + LEFT JOIN acl_model_permissions amp + ON amp.model_type = 'Role' AND amp.model_id = ar.id + LEFT JOIN acl_permissions ap ON ap.id = amp.permission_id + WHERE ar.slug = ${`rank_${rankId}`} + ORDER BY ap.slug ASC + `), + ]); + const users = userResult[0] as unknown as { + id: number | bigint; + username: string; + }[]; + const roles = roleResult[0] as unknown as { + id: number | bigint; + slug: string; + title: string; + permission_slug: string | null; + }[]; + const role = roles[0]; + + return { + id: rank.id, + name: rank.rank_name, + level: rank.level, + userCount: users.length, + badge: rank.badge, + permissions: rank.permissions, + cmsRole: role + ? { + id: Number(role.id), + slug: role.slug, + title: role.title, + permissionSlugs: roles.flatMap((row) => + row.permission_slug ? [row.permission_slug] : [], + ), + } + : null, + users: users.map((user) => ({ + id: Number(user.id), + username: user.username, + })), + }; + }, + async loadAclOverview() { + const [{ db }, { sql }] = await Promise.all([ + import("@/lib/db"), + import("drizzle-orm"), + ]); + const [result] = await db.execute(sql` + SELECT + (SELECT COUNT(*) FROM acl_roles) AS roles, + (SELECT COUNT(*) FROM acl_permissions) AS permissions + `); + const row = ( + result as unknown as { + roles: number | bigint; + permissions: number | bigint; + }[] + )[0]; + return { + roles: Number(row?.roles ?? 0), + permissions: Number(row?.permissions ?? 0), + }; + }, +}; + +export const systemAccessQuery = createSystemAccessQuery(systemAccessAdapters); diff --git a/src/features/housekeeping/domains/system/queries/configuration.ts b/src/features/housekeeping/domains/system/queries/configuration.ts new file mode 100644 index 00000000..41fd978b --- /dev/null +++ b/src/features/housekeeping/domains/system/queries/configuration.ts @@ -0,0 +1,172 @@ +import "server-only"; + +import { PERMS } from "@/lib/permission-slugs"; +import { authorizeHousekeeping } from "../../../foundation/authorization"; +import { + anyCapability, + fail, + type HousekeepingQuery, + ok, +} from "../../../foundation/contracts"; + +export interface SystemSettingRow { + readonly key: string; + readonly value: string; + readonly comment?: string | null; +} + +export interface SystemEmulatorTextResult { + readonly rows: readonly SystemSettingRow[]; + readonly total: number; +} + +export interface SystemConfigurationAdapters { + loadWebsiteSettings(): Promise; + loadEmulatorSettings(): Promise; + loadEmulatorTexts(): Promise; +} + +export type SystemConfigurationQueryInput = + | { readonly routeId: "system.configuration.settings" } + | { readonly routeId: "system.configuration.emulator" }; + +export type SystemConfigurationQueryData = + | { + readonly kind: "settings"; + readonly settings: readonly SystemSettingRow[]; + readonly partialDependencies: readonly string[]; + } + | { + readonly kind: "emulator"; + readonly settings: readonly SystemSettingRow[]; + readonly texts: readonly SystemSettingRow[]; + readonly textsTotal: number; + readonly partialDependencies: readonly string[]; + }; + +const configurationCapability = anyCapability(PERMS.SETTINGS_VIEW); + +function unavailable(correlationId: string) { + return fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + correlationId, + ); +} + +export function createSystemConfigurationQuery( + adapters: SystemConfigurationAdapters, +): HousekeepingQuery< + SystemConfigurationQueryInput, + SystemConfigurationQueryData +> { + return { + id: "system.configuration.query", + owner: "system", + capability: configurationCapability, + async run(context, input) { + const authorization = authorizeHousekeeping( + context, + configurationCapability, + ); + if (!authorization.ok) return authorization; + const correlationId = authorization.correlationId; + + if (input.routeId === "system.configuration.settings") { + try { + return ok( + { + kind: "settings" as const, + settings: await adapters.loadWebsiteSettings(), + partialDependencies: [], + }, + correlationId, + ); + } catch { + return unavailable(correlationId); + } + } + + const [settingsResult, textsResult] = await Promise.allSettled([ + adapters.loadEmulatorSettings(), + adapters.loadEmulatorTexts(), + ]); + if ( + settingsResult.status === "rejected" && + textsResult.status === "rejected" + ) { + return unavailable(correlationId); + } + + const partialDependencies: string[] = []; + if (settingsResult.status === "rejected") { + partialDependencies.push("emulator-settings"); + } + if (textsResult.status === "rejected") { + partialDependencies.push("emulator-texts"); + } + const textData = + textsResult.status === "fulfilled" + ? textsResult.value + : { rows: [], total: 0 }; + + return ok( + { + kind: "emulator", + settings: + settingsResult.status === "fulfilled" ? settingsResult.value : [], + texts: textData.rows, + textsTotal: textData.total, + partialDependencies, + }, + correlationId, + ); + }, + }; +} + +export const systemConfigurationAdapters: SystemConfigurationAdapters = { + async loadWebsiteSettings() { + const [{ asc }, { db, WebsiteSetting }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + return db + .select({ + key: WebsiteSetting.key, + value: WebsiteSetting.value, + comment: WebsiteSetting.comment, + }) + .from(WebsiteSetting) + .orderBy(asc(WebsiteSetting.key)); + }, + async loadEmulatorSettings() { + const [{ asc }, { db, EmulatorSettings }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + return db + .select({ key: EmulatorSettings.key, value: EmulatorSettings.value }) + .from(EmulatorSettings) + .orderBy(asc(EmulatorSettings.key)); + }, + async loadEmulatorTexts() { + const [{ asc, count }, { db, EmulatorTexts }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + const [rows, totals] = await Promise.all([ + db + .select({ key: EmulatorTexts.key, value: EmulatorTexts.value }) + .from(EmulatorTexts) + .orderBy(asc(EmulatorTexts.key)) + .limit(300), + db.select({ total: count() }).from(EmulatorTexts), + ]); + return { rows, total: Number(totals[0]?.total ?? 0) }; + }, +}; + +export const systemConfigurationQuery = createSystemConfigurationQuery( + systemConfigurationAdapters, +); diff --git a/src/features/housekeeping/domains/system/queries/observability.ts b/src/features/housekeeping/domains/system/queries/observability.ts new file mode 100644 index 00000000..44ad0e95 --- /dev/null +++ b/src/features/housekeeping/domains/system/queries/observability.ts @@ -0,0 +1,427 @@ +import "server-only"; + +import { PERMS } from "@/lib/permission-slugs"; +import { authorizeHousekeeping } from "../../../foundation/authorization"; +import { + anyCapability, + fail, + type HousekeepingQuery, + ok, +} from "../../../foundation/contracts"; + +export interface SystemMetric { + readonly label: string; + readonly value: number | string; +} + +export interface SystemObservationRow { + readonly id: string; + readonly primary: string; + readonly secondary?: string; + readonly timestamp?: number | string; +} + +export interface SystemObservationList { + readonly metrics: readonly SystemMetric[]; + readonly rows: readonly SystemObservationRow[]; +} + +export interface SystemHealthSnapshot { + readonly dbOk: boolean; + readonly dbLatencyMs: number; + readonly redisOk: boolean | null; + readonly emulatorOk: boolean; + readonly onlineUsers: number; +} + +export interface SystemObservabilityAdapters { + loadAnalytics( + view: "overview" | "activity" | "economy", + ): Promise; + loadLogs( + view: "staff" | "audit" | "chat" | "commands" | "trades", + ): Promise; + loadHealth(): Promise; + loadErrors(): Promise; +} + +export type SystemObservabilityQueryInput = { + readonly routeId: + | "system.observability.analytics" + | "system.observability.analytics-activity" + | "system.observability.analytics-economy" + | "system.observability.devops" + | "system.observability.devops-errors" + | "system.observability.logs-staff" + | "system.observability.logs-audit" + | "system.observability.logs-chat" + | "system.observability.logs-commands" + | "system.observability.logs-trades"; +}; + +export type SystemObservabilityQueryData = + | { + readonly kind: "analytics" | "logs"; + readonly metrics: readonly SystemMetric[]; + readonly rows: readonly SystemObservationRow[]; + readonly partialDependencies: readonly string[]; + } + | { + readonly kind: "devops"; + readonly health: SystemHealthSnapshot | null; + readonly errors: readonly SystemObservationRow[]; + readonly partialDependencies: readonly string[]; + } + | { + readonly kind: "devops-errors"; + readonly errors: readonly SystemObservationRow[]; + readonly partialDependencies: readonly string[]; + }; + +const broadCapability = anyCapability( + PERMS.ANALYTICS_VIEW, + PERMS.DEVOPS_VIEW, + PERMS.LOGS_VIEW, +); + +function capabilityForRoute(routeId: SystemObservabilityQueryInput["routeId"]) { + if (routeId.startsWith("system.observability.analytics")) { + return anyCapability(PERMS.ANALYTICS_VIEW); + } + if (routeId.startsWith("system.observability.devops")) { + return anyCapability(PERMS.DEVOPS_VIEW); + } + return anyCapability(PERMS.LOGS_VIEW); +} + +function unavailable(correlationId: string) { + return fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + correlationId, + ); +} + +export function createSystemObservabilityQuery( + adapters: SystemObservabilityAdapters, +): HousekeepingQuery< + SystemObservabilityQueryInput, + SystemObservabilityQueryData +> { + return { + id: "system.observability.query", + owner: "system", + capability: broadCapability, + async run(context, input) { + const authorization = authorizeHousekeeping( + context, + capabilityForRoute(input.routeId), + ); + if (!authorization.ok) return authorization; + const correlationId = authorization.correlationId; + + if (input.routeId === "system.observability.devops") { + const [healthResult, errorsResult] = await Promise.allSettled([ + adapters.loadHealth(), + adapters.loadErrors(), + ]); + if ( + healthResult.status === "rejected" && + errorsResult.status === "rejected" + ) { + return unavailable(correlationId); + } + const partialDependencies: string[] = []; + if (healthResult.status === "rejected") { + partialDependencies.push("health"); + } + if (errorsResult.status === "rejected") { + partialDependencies.push("emulator-errors"); + } + return ok( + { + kind: "devops", + health: + healthResult.status === "fulfilled" ? healthResult.value : null, + errors: + errorsResult.status === "fulfilled" ? errorsResult.value : [], + partialDependencies, + }, + correlationId, + ); + } + + if (input.routeId === "system.observability.devops-errors") { + try { + return ok( + { + kind: "devops-errors" as const, + errors: await adapters.loadErrors(), + partialDependencies: [], + }, + correlationId, + ); + } catch { + return unavailable(correlationId); + } + } + + const analyticsView = { + "system.observability.analytics": "overview", + "system.observability.analytics-activity": "activity", + "system.observability.analytics-economy": "economy", + } as const; + const analytics = + analyticsView[input.routeId as keyof typeof analyticsView]; + try { + if (analytics) { + const data = await adapters.loadAnalytics(analytics); + return ok( + { + kind: "analytics" as const, + ...data, + partialDependencies: [], + }, + correlationId, + ); + } + + const logViews = { + "system.observability.logs-staff": "staff", + "system.observability.logs-audit": "audit", + "system.observability.logs-chat": "chat", + "system.observability.logs-commands": "commands", + "system.observability.logs-trades": "trades", + } as const; + const data = await adapters.loadLogs( + logViews[input.routeId as keyof typeof logViews], + ); + return ok( + { + kind: "logs" as const, + ...data, + partialDependencies: [], + }, + correlationId, + ); + } catch { + return unavailable(correlationId); + } + }, + }; +} + +async function rawRows(query: unknown): Promise { + const { db } = await import("@/lib/db"); + const [rows] = await db.execute(query as never); + return (rows ?? []) as unknown as T[]; +} + +export const systemObservabilityAdapters: SystemObservabilityAdapters = { + async loadAnalytics(view) { + const { sql } = await import("drizzle-orm"); + const weekAgo = Math.floor(Date.now() / 1000) - 7 * 86_400; + if (view === "overview") { + const rows = await rawRows>(sql` + SELECT + (SELECT COUNT(*) FROM users) AS totalUsers, + (SELECT COUNT(*) FROM users WHERE online = '1') AS onlineUsers, + (SELECT COUNT(*) FROM rooms) AS totalRooms, + (SELECT COUNT(*) FROM chatlogs_room WHERE timestamp >= ${weekAgo}) AS weekChats, + (SELECT COUNT(*) FROM room_trade_log WHERE timestamp >= ${weekAgo}) AS weekTrades + `); + const row = rows[0] ?? {}; + return { + metrics: [ + { label: "Total users", value: Number(row.totalUsers ?? 0) }, + { label: "Online users", value: Number(row.onlineUsers ?? 0) }, + { label: "Total rooms", value: Number(row.totalRooms ?? 0) }, + { label: "Weekly chats", value: Number(row.weekChats ?? 0) }, + { label: "Weekly trades", value: Number(row.weekTrades ?? 0) }, + ], + rows: [], + }; + } + if (view === "activity") { + const rows = await rawRows>(sql` + SELECT + (SELECT COUNT(*) FROM chatlogs_room WHERE timestamp >= ${weekAgo}) AS chats, + (SELECT COUNT(*) FROM commandlogs WHERE timestamp >= ${weekAgo}) AS commands, + (SELECT COUNT(*) FROM bans WHERE timestamp >= ${weekAgo}) AS bans, + (SELECT COUNT(*) FROM users WHERE account_created >= ${weekAgo}) AS registrations + `); + const row = rows[0] ?? {}; + return { + metrics: [ + { label: "Chats", value: Number(row.chats ?? 0) }, + { label: "Commands", value: Number(row.commands ?? 0) }, + { label: "Bans", value: Number(row.bans ?? 0) }, + { label: "Registrations", value: Number(row.registrations ?? 0) }, + ], + rows: [], + }; + } + + const rows = await rawRows>(sql` + SELECT + COALESCE(SUM(credits), 0) AS credits, + COALESCE(SUM(pixels), 0) AS pixels, + COALESCE(SUM(points), 0) AS points, + (SELECT COUNT(*) FROM logs_shop_purchases WHERE timestamp >= ${weekAgo}) AS purchases, + (SELECT COUNT(*) FROM room_trade_log WHERE timestamp >= ${weekAgo}) AS trades + FROM users + `); + const row = rows[0] ?? {}; + return { + metrics: [ + { label: "Credits", value: Number(row.credits ?? 0) }, + { label: "Pixels", value: Number(row.pixels ?? 0) }, + { label: "Points", value: Number(row.points ?? 0) }, + { label: "Purchases", value: Number(row.purchases ?? 0) }, + { label: "Trades", value: Number(row.trades ?? 0) }, + ], + rows: [], + }; + }, + async loadLogs(view) { + if (view === "audit") { + const { getAuditLogs } = await import("@/lib/services/audit"); + const result = await getAuditLogs({ page: 1, perPage: 50 }); + return { + metrics: [{ label: "Audit entries", value: result.total }], + rows: result.rows.map((row) => ({ + id: String(row.id), + primary: `${row.username}: ${row.action}`, + secondary: row.target, + timestamp: row.createdAt, + })), + }; + } + + const { sql } = await import("drizzle-orm"); + if (view === "staff") { + const rows = await rawRows<{ + id: number; + action: string; + description: string; + username: string | null; + createdAt: string; + }>(sql` + SELECT sa.id, sa.action, sa.description, u.username, + sa.created_at AS createdAt + FROM staff_activities sa + LEFT JOIN users u ON u.id = sa.user_id + ORDER BY sa.id DESC LIMIT 50 + `); + return { + metrics: [{ label: "Staff activities", value: rows.length }], + rows: rows.map((row) => ({ + id: String(row.id), + primary: `${row.username ?? "Unknown"}: ${row.action}`, + secondary: row.description, + timestamp: row.createdAt, + })), + }; + } + + if (view === "chat") { + const rows = await rawRows<{ + id: number; + username: string | null; + message: string; + timestamp: number; + }>(sql` + SELECT c.id, u.username, c.message, c.timestamp + FROM chatlogs_room c + LEFT JOIN users u ON u.id = c.user_from_id + ORDER BY c.timestamp DESC LIMIT 50 + `); + return { + metrics: [{ label: "Chat entries", value: rows.length }], + rows: rows.map((row) => ({ + id: String(row.id), + primary: row.username ?? "Unknown", + secondary: row.message, + timestamp: row.timestamp, + })), + }; + } + + if (view === "commands") { + const rows = await rawRows<{ + id: number; + username: string | null; + command: string; + params: string; + timestamp: number; + }>(sql` + SELECT c.id, u.username, c.command, c.params, c.timestamp + FROM commandlogs c + LEFT JOIN users u ON u.id = c.user_id + ORDER BY c.timestamp DESC LIMIT 50 + `); + return { + metrics: [{ label: "Command entries", value: rows.length }], + rows: rows.map((row) => ({ + id: String(row.id), + primary: `${row.username ?? "Unknown"}: ${row.command}`, + secondary: row.params, + timestamp: row.timestamp, + })), + }; + } + + const rows = await rawRows<{ + id: number; + userOne: string | null; + userTwo: string | null; + timestamp: number; + }>(sql` + SELECT t.id, u1.username AS userOne, u2.username AS userTwo, t.timestamp + FROM room_trade_log t + LEFT JOIN users u1 ON u1.id = t.user_one_id + LEFT JOIN users u2 ON u2.id = t.user_two_id + ORDER BY t.timestamp DESC LIMIT 50 + `); + return { + metrics: [{ label: "Trade entries", value: rows.length }], + rows: rows.map((row) => ({ + id: String(row.id), + primary: `${row.userOne ?? "Unknown"} ↔ ${row.userTwo ?? "Unknown"}`, + secondary: "Completed", + timestamp: row.timestamp, + })), + }; + }, + async loadHealth() { + const { fetchOpsHealth } = await import("@/lib/admin/ops-health"); + return fetchOpsHealth(); + }, + async loadErrors() { + const { sql } = await import("drizzle-orm"); + const rows = await rawRows<{ + id: number; + type: string; + version: string; + stacktrace: Uint8Array | string; + timestamp: number; + }>(sql` + SELECT id, type, version, stacktrace, timestamp + FROM emulator_errors + ORDER BY id DESC LIMIT 50 + `); + return rows.map((row) => ({ + id: String(row.id), + primary: `${row.type} (${row.version})`, + secondary: + typeof row.stacktrace === "string" + ? row.stacktrace + : Buffer.from(row.stacktrace).toString("utf8"), + timestamp: row.timestamp, + })); + }, +}; + +export const systemObservabilityQuery = createSystemObservabilityQuery( + systemObservabilityAdapters, +); diff --git a/src/features/housekeeping/domains/system/queries/operations.ts b/src/features/housekeeping/domains/system/queries/operations.ts new file mode 100644 index 00000000..46ba68c7 --- /dev/null +++ b/src/features/housekeeping/domains/system/queries/operations.ts @@ -0,0 +1,246 @@ +import "server-only"; + +import { PERMS } from "@/lib/permission-slugs"; +import { authorizeHousekeeping } from "../../../foundation/authorization"; +import { + anyCapability, + fail, + type HousekeepingQuery, + ok, +} from "../../../foundation/contracts"; +import type { SystemHealthSnapshot } from "./observability"; + +export interface SystemAlertRow { + readonly id: number; + readonly severity: string; + readonly type: string; + readonly message: string; + readonly isRead: boolean; + readonly createdAt?: string | Date | null; +} + +export interface SystemOnlineRoster { + readonly count: number; + readonly users: readonly { + readonly id: number; + readonly username: string; + readonly roomId?: number | null; + readonly roomName?: string | null; + }[]; +} + +export interface SystemMaintenanceSnapshot { + readonly enabled: boolean; + readonly message: string; + readonly minimumLoginRank: number; +} + +export interface SystemOperationsAdapters { + loadAlerts(): Promise; + loadHealth(): Promise; + loadOnlineUsers(): Promise; + loadMaintenance(): Promise; +} + +export type SystemOperationsQueryInput = { + readonly routeId: + | "system.operations.alerts" + | "system.operations.command-center" + | "system.operations.maintenance"; +}; + +export type SystemOperationsQueryData = + | { + readonly kind: "alerts"; + readonly alerts: readonly SystemAlertRow[]; + readonly partialDependencies: readonly string[]; + } + | { + readonly kind: "command-center"; + readonly health: SystemHealthSnapshot | null; + readonly onlineUsers: SystemOnlineRoster; + readonly maintenance: SystemMaintenanceSnapshot | null; + readonly partialDependencies: readonly string[]; + } + | { + readonly kind: "maintenance"; + readonly maintenance: SystemMaintenanceSnapshot; + readonly partialDependencies: readonly string[]; + }; + +const broadCapability = anyCapability( + PERMS.NOTIFICATIONS_VIEW, + PERMS.RCON_EXECUTE, + PERMS.SETTINGS_VIEW, +); + +function capabilityForRoute(routeId: SystemOperationsQueryInput["routeId"]) { + if (routeId === "system.operations.alerts") { + return anyCapability(PERMS.NOTIFICATIONS_VIEW); + } + if (routeId === "system.operations.command-center") { + return anyCapability(PERMS.RCON_EXECUTE); + } + return anyCapability(PERMS.SETTINGS_VIEW); +} + +function unavailable(correlationId: string) { + return fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + correlationId, + ); +} + +export function createSystemOperationsQuery( + adapters: SystemOperationsAdapters, +): HousekeepingQuery { + return { + id: "system.operations.query", + owner: "system", + capability: broadCapability, + async run(context, input) { + const authorization = authorizeHousekeeping( + context, + capabilityForRoute(input.routeId), + ); + if (!authorization.ok) return authorization; + const correlationId = authorization.correlationId; + + if (input.routeId === "system.operations.alerts") { + try { + return ok( + { + kind: "alerts" as const, + alerts: await adapters.loadAlerts(), + partialDependencies: [], + }, + correlationId, + ); + } catch { + return unavailable(correlationId); + } + } + + if (input.routeId === "system.operations.maintenance") { + try { + return ok( + { + kind: "maintenance" as const, + maintenance: await adapters.loadMaintenance(), + partialDependencies: [], + }, + correlationId, + ); + } catch { + return unavailable(correlationId); + } + } + + const [healthResult, onlineResult, maintenanceResult] = + await Promise.allSettled([ + adapters.loadHealth(), + adapters.loadOnlineUsers(), + adapters.loadMaintenance(), + ]); + if ( + healthResult.status === "rejected" && + onlineResult.status === "rejected" && + maintenanceResult.status === "rejected" + ) { + return unavailable(correlationId); + } + + const partialDependencies: string[] = []; + if (healthResult.status === "rejected") + partialDependencies.push("health"); + if (onlineResult.status === "rejected") { + partialDependencies.push("online-users"); + } + if (maintenanceResult.status === "rejected") { + partialDependencies.push("maintenance"); + } + + return ok( + { + kind: "command-center", + health: + healthResult.status === "fulfilled" ? healthResult.value : null, + onlineUsers: + onlineResult.status === "fulfilled" + ? onlineResult.value + : { count: 0, users: [] }, + maintenance: + maintenanceResult.status === "fulfilled" + ? maintenanceResult.value + : null, + partialDependencies, + }, + correlationId, + ); + }, + }; +} + +export const systemOperationsAdapters: SystemOperationsAdapters = { + async loadAlerts() { + const [{ desc }, { AlertLogs, db }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + const rows = await db + .select({ + id: AlertLogs.id, + severity: AlertLogs.severity, + type: AlertLogs.type, + message: AlertLogs.message, + isRead: AlertLogs.isRead, + createdAt: AlertLogs.createdAt, + }) + .from(AlertLogs) + .orderBy(desc(AlertLogs.id)) + .limit(100); + return rows.map((row) => ({ ...row, id: Number(row.id) })); + }, + async loadHealth() { + const { fetchOpsHealth } = await import("@/lib/admin/ops-health"); + return fetchOpsHealth(); + }, + async loadOnlineUsers() { + const { fetchOpsOnlineUsers } = await import( + "@/lib/admin/ops-online-users" + ); + return fetchOpsOnlineUsers(40); + }, + async loadMaintenance() { + const [{ inArray }, { db, WebsiteSetting }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + const rows = await db + .select({ key: WebsiteSetting.key, value: WebsiteSetting.value }) + .from(WebsiteSetting) + .where( + inArray(WebsiteSetting.key, [ + "maintenance_enabled", + "maintenance_message", + "min_maintenance_login_rank", + ]), + ); + const values = new Map(rows.map((row) => [row.key, row.value])); + const enabled = values.get("maintenance_enabled") ?? "0"; + const parsedRank = Number.parseInt( + values.get("min_maintenance_login_rank") ?? "5", + 10, + ); + return { + enabled: enabled === "1" || enabled.toLowerCase() === "true", + message: values.get("maintenance_message") ?? "", + minimumLoginRank: Number.isFinite(parsedRank) ? parsedRank : 5, + }; + }, +}; + +export const systemOperationsQuery = createSystemOperationsQuery( + systemOperationsAdapters, +); diff --git a/src/features/housekeeping/domains/system/queries/system-queries.test.ts b/src/features/housekeeping/domains/system/queries/system-queries.test.ts new file mode 100644 index 00000000..c457d55d --- /dev/null +++ b/src/features/housekeeping/domains/system/queries/system-queries.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, vi } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +import type { HousekeepingCapabilityContext } from "../../../foundation/contracts"; +import { createSystemAccessQuery } from "./access"; +import { createSystemConfigurationQuery } from "./configuration"; +import { createSystemObservabilityQuery } from "./observability"; +import { createSystemOperationsQuery } from "./operations"; + +function context(granted: readonly string[]): HousekeepingCapabilityContext { + const permissions = new Set(granted); + return { + actor: { id: 42, username: "operator", rank: 99 }, + isSuperAdmin: false, + has: (slug) => permissions.has(slug), + hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)), + hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)), + }; +} + +describe("System access query", () => { + it("combines live rank and ACL data through injected adapters", async () => { + const query = createSystemAccessQuery({ + loadRanks: async () => [ + { id: 7, name: "Administrator", level: 7, userCount: 2 }, + ], + loadRankDetail: async () => null, + loadAclOverview: async () => ({ roles: 3, permissions: 18 }), + }); + + const result = await query.run(context([PERMS.PERMISSIONS_MANAGE]), { + routeId: "system.access.permissions", + }); + + expect(result).toMatchObject({ + ok: true, + data: { + kind: "permissions", + ranks: [{ id: 7, name: "Administrator", level: 7, userCount: 2 }], + acl: { roles: 3, permissions: 18 }, + partialDependencies: [], + }, + }); + }); + + it("fails closed before calling adapters and maps total dependency failure", async () => { + const loadRanks = vi.fn(async () => { + throw new Error("database offline"); + }); + const loadAclOverview = vi.fn(async () => { + throw new Error("acl offline"); + }); + const query = createSystemAccessQuery({ + loadRanks, + loadRankDetail: async () => null, + loadAclOverview, + }); + + const forbidden = await query.run(context([]), { + routeId: "system.access.permissions", + }); + expect(forbidden).toMatchObject({ + ok: false, + error: { code: "FORBIDDEN" }, + }); + expect(loadRanks).not.toHaveBeenCalled(); + + const unavailable = await query.run(context([PERMS.PERMISSIONS_MANAGE]), { + routeId: "system.access.permissions", + }); + expect(unavailable).toMatchObject({ + ok: false, + error: { code: "DEPENDENCY_UNAVAILABLE" }, + }); + }); +}); + +describe("System configuration query", () => { + it("returns usable emulator settings as partial when texts are unavailable", async () => { + const query = createSystemConfigurationQuery({ + loadWebsiteSettings: async () => [], + loadEmulatorSettings: async () => [{ key: "version", value: "1.0" }], + loadEmulatorTexts: async () => { + throw new Error("emulator_texts unavailable"); + }, + }); + + const result = await query.run(context([PERMS.SETTINGS_VIEW]), { + routeId: "system.configuration.emulator", + }); + + expect(result).toMatchObject({ + ok: true, + data: { + kind: "emulator", + settings: [{ key: "version", value: "1.0" }], + texts: [], + textsTotal: 0, + partialDependencies: ["emulator-texts"], + }, + }); + }); +}); + +describe("System observability query", () => { + it("keeps health evidence when the error feed is unavailable", async () => { + const query = createSystemObservabilityQuery({ + loadAnalytics: async () => ({ metrics: [], rows: [] }), + loadLogs: async () => ({ metrics: [], rows: [] }), + loadHealth: async () => ({ + dbOk: true, + dbLatencyMs: 4, + redisOk: null, + emulatorOk: true, + onlineUsers: 12, + }), + loadErrors: async () => { + throw new Error("errors table unavailable"); + }, + }); + + const result = await query.run(context([PERMS.DEVOPS_VIEW]), { + routeId: "system.observability.devops", + }); + + expect(result).toMatchObject({ + ok: true, + data: { + kind: "devops", + health: { dbOk: true, onlineUsers: 12 }, + errors: [], + partialDependencies: ["emulator-errors"], + }, + }); + }); +}); + +describe("System operations query", () => { + it("maps an unavailable command-center dependency set to a typed failure", async () => { + const down = async () => { + throw new Error("dependency unavailable"); + }; + const query = createSystemOperationsQuery({ + loadAlerts: down, + loadHealth: down, + loadOnlineUsers: down, + loadMaintenance: down, + }); + + const result = await query.run(context([PERMS.RCON_EXECUTE]), { + routeId: "system.operations.command-center", + }); + + expect(result).toMatchObject({ + ok: false, + error: { + code: "DEPENDENCY_UNAVAILABLE", + messageKey: "errors.housekeeping.dependencyUnavailable", + }, + }); + }); + + it("loads alert and maintenance workflows from their dedicated adapters", async () => { + const query = createSystemOperationsQuery({ + loadAlerts: async () => [ + { + id: 1, + severity: "warning", + type: "runtime", + message: "High load", + isRead: false, + }, + ], + loadHealth: async () => ({ + dbOk: true, + dbLatencyMs: 1, + redisOk: true, + emulatorOk: true, + onlineUsers: 1, + }), + loadOnlineUsers: async () => ({ count: 1, users: [] }), + loadMaintenance: async () => ({ + enabled: false, + message: "", + minimumLoginRank: 5, + }), + }); + + const alerts = await query.run(context([PERMS.NOTIFICATIONS_VIEW]), { + routeId: "system.operations.alerts", + }); + const maintenance = await query.run(context([PERMS.SETTINGS_VIEW]), { + routeId: "system.operations.maintenance", + }); + + expect(alerts).toMatchObject({ + ok: true, + data: { kind: "alerts", alerts: [{ message: "High load" }] }, + }); + expect(maintenance).toMatchObject({ + ok: true, + data: { + kind: "maintenance", + maintenance: { enabled: false, minimumLoginRank: 5 }, + }, + }); + }); +}); diff --git a/src/features/housekeeping/domains/system/route-handlers.ts b/src/features/housekeeping/domains/system/route-handlers.ts new file mode 100644 index 00000000..ff0ee1e9 --- /dev/null +++ b/src/features/housekeeping/domains/system/route-handlers.ts @@ -0,0 +1,26 @@ +import type { HousekeepingRouteHandler } from "../../route-handlers"; +import { renderSystemAccessPage } from "./pages/access"; +import { renderSystemConfigurationPage } from "./pages/configuration"; +import { renderSystemObservabilityPage } from "./pages/observability"; +import { renderSystemOperationsPage } from "./pages/operations"; +import { SYSTEM_ROUTE_IDS, type SystemRouteId } from "./routes"; + +function rendererFor( + routeId: SystemRouteId, +): HousekeepingRouteHandler["render"] { + if (routeId.startsWith("system.access.")) return renderSystemAccessPage; + if (routeId.startsWith("system.configuration.")) { + return renderSystemConfigurationPage; + } + if (routeId.startsWith("system.observability.")) { + return renderSystemObservabilityPage; + } + return renderSystemOperationsPage; +} + +export const SYSTEM_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] = + Object.freeze( + SYSTEM_ROUTE_IDS.map((routeId) => + Object.freeze({ routeId, render: rendererFor(routeId) }), + ), + ); diff --git a/src/features/housekeeping/domains/system/routes.test.ts b/src/features/housekeeping/domains/system/routes.test.ts new file mode 100644 index 00000000..2adea78d --- /dev/null +++ b/src/features/housekeeping/domains/system/routes.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +import { systemMigrationEntries } from "../../migration/system"; +import { SYSTEM_ROUTE_IDS, SYSTEM_ROUTES } from "./routes"; + +const expectedRoutes = [ + [ + "system.access.permissions", + "/ase/system/access/permissions", + "pages.admin.hubs.tabs.permissions", + PERMS.PERMISSIONS_MANAGE, + ], + [ + "system.access.permission-detail", + "/ase/system/access/permissions/:id", + "pages.admin.hubs.tabs.permissions", + PERMS.PERMISSIONS_MANAGE, + ], + [ + "system.configuration.settings", + "/ase/system/configuration/settings", + "pages.admin.hubs.tabs.cms", + PERMS.SETTINGS_VIEW, + ], + [ + "system.configuration.emulator", + "/ase/system/configuration/emulator", + "pages.admin.hubs.tabs.emulator", + PERMS.SETTINGS_VIEW, + ], + [ + "system.observability.analytics", + "/ase/system/observability/analytics", + "pages.admin.hubs.tabs.analytics", + PERMS.ANALYTICS_VIEW, + ], + [ + "system.observability.analytics-activity", + "/ase/system/observability/analytics/activity", + "pages.admin.hubs.tabs.activity", + PERMS.ANALYTICS_VIEW, + ], + [ + "system.observability.analytics-economy", + "/ase/system/observability/analytics/economy", + "pages.admin.hubs.tabs.economy", + PERMS.ANALYTICS_VIEW, + ], + [ + "system.observability.devops", + "/ase/system/observability/devops", + "pages.admin.hubs.tabs.devops", + PERMS.DEVOPS_VIEW, + ], + [ + "system.observability.devops-errors", + "/ase/system/observability/devops/errors", + "pages.admin.hubs.tabs.errors", + PERMS.DEVOPS_VIEW, + ], + [ + "system.observability.logs-staff", + "/ase/system/observability/logs/staff", + "pages.admin.hubs.tabs.logs", + PERMS.LOGS_VIEW, + ], + [ + "system.observability.logs-audit", + "/ase/system/observability/logs/audit", + "pages.admin.hubs.tabs.audit", + PERMS.LOGS_VIEW, + ], + [ + "system.observability.logs-chat", + "/ase/system/observability/logs/chat", + "pages.admin.hubs.tabs.chat", + PERMS.LOGS_VIEW, + ], + [ + "system.observability.logs-commands", + "/ase/system/observability/logs/commands", + "pages.admin.hubs.tabs.commands", + PERMS.LOGS_VIEW, + ], + [ + "system.observability.logs-trades", + "/ase/system/observability/logs/trades", + "pages.admin.hubs.tabs.trades", + PERMS.LOGS_VIEW, + ], + [ + "system.operations.alerts", + "/ase/system/operations/alerts", + "pages.admin.hubs.tabs.alerts", + PERMS.NOTIFICATIONS_VIEW, + ], + [ + "system.operations.command-center", + "/ase/system/operations/command-center", + "pages.admin.hubs.tabs.commando", + PERMS.RCON_EXECUTE, + ], + [ + "system.operations.maintenance", + "/ase/system/operations/maintenance", + "pages.admin.hubs.tabs.maintenance", + PERMS.SETTINGS_VIEW, + ], +] as const; + +describe("SYSTEM_ROUTES", () => { + it("declares the exact ordered System route IDs", () => { + expect(SYSTEM_ROUTE_IDS).toEqual(expectedRoutes.map(([id]) => id)); + expect(SYSTEM_ROUTES.map((route) => route.id)).toEqual(SYSTEM_ROUTE_IDS); + }); + + it("uses the canonical destinations, stable labels, and read capabilities", () => { + expect( + SYSTEM_ROUTES.map((route) => [ + route.id, + route.href, + route.labelKey, + route.capability.mode, + route.capability.slugs, + ]), + ).toEqual( + expectedRoutes.map(([id, href, labelKey, capability]) => [ + id, + href, + labelKey, + "any", + [capability], + ]), + ); + }); + + it("covers every non-removed System migration destination exactly once", () => { + const plannedTargets = systemMigrationEntries + .filter((entry) => entry.targetPath !== null) + .map((entry) => entry.targetPath) + .sort(); + const routeTargets = SYSTEM_ROUTES.map((route) => route.href).sort(); + + expect(routeTargets).toEqual(plannedTargets); + expect(new Set(routeTargets).size).toBe(routeTargets.length); + }); +}); diff --git a/src/features/housekeeping/domains/system/routes.ts b/src/features/housekeeping/domains/system/routes.ts new file mode 100644 index 00000000..62f5dd81 --- /dev/null +++ b/src/features/housekeeping/domains/system/routes.ts @@ -0,0 +1,132 @@ +import { PERMS } from "@/lib/permission-slugs"; +import { + anyCapability, + type HousekeepingRouteDefinition, +} from "../../foundation/contracts"; + +export const SYSTEM_ROUTE_IDS = [ + "system.access.permissions", + "system.access.permission-detail", + "system.configuration.settings", + "system.configuration.emulator", + "system.observability.analytics", + "system.observability.analytics-activity", + "system.observability.analytics-economy", + "system.observability.devops", + "system.observability.devops-errors", + "system.observability.logs-staff", + "system.observability.logs-audit", + "system.observability.logs-chat", + "system.observability.logs-commands", + "system.observability.logs-trades", + "system.operations.alerts", + "system.operations.command-center", + "system.operations.maintenance", +] as const; + +export type SystemRouteId = (typeof SYSTEM_ROUTE_IDS)[number]; + +export const SYSTEM_ROUTES = [ + { + id: "system.access.permissions", + labelKey: "pages.admin.hubs.tabs.permissions", + href: "/ase/system/access/permissions", + capability: anyCapability(PERMS.PERMISSIONS_MANAGE), + }, + { + id: "system.access.permission-detail", + labelKey: "pages.admin.hubs.tabs.permissions", + href: "/ase/system/access/permissions/:id", + capability: anyCapability(PERMS.PERMISSIONS_MANAGE), + }, + { + id: "system.configuration.settings", + labelKey: "pages.admin.hubs.tabs.cms", + href: "/ase/system/configuration/settings", + capability: anyCapability(PERMS.SETTINGS_VIEW), + }, + { + id: "system.configuration.emulator", + labelKey: "pages.admin.hubs.tabs.emulator", + href: "/ase/system/configuration/emulator", + capability: anyCapability(PERMS.SETTINGS_VIEW), + }, + { + id: "system.observability.analytics", + labelKey: "pages.admin.hubs.tabs.analytics", + href: "/ase/system/observability/analytics", + capability: anyCapability(PERMS.ANALYTICS_VIEW), + }, + { + id: "system.observability.analytics-activity", + labelKey: "pages.admin.hubs.tabs.activity", + href: "/ase/system/observability/analytics/activity", + capability: anyCapability(PERMS.ANALYTICS_VIEW), + }, + { + id: "system.observability.analytics-economy", + labelKey: "pages.admin.hubs.tabs.economy", + href: "/ase/system/observability/analytics/economy", + capability: anyCapability(PERMS.ANALYTICS_VIEW), + }, + { + id: "system.observability.devops", + labelKey: "pages.admin.hubs.tabs.devops", + href: "/ase/system/observability/devops", + capability: anyCapability(PERMS.DEVOPS_VIEW), + }, + { + id: "system.observability.devops-errors", + labelKey: "pages.admin.hubs.tabs.errors", + href: "/ase/system/observability/devops/errors", + capability: anyCapability(PERMS.DEVOPS_VIEW), + }, + { + id: "system.observability.logs-staff", + labelKey: "pages.admin.hubs.tabs.logs", + href: "/ase/system/observability/logs/staff", + capability: anyCapability(PERMS.LOGS_VIEW), + }, + { + id: "system.observability.logs-audit", + labelKey: "pages.admin.hubs.tabs.audit", + href: "/ase/system/observability/logs/audit", + capability: anyCapability(PERMS.LOGS_VIEW), + }, + { + id: "system.observability.logs-chat", + labelKey: "pages.admin.hubs.tabs.chat", + href: "/ase/system/observability/logs/chat", + capability: anyCapability(PERMS.LOGS_VIEW), + }, + { + id: "system.observability.logs-commands", + labelKey: "pages.admin.hubs.tabs.commands", + href: "/ase/system/observability/logs/commands", + capability: anyCapability(PERMS.LOGS_VIEW), + }, + { + id: "system.observability.logs-trades", + labelKey: "pages.admin.hubs.tabs.trades", + href: "/ase/system/observability/logs/trades", + capability: anyCapability(PERMS.LOGS_VIEW), + }, + { + id: "system.operations.alerts", + labelKey: "pages.admin.hubs.tabs.alerts", + href: "/ase/system/operations/alerts", + capability: anyCapability(PERMS.NOTIFICATIONS_VIEW), + }, + { + id: "system.operations.command-center", + labelKey: "pages.admin.hubs.tabs.commando", + href: "/ase/system/operations/command-center", + capability: anyCapability(PERMS.RCON_EXECUTE), + }, + { + id: "system.operations.maintenance", + labelKey: "pages.admin.hubs.tabs.maintenance", + href: "/ase/system/operations/maintenance", + capability: anyCapability(PERMS.SETTINGS_VIEW), + }, +] as const satisfies readonly HousekeepingRouteDefinition[]; diff --git a/src/features/housekeeping/domains/system/services/mutations-production.test.ts b/src/features/housekeeping/domains/system/services/mutations-production.test.ts new file mode 100644 index 00000000..57f376e7 --- /dev/null +++ b/src/features/housekeeping/domains/system/services/mutations-production.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +import type { HousekeepingCapabilityContext } from "../../../foundation/contracts"; + +const { markReadWhere, rconSend } = vi.hoisted(() => ({ + markReadWhere: vi.fn(), + rconSend: vi.fn(), +})); + +vi.mock("@/lib/db", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + db: { + ...actual.db, + update: vi.fn(() => ({ + set: vi.fn(() => ({ where: markReadWhere })), + })), + }, + }; +}); + +vi.mock("@/lib/services/rcon", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + rcon: { send: rconSend }, + }; +}); + +import { systemMutationService } from "./mutations"; + +function capabilityContext( + granted: readonly string[], +): HousekeepingCapabilityContext { + const permissions = new Set(granted); + return { + actor: { id: 42, username: "operator", rank: 500 }, + isSuperAdmin: false, + has: (slug) => permissions.has(slug), + hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)), + hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)), + }; +} + +describe("System production mutation failures", () => { + beforeEach(() => { + markReadWhere.mockReset(); + rconSend.mockReset(); + }); + + it("maps a failed alert broadcast to dependency unavailable", async () => { + rconSend.mockResolvedValue(false); + + const result = await systemMutationService.execute( + { + capability: capabilityContext([PERMS.NOTIFICATIONS_EDIT]), + correlationId: "broadcast-failure", + }, + "operations.alert.broadcast", + { message: "Hotel notice" }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { code: "DEPENDENCY_UNAVAILABLE" }, + correlationId: "broadcast-failure", + }); + }); + + it("maps a thrown alert broadcast to dependency unavailable", async () => { + rconSend.mockRejectedValue(new Error("RCON unavailable")); + + const result = await systemMutationService.execute( + { + capability: capabilityContext([PERMS.NOTIFICATIONS_EDIT]), + correlationId: "broadcast-error", + }, + "operations.alert.broadcast", + { message: "Hotel notice" }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { code: "DEPENDENCY_UNAVAILABLE" }, + correlationId: "broadcast-error", + }); + }); + + it("maps mark-read persistence failure to dependency unavailable", async () => { + markReadWhere.mockRejectedValue(new Error("database unavailable")); + + const result = await systemMutationService.execute( + { + capability: capabilityContext([PERMS.NOTIFICATIONS_VIEW]), + correlationId: "mark-read-error", + }, + "operations.alerts.mark-read", + {}, + ); + + expect(result).toMatchObject({ + ok: false, + error: { code: "DEPENDENCY_UNAVAILABLE" }, + correlationId: "mark-read-error", + }); + }); +}); diff --git a/src/features/housekeeping/domains/system/services/mutations.ts b/src/features/housekeeping/domains/system/services/mutations.ts new file mode 100644 index 00000000..cd4df23c --- /dev/null +++ b/src/features/housekeeping/domains/system/services/mutations.ts @@ -0,0 +1,763 @@ +import "server-only"; + +import { and, count, eq, inArray, sql } from "drizzle-orm"; +import type { ResultSetHeader } from "mysql2"; +import { MANAGED_SETTING_KEYS } from "@/app/admin/settings/cms-settings-config"; +import { + AclModelPermission, + AclModelRole, + AclPermission, + AclRole, + AlertLogs, + db, + EmulatorSettings, + EmulatorTexts, + User, + WebsiteSetting, +} from "@/lib/db"; +import { + HABBO_GAMEDATA_HOTEL_SETTING_KEY, + normalizeHabboGamedataHotel, +} from "@/lib/habbo-gamedata-hotel"; +import { PERMS } from "@/lib/permission-slugs"; +import { clearOfficialHabboFurnidataCache } from "@/lib/services/habbo-furnidata-cache"; +import { clearBadgeCache } from "@/lib/services/habboassets"; +import { + createEmulatorRank, + deleteEmulatorRank, + updateEmulatorRank, +} from "@/lib/services/permission-ranks"; +import { rcon } from "@/lib/services/rcon"; +import { siteSettings } from "@/lib/services/site-settings"; +import { logStaffActivity } from "@/lib/services/staff-activity"; +import { satisfiesCapability } from "../../../foundation/capability-context"; +import { + anyCapability, + fail, + type HousekeepingCapabilityContext, + type HousekeepingErrorCode, + type HousekeepingResult, + ok, +} from "../../../foundation/contracts"; + +export type SystemMutationOperation = + | "access.rank.create" + | "access.rank.delete" + | "access.rank.update" + | "access.permissions.update" + | "access.permissions.repair" + | "configuration.settings.save" + | "configuration.setting.create" + | "configuration.setting.update" + | "configuration.setting.delete" + | "configuration.emulator-setting.update" + | "configuration.emulator-text.update" + | "operations.alerts.mark-read" + | "operations.alert.broadcast" + | "rcon.update-catalog" + | "rcon.update-word-filter" + | "rcon.update-navigator" + | "rcon.hotel-alert" + | "rcon.disconnect-user" + | "rcon.alert-user" + | "rcon.forward-user" + | "rcon.give-credits" + | "rcon.give-duckets" + | "rcon.give-diamonds" + | "rcon.give-badge" + | "rcon.set-motto" + | "rcon.set-rank" + | "rcon.execute-command" + | "rcon.send-gift" + | "operations.maintenance.update"; + +export interface SystemMutationContext { + readonly capability: HousekeepingCapabilityContext; + readonly correlationId: string; +} + +export interface SystemMutationAdapter { + execute( + operation: SystemMutationOperation, + input: unknown, + context: SystemMutationContext, + ): Promise; +} + +export interface SystemMutationService { + execute( + context: SystemMutationContext, + operation: SystemMutationOperation, + input: unknown, + ): Promise>; +} + +class SystemMutationFailure extends Error { + constructor( + readonly code: HousekeepingErrorCode, + readonly messageKey: string, + readonly fieldErrors?: Readonly>, + ) { + super(messageKey); + this.name = "SystemMutationFailure"; + } +} + +function operationCapability(operation: SystemMutationOperation) { + if (operation.startsWith("access.")) { + return anyCapability(PERMS.PERMISSIONS_MANAGE); + } + if (operation.startsWith("configuration.")) { + return anyCapability(PERMS.SETTINGS_EDIT); + } + if (operation === "operations.alerts.mark-read") { + return anyCapability(PERMS.NOTIFICATIONS_VIEW); + } + if (operation === "operations.alert.broadcast") { + return anyCapability(PERMS.NOTIFICATIONS_EDIT); + } + if (operation.startsWith("rcon.")) { + return anyCapability(PERMS.RCON_EXECUTE); + } + return anyCapability(PERMS.SETTINGS_EDIT); +} + +export function createSystemMutationService( + adapter: SystemMutationAdapter, +): SystemMutationService { + return { + async execute(context, operation, input) { + if ( + !satisfiesCapability(context.capability, operationCapability(operation)) + ) { + return fail( + "FORBIDDEN", + "errors.housekeeping.forbidden", + context.correlationId, + ); + } + + try { + return ok( + await adapter.execute(operation, input, context), + context.correlationId, + ); + } catch (error) { + if (error instanceof SystemMutationFailure) { + return fail( + error.code, + error.messageKey, + context.correlationId, + error.fieldErrors, + ); + } + return fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + context.correlationId, + ); + } + }, + }; +} + +function record(input: unknown): Record { + if (typeof input !== "object" || input === null || Array.isArray(input)) { + throw new SystemMutationFailure( + "VALIDATION", + "errors.housekeeping.validation", + ); + } + return input as Record; +} + +function text(value: unknown, maxLength: number, trim = true): string { + const normalized = String(value ?? "").normalize("NFC"); + return (trim ? normalized.trim() : normalized).slice(0, maxLength); +} + +function positiveInteger(value: unknown): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new SystemMutationFailure( + "VALIDATION", + "errors.housekeeping.validation", + ); + } + return parsed; +} + +function normalizeSettingValue(key: string, value: string): string { + return key === HABBO_GAMEDATA_HOTEL_SETTING_KEY + ? normalizeHabboGamedataHotel(value) + : value; +} + +function bustGamedataCachesIfNeeded(key: string): void { + if (key !== HABBO_GAMEDATA_HOTEL_SETTING_KEY) return; + clearOfficialHabboFurnidataCache(); + clearBadgeCache(); +} + +async function requireRcon(result: boolean): Promise { + if (!result) { + throw new SystemMutationFailure( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + ); + } +} + +async function upsertWebsiteSetting( + key: string, + value: string, + comment?: string | null, +): Promise { + await db + .insert(WebsiteSetting) + .values({ key, value, ...(comment === undefined ? {} : { comment }) }) + .onDuplicateKeyUpdate({ set: { value } }); +} + +async function executeAccessMutation( + operation: Extract, + input: unknown, + context: SystemMutationContext, +): Promise { + const data = record(input); + if (operation === "access.rank.create") { + const name = text(data.name, 25); + const level = positiveInteger(data.level); + const id = await createEmulatorRank(db, { rank_name: name, level }); + await db + .insert(AclRole) + .values({ + slug: `rank_${id}`, + title: name, + description: "CMS role synchronized from permission_ranks", + }) + .onDuplicateKeyUpdate({ set: { title: name } }); + await logStaffActivity({ + staffId: context.capability.actor.id, + action: "rank_create", + description: `Created rank #${id}`, + targetType: "rank", + targetId: id, + }); + await rcon.send("updatepermissions"); + return { id }; + } + + const id = positiveInteger(data.id); + if (operation === "access.rank.delete") { + const [userCount] = await db + .select({ total: count() }) + .from(User) + .where(eq(User.rank, id)); + const users = Number(userCount?.total ?? 0); + if (users > 0) { + throw new SystemMutationFailure( + "CONFLICT", + "errors.housekeeping.system.rankInUse", + { rank: [String(users)] }, + ); + } + const [role] = await db + .select({ id: AclRole.id }) + .from(AclRole) + .where(eq(AclRole.slug, `rank_${id}`)) + .limit(1); + await deleteEmulatorRank(db, id); + if (role) { + await db.transaction(async (tx) => { + await tx + .delete(AclModelPermission) + .where( + and( + eq(AclModelPermission.modelId, role.id), + eq(AclModelPermission.modelType, "Role"), + ), + ); + await tx.delete(AclModelRole).where(eq(AclModelRole.roleId, role.id)); + await tx.delete(AclRole).where(eq(AclRole.id, role.id)); + }); + } + await logStaffActivity({ + staffId: context.capability.actor.id, + action: "rank_delete", + description: `Deleted rank #${id}`, + targetType: "rank", + targetId: id, + }); + await rcon.send("updatepermissions"); + return null; + } + + if (operation === "access.rank.update") { + const fields = record(data.fields); + const normalizedFields = Object.fromEntries( + Object.entries(fields).flatMap(([key, value]) => + typeof value === "string" || typeof value === "number" + ? [[key, value] as const] + : [], + ), + ); + await updateEmulatorRank(db, id, normalizedFields); + if (typeof normalizedFields.rank_name === "string") { + await db + .update(AclRole) + .set({ title: normalizedFields.rank_name }) + .where(eq(AclRole.slug, `rank_${id}`)); + } + await logStaffActivity({ + staffId: context.capability.actor.id, + action: "rank_update", + description: `Updated rank #${id}`, + targetType: "rank", + targetId: id, + }); + await rcon.send("updatepermissions"); + return null; + } + + if (operation === "access.permissions.update") { + const roleId = positiveInteger(data.roleId); + const slugs = Array.isArray(data.permissionSlugs) + ? data.permissionSlugs.map((slug) => text(slug, 160)).filter(Boolean) + : []; + const [role] = await db + .select({ id: AclRole.id, slug: AclRole.slug }) + .from(AclRole) + .where(eq(AclRole.id, roleId)) + .limit(1); + if (!role) { + throw new SystemMutationFailure( + "NOT_FOUND", + "errors.housekeeping.system.roleNotFound", + ); + } + const permissions = slugs.length + ? await db + .select({ id: AclPermission.id }) + .from(AclPermission) + .where(inArray(AclPermission.slug, slugs)) + : []; + await db.transaction(async (tx) => { + await tx + .delete(AclModelPermission) + .where( + and( + eq(AclModelPermission.modelId, role.id), + eq(AclModelPermission.modelType, "Role"), + ), + ); + if (permissions.length) { + await tx.insert(AclModelPermission).values( + permissions.map((permission) => ({ + modelId: role.id, + modelType: "Role", + permissionId: permission.id, + })), + ); + } + }); + await logStaffActivity({ + staffId: context.capability.actor.id, + action: "acl_role_permissions_update", + description: `Updated ${permissions.length} permissions for ${role.slug}`, + targetType: "acl_role", + targetId: role.id, + }); + return { updated: permissions.length }; + } + + const [dashboardFillResult] = await db.execute(sql` + INSERT INTO acl_model_permissions (model_type, model_id, permission_id) + SELECT 'Role', ar.id, ap.id + FROM acl_roles ar + JOIN acl_permissions ap ON ap.slug LIKE 'admin.%' + WHERE EXISTS ( + SELECT 1 FROM acl_model_permissions amp + JOIN acl_permissions apdash ON apdash.id = amp.permission_id + WHERE amp.model_type = 'Role' AND amp.model_id = ar.id + AND apdash.slug = 'admin.dashboard' + ) + AND NOT EXISTS ( + SELECT 1 FROM acl_model_permissions amp2 + WHERE amp2.model_type = 'Role' AND amp2.model_id = ar.id + AND amp2.permission_id = ap.id + ) + `); + const [midRankViewsResult] = await db.execute(sql` + INSERT INTO acl_model_permissions (model_type, model_id, permission_id) + SELECT 'Role', ar.id, ap.id + FROM permission_ranks pr + JOIN acl_roles ar ON ar.slug = CONCAT('rank_', pr.id) + JOIN acl_permissions ap ON ( + ap.slug = 'admin.dashboard' + OR (ap.slug LIKE 'admin.%' AND ap.slug LIKE '%.view') + ) + WHERE pr.id >= 6 + AND NOT EXISTS ( + SELECT 1 + FROM acl_model_permissions amp + WHERE amp.model_type = 'Role' + AND amp.model_id = ar.id + AND amp.permission_id = ap.id + ) + `); + const [highRankToolsResult] = await db.execute(sql` + INSERT INTO acl_model_permissions (model_type, model_id, permission_id) + SELECT 'Role', ar.id, ap.id + FROM permission_ranks pr + JOIN acl_roles ar ON ar.slug = CONCAT('rank_', pr.id) + JOIN acl_permissions ap ON ( + (ap.slug LIKE 'admin.%' AND ap.slug LIKE '%.edit') + OR ap.slug IN ( + 'admin.permissions.manage', + 'admin.rcon.execute', + 'admin.assets.import', + 'admin.export', + 'admin.analytics.export', + 'admin.users.ban', + 'admin.users.reset_password', + 'admin.room.delete' + ) + ) + WHERE pr.id >= 7 + AND NOT EXISTS ( + SELECT 1 + FROM acl_model_permissions amp + WHERE amp.model_type = 'Role' + AND amp.model_id = ar.id + AND amp.permission_id = ap.id + ) + `); + const inserted = + Number((dashboardFillResult as ResultSetHeader).affectedRows) + + Number((midRankViewsResult as ResultSetHeader).affectedRows) + + Number((highRankToolsResult as ResultSetHeader).affectedRows); + await logStaffActivity({ + staffId: context.capability.actor.id, + action: "acl_nav_grants_repair", + description: `Repaired admin nav ACL grants (${inserted} rows inserted)`, + targetType: "acl", + targetId: 0, + }); + return { inserted }; +} + +async function executeConfigurationMutation( + operation: Extract, + input: unknown, +): Promise { + const data = record(input); + if (operation === "configuration.settings.save") { + const settings = record(data.settings); + const allowedKeys = new Set(MANAGED_SETTING_KEYS); + const entries = Object.entries(settings).flatMap(([key, value]) => + allowedKeys.has(key) && typeof value === "string" + ? [[key, normalizeSettingValue(key, value)] as const] + : [], + ); + await Promise.all( + entries.map(([key, value]) => upsertWebsiteSetting(key, value)), + ); + await siteSettings.reload(); + if (entries.some(([key]) => key === HABBO_GAMEDATA_HOTEL_SETTING_KEY)) { + clearOfficialHabboFurnidataCache(); + clearBadgeCache(); + } + return { saved: entries.length }; + } + + if (operation === "configuration.emulator-setting.update") { + const key = text(data.key, 100); + if (!key) { + throw new SystemMutationFailure( + "VALIDATION", + "errors.housekeeping.validation", + ); + } + const value = text(data.value, 512, false); + await db + .insert(EmulatorSettings) + .values({ key, value }) + .onDuplicateKeyUpdate({ set: { value } }); + return null; + } + + if (operation === "configuration.emulator-text.update") { + const key = text(data.key, 100); + if (!key) { + throw new SystemMutationFailure( + "VALIDATION", + "errors.housekeeping.validation", + ); + } + const value = text(data.value, 4096, false); + await db + .insert(EmulatorTexts) + .values({ key, value }) + .onDuplicateKeyUpdate({ set: { value } }); + return null; + } + + const key = text(data.key, 255); + if (!key) { + throw new SystemMutationFailure( + "VALIDATION", + "errors.housekeeping.validation", + ); + } + if (operation === "configuration.setting.delete") { + await db.delete(WebsiteSetting).where(eq(WebsiteSetting.key, key)); + await siteSettings.reload(); + bustGamedataCachesIfNeeded(key); + return null; + } + + const value = normalizeSettingValue(key, text(data.value, 65_535, false)); + const comment = + operation === "configuration.setting.create" + ? text(data.comment, 255) || null + : undefined; + await upsertWebsiteSetting(key, value, comment); + await siteSettings.reload(); + bustGamedataCachesIfNeeded(key); + return null; +} + +async function executeRconMutation( + operation: Extract, + input: unknown, + context: SystemMutationContext, +): Promise { + const data = record(input); + switch (operation) { + case "rcon.update-catalog": + await requireRcon(await rcon.updateCatalog()); + break; + case "rcon.update-word-filter": + await requireRcon(await rcon.updateWordFilter()); + break; + case "rcon.update-navigator": + await requireRcon(await rcon.send("updatenavigator", null)); + break; + case "rcon.hotel-alert": + await requireRcon( + await rcon.send("hotelalert", { message: text(data.message, 512) }), + ); + break; + case "rcon.disconnect-user": + await requireRcon( + await rcon.disconnectUser( + positiveInteger(data.userId), + text(data.username, 255), + ), + ); + break; + case "rcon.alert-user": + await requireRcon( + await rcon.alertUser( + positiveInteger(data.userId), + text(data.message, 512), + ), + ); + break; + case "rcon.forward-user": + await requireRcon( + await rcon.forwardUser( + positiveInteger(data.userId), + positiveInteger(data.roomId), + ), + ); + break; + case "rcon.give-credits": + await requireRcon( + await rcon.giveCredits( + positiveInteger(data.userId), + positiveInteger(data.amount), + ), + ); + break; + case "rcon.give-duckets": + await requireRcon( + await rcon.giveDuckets( + positiveInteger(data.userId), + positiveInteger(data.amount), + ), + ); + break; + case "rcon.give-diamonds": + await requireRcon( + await rcon.giveDiamonds( + positiveInteger(data.userId), + positiveInteger(data.amount), + ), + ); + break; + case "rcon.give-badge": + await requireRcon( + await rcon.giveBadge( + positiveInteger(data.userId), + text(data.badge, 32), + ), + ); + break; + case "rcon.set-motto": + await requireRcon( + await rcon.setMotto( + positiveInteger(data.userId), + text(data.motto, 127), + ), + ); + break; + case "rcon.set-rank": { + const userId = positiveInteger(data.userId); + const rank = positiveInteger(data.rank); + const [target] = await db + .select({ rank: User.rank }) + .from(User) + .where(eq(User.id, userId)) + .limit(1); + if (!target) { + throw new SystemMutationFailure( + "NOT_FOUND", + "errors.housekeeping.system.userNotFound", + ); + } + let rankRows: { id: number }[] = []; + try { + const [rows] = await db.execute( + sql`SELECT id FROM permission_ranks WHERE id = ${rank} LIMIT 1`, + ); + rankRows = rows as unknown as { id: number }[]; + } catch { + rankRows = []; + } + if (rankRows.length === 0) { + throw new SystemMutationFailure( + "NOT_FOUND", + "errors.housekeeping.system.rankNotFound", + ); + } + if (!context.capability.isSuperAdmin) { + const actorRank = context.capability.actor.rank; + if (target.rank >= actorRank) { + throw new SystemMutationFailure( + "FORBIDDEN", + "errors.housekeeping.system.cannotChangePeerRank", + ); + } + if (rank >= actorRank) { + throw new SystemMutationFailure( + "FORBIDDEN", + "errors.housekeeping.system.cannotAssignPeerRank", + ); + } + } + await requireRcon(await rcon.setRank(userId, rank)); + await db.update(User).set({ rank }).where(eq(User.id, userId)); + break; + } + case "rcon.execute-command": + await requireRcon( + await rcon.executeCommand( + positiveInteger(data.userId), + text(data.command, 100), + ), + ); + break; + case "rcon.send-gift": + await requireRcon( + await rcon.sendGift( + positiveInteger(data.userId), + positiveInteger(data.itemId), + text(data.message || "Here is a gift.", 255) || "Here is a gift.", + ), + ); + break; + } + return null; +} + +const systemProductionMutationAdapter: SystemMutationAdapter = { + async execute(operation, input, context) { + if (operation.startsWith("access.")) { + return executeAccessMutation( + operation as Extract, + input, + context, + ); + } + if (operation.startsWith("configuration.")) { + return executeConfigurationMutation( + operation as Extract< + SystemMutationOperation, + `configuration.${string}` + >, + input, + ); + } + if (operation.startsWith("rcon.")) { + return executeRconMutation( + operation as Extract, + input, + context, + ); + } + + if (operation === "operations.alerts.mark-read") { + await db + .update(AlertLogs) + .set({ isRead: true, updatedAt: new Date() }) + .where(eq(AlertLogs.isRead, false)); + return null; + } + if (operation === "operations.alert.broadcast") { + const message = text(record(input).message, 1000); + if (!message) { + throw new SystemMutationFailure( + "VALIDATION", + "errors.housekeeping.validation", + ); + } + await requireRcon(await rcon.send("hotelalert", { message })); + return { delivered: true }; + } + + const data = record(input); + const enabled = Boolean(data.enabled); + const message = text(data.message, 65_535, false); + const parsedRank = Number(data.minimumLoginRank); + const minimumLoginRank = + Number.isInteger(parsedRank) && parsedRank >= 0 ? parsedRank : 5; + const rows = [ + [ + "maintenance_enabled", + enabled ? "1" : "0", + "Determines whether maintenance is enabled or not", + ], + [ + "maintenance_message", + message, + "The maintenance message displayed to users while maintenance is activated", + ], + [ + "min_maintenance_login_rank", + String(minimumLoginRank), + "The minimum rank required to login to the hotel during maintenance", + ], + ] as const; + for (const [key, value, comment] of rows) { + await upsertWebsiteSetting(key, value, comment); + } + await siteSettings.reload(); + return null; + }, +}; + +export const systemMutationService = createSystemMutationService( + systemProductionMutationAdapter, +); diff --git a/src/features/housekeeping/foundation/commands/bootstrap.test.ts b/src/features/housekeeping/foundation/commands/bootstrap.test.ts index 748c92ce..a061fa35 100644 --- a/src/features/housekeeping/foundation/commands/bootstrap.test.ts +++ b/src/features/housekeeping/foundation/commands/bootstrap.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; +import { SYSTEM_COMMAND_IDS } from "../../domains/system/commands/system-commands"; import { anyCapability, ok } from "../contracts"; import { defineHousekeepingCommands, @@ -7,6 +8,7 @@ import { registerHousekeepingCommands, } from "./bootstrap"; import { + getHousekeepingCommand, type HousekeepingCommand, registerHousekeepingCommand, } from "./registry"; @@ -55,6 +57,9 @@ describe("housekeeping command bootstrap", () => { }); it("registers the complete current list and seals during module initialization", () => { expect(housekeepingCommandRegistryReady).toBe(true); + expect( + SYSTEM_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id), + ).toEqual(SYSTEM_COMMAND_IDS); expect(() => registerHousekeepingCommand({ id: "system.bootstrap.too-late", diff --git a/src/features/housekeeping/foundation/commands/bootstrap.ts b/src/features/housekeeping/foundation/commands/bootstrap.ts index 19a4ac02..5a014a3a 100644 --- a/src/features/housekeeping/foundation/commands/bootstrap.ts +++ b/src/features/housekeeping/foundation/commands/bootstrap.ts @@ -1,5 +1,6 @@ import "server-only"; +import { SYSTEM_COMMANDS } from "../../domains/system/commands/system-commands"; import type { HousekeepingCommand } from "./registry"; import { registerHousekeepingCommand, @@ -34,7 +35,9 @@ export function registerHousekeepingCommands< } } -const currentHousekeepingCommands = defineHousekeepingCommands(); +const currentHousekeepingCommands = defineHousekeepingCommands( + ...SYSTEM_COMMANDS, +); registerHousekeepingCommands(currentHousekeepingCommands); sealHousekeepingCommandRegistry(); diff --git a/src/features/housekeeping/foundation/foundation-source-contract.test.ts b/src/features/housekeeping/foundation/foundation-source-contract.test.ts index 561edd00..974b0d48 100644 --- a/src/features/housekeeping/foundation/foundation-source-contract.test.ts +++ b/src/features/housekeeping/foundation/foundation-source-contract.test.ts @@ -4,6 +4,7 @@ import { join, posix } from "node:path"; import { createElement, type ReactElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; +import { SYSTEM_ROUTE_IDS } from "../domains/system/routes"; import { HOUSEKEEPING_MANIFESTS } from "../manifests"; import { discoverLegacyPages } from "../migration/discover-legacy-pages"; import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix"; @@ -17,6 +18,81 @@ const SERVER_CAPABILITY_CONTEXT = "src/features/housekeeping/foundation/server-capability-context.ts"; const PERMISSIONS_ADAPTER = "src/lib/permissions"; const DOMAIN_MODULE_ROOT = "src/features/housekeeping/domains"; +const approvedSystemRuntimeImports = new Map>([ + [ + "src/features/housekeeping/domains/system/commands/system-commands.ts", + new Set(["src/features/housekeeping/domains/system/services/mutations"]), + ], + [ + "src/features/housekeeping/domains/system/pages/access.tsx", + new Set(["src/features/housekeeping/domains/system/queries/access"]), + ], + [ + "src/features/housekeeping/domains/system/pages/configuration.tsx", + new Set(["src/features/housekeeping/domains/system/queries/configuration"]), + ], + [ + "src/features/housekeeping/domains/system/pages/observability.tsx", + new Set(["src/features/housekeeping/domains/system/queries/observability"]), + ], + [ + "src/features/housekeeping/domains/system/pages/operations.tsx", + new Set(["src/features/housekeeping/domains/system/queries/operations"]), + ], + [ + "src/features/housekeeping/domains/system/queries/access.ts", + new Set(["src/lib/db", "drizzle-orm"]), + ], + [ + "src/features/housekeeping/domains/system/queries/configuration.ts", + new Set(["src/lib/db", "drizzle-orm"]), + ], + [ + "src/features/housekeeping/domains/system/queries/observability.ts", + new Set(["src/lib/db", "drizzle-orm"]), + ], + [ + "src/features/housekeeping/domains/system/queries/operations.ts", + new Set([ + "src/features/housekeeping/domains/system/queries/observability", + "src/lib/db", + "drizzle-orm", + ]), + ], + [ + "src/features/housekeeping/domains/system/services/mutations.ts", + new Set([ + "src/app/admin/settings/cms-settings-config", + "src/lib/db", + "drizzle-orm", + "mysql2", + ]), + ], + [ + "src/features/housekeeping/domains/system/manifest.ts", + new Set(["src/features/housekeeping/domains/system/routes"]), + ], + [ + "src/features/housekeeping/domains/system/route-handlers.ts", + new Set([ + "src/features/housekeeping/domains/system/pages/access", + "src/features/housekeeping/domains/system/pages/configuration", + "src/features/housekeeping/domains/system/pages/observability", + "src/features/housekeeping/domains/system/pages/operations", + "src/features/housekeeping/domains/system/routes", + ]), + ], + [ + "src/features/housekeeping/foundation/commands/bootstrap.ts", + new Set([ + "src/features/housekeeping/domains/system/commands/system-commands", + ]), + ], + [ + "src/features/housekeeping/route-handlers.ts", + new Set(["src/features/housekeeping/domains/system/route-handlers"]), + ], +]); const forbiddenModuleRoots = [ "src/lib/db", "src/lib/db-pool", @@ -358,6 +434,17 @@ function isAllowedPermissionSetTypeImport( ); } +function isApprovedSystemRuntimeImport( + canonical: CanonicalModuleSpecifier, + sourceFile: string, +): boolean { + const allowed = approvedSystemRuntimeImports.get(sourceFile); + return ( + allowed !== undefined && + canonical.candidates.some((candidate) => allowed.has(candidate)) + ); +} + function findHousekeepingImportBoundaryViolations( source: string, sourceFile: string, @@ -370,6 +457,7 @@ function findHousekeepingImportBoundaryViolations( if (canonical.violation) violations.push(canonical.violation); if (isAllowedPermissionSetTypeImport(access, canonical, sourceFile)) continue; + if (isApprovedSystemRuntimeImport(canonical, sourceFile)) continue; const forbiddenPath = canonical.candidates.find((candidate) => isForbiddenModulePath(candidate, sourceFile), ); @@ -398,6 +486,33 @@ describe("housekeeping runtime import boundary", () => { } }); + it("allows only the approved System vertical runtime edges", () => { + expect( + findHousekeepingImportBoundaryViolations( + 'import { systemMutationService } from "../services/mutations";', + "src/features/housekeeping/domains/system/commands/system-commands.ts", + ), + ).toEqual([]); + expect( + findHousekeepingImportBoundaryViolations( + 'import { db } from "@/lib/db";', + "src/features/housekeeping/domains/system/queries/access.ts", + ), + ).toEqual([]); + expect( + findHousekeepingImportBoundaryViolations( + 'import { db } from "@/lib/db";', + "src/features/housekeeping/domains/system/commands/system-commands.ts", + ), + ).toEqual(["src/lib/db"]); + expect( + findHousekeepingImportBoundaryViolations( + 'import service from "../system/services/mutations";', + "src/features/housekeeping/domains/people/manifest.ts", + ), + ).toEqual(["src/features/housekeeping/domains/system/services/mutations"]); + }); + it.each([ [ "aliased database import", @@ -561,7 +676,7 @@ describe("housekeeping foundation completion contracts", () => { } }); - it("creates the real six-domain registry in locked order without workflows", () => { + it("creates the real six-domain registry with only the System routes enabled", () => { const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS); expect(registry.domains.map((domain) => domain.id)).toEqual([ @@ -572,9 +687,16 @@ describe("housekeeping foundation completion contracts", () => { "hotel", "system", ]); - expect(registry.domains.every((domain) => domain.routes.length === 0)).toBe( - true, - ); + expect( + registry.domains + .filter((domain) => domain.id !== "system") + .every((domain) => domain.routes.length === 0), + ).toBe(true); + expect( + registry.domains + .find((domain) => domain.id === "system") + ?.routes.map((route) => route.id), + ).toEqual(SYSTEM_ROUTE_IDS); }); it("keeps production preview disabled even when the flag is true", () => { diff --git a/src/features/housekeeping/foundation/registry.test.ts b/src/features/housekeeping/foundation/registry.test.ts index 8c4d01ba..250c5400 100644 --- a/src/features/housekeeping/foundation/registry.test.ts +++ b/src/features/housekeeping/foundation/registry.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { PERMS } from "@/lib/permission-slugs"; +import { SYSTEM_ROUTES } from "../domains/system/routes"; import { HOUSEKEEPING_MANIFESTS } from "../manifests"; import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix"; import { @@ -353,7 +354,9 @@ describe("housekeeping registry", () => { labelKey: expected.labelKey, descriptionKey: expected.descriptionKey, }); - expect(actual.routes).toEqual([]); + expect(actual.routes).toEqual( + expected.id === "system" ? SYSTEM_ROUTES : [], + ); expect(actual.searchProviders).toEqual([]); expect(actual.inboxSources).toEqual([]); expect(actual.widgets).toEqual([]); diff --git a/src/features/housekeeping/route-handlers.test.ts b/src/features/housekeeping/route-handlers.test.ts index 7756aad8..3a0183d6 100644 --- a/src/features/housekeeping/route-handlers.test.ts +++ b/src/features/housekeeping/route-handlers.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { SYSTEM_ROUTE_IDS } from "./domains/system/routes"; import { createHousekeepingRegistry } from "./foundation/registry"; import { HOUSEKEEPING_MANIFESTS } from "./manifests"; import { HOUSEKEEPING_ROUTE_HANDLERS } from "./route-handlers"; @@ -15,6 +16,6 @@ describe("housekeeping route handlers", () => { expect(new Set(handlerIds).size).toBe(handlerIds.length); expect([...handlerIds].sort()).toEqual([...routeIds].sort()); - expect(handlerIds).toEqual([]); + expect(handlerIds).toEqual(SYSTEM_ROUTE_IDS); }); }); diff --git a/src/features/housekeeping/route-handlers.ts b/src/features/housekeeping/route-handlers.ts index 3f0dbf69..10cfe2da 100644 --- a/src/features/housekeeping/route-handlers.ts +++ b/src/features/housekeeping/route-handlers.ts @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { SYSTEM_ROUTE_HANDLERS } from "./domains/system/route-handlers"; import type { HousekeepingCapabilityContext } from "./foundation/contracts"; import type { HousekeepingRouteMatch } from "./foundation/routing/match-route"; @@ -13,4 +14,4 @@ export interface HousekeepingRouteHandler { } export const HOUSEKEEPING_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] = - Object.freeze([]); + Object.freeze([...SYSTEM_ROUTE_HANDLERS]); diff --git a/src/lib/admin/acl-management-contract.test.ts b/src/lib/admin/acl-management-contract.test.ts index 6703311b..e5200968 100644 --- a/src/lib/admin/acl-management-contract.test.ts +++ b/src/lib/admin/acl-management-contract.test.ts @@ -3,12 +3,23 @@ import { describe, expect, it } from "vitest"; describe("ACL management contract", () => { it("uses normalized ACL persistence and the permissions.manage guard", () => { - const source = readFileSync("src/actions/permissions.ts", "utf8"); - expect(source).toContain("PERMS.PERMISSIONS_MANAGE"); - expect(source).toContain("adminAction"); - expect(source).toContain("AclModelPermission"); - expect(source).not.toContain("websiteHousekeepingPermissions"); - expect(source).not.toContain("websiteTeams"); + const wrapper = readFileSync("src/actions/permissions.ts", "utf8"); + const service = readFileSync( + "src/features/housekeeping/domains/system/services/mutations.ts", + "utf8", + ); + expect(wrapper).toContain("PERMS.PERMISSIONS_MANAGE"); + expect(wrapper).toContain("adminAction"); + expect(wrapper).toContain("access.permissions.update"); + expect(wrapper).toContain("access.permissions.repair"); + expect(service).toContain('import "server-only"'); + expect(service).toContain("AclModelPermission"); + expect(service).not.toContain( + "export const systemProductionMutationAdapter", + ); + expect(service).not.toContain("legacyMessage"); + expect(service).not.toContain("websiteHousekeepingPermissions"); + expect(service).not.toContain("websiteTeams"); }); it("ships an idempotent ACL completion migration", () => {