From 1360b0ed59d107153eacb5c730f75c98b2774342 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 5 Sep 2026 17:02:12 +0200 Subject: [PATCH] feat(housekeeping): make system mutations atomic --- src/actions/admin-alerts.test.ts | 16 +- src/actions/admin-alerts.ts | 26 +- src/actions/admin-emulator.ts | 24 +- src/actions/admin-maintenance.test.ts | 100 +- src/actions/admin-maintenance.ts | 39 +- src/actions/admin-settings.ts | 93 +- src/actions/commandocentrum.ts | 113 ++- src/actions/emulator.test.ts | 25 +- src/actions/emulator.ts | 26 +- src/actions/permissions.ts | 213 +---- src/actions/system-legacy-adapters.test.ts | 94 ++ .../system/commands/system-commands.test.ts | 2 + .../system/commands/system-commands.ts | 4 +- .../services/mutations-production.test.ts | 38 +- .../domains/system/services/mutations.ts | 861 +++++++++++++----- .../rank-mutations-production.test.ts | 67 ++ .../system/services/system-atomic.test.ts | 139 +++ .../foundation/commands/dispatcher.test.ts | 30 + .../foundation/commands/dispatcher.ts | 1 + src/lib/admin/acl-management-contract.test.ts | 17 +- src/lib/services/rank-entrypoints.test.ts | 30 +- 21 files changed, 1223 insertions(+), 735 deletions(-) create mode 100644 src/actions/system-legacy-adapters.test.ts create mode 100644 src/features/housekeeping/domains/system/services/system-atomic.test.ts diff --git a/src/actions/admin-alerts.test.ts b/src/actions/admin-alerts.test.ts index 253f3831..75b1d3c8 100644 --- a/src/actions/admin-alerts.test.ts +++ b/src/actions/admin-alerts.test.ts @@ -2,9 +2,13 @@ import { revalidatePath } from "next/cache"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { requirePermission } from "@/lib/admin/guard"; -import { rcon } from "@/lib/services/rcon"; import { sendHotelAlert } from "./admin-alerts"; +const { executeSystem } = vi.hoisted(() => ({ executeSystem: vi.fn() })); +vi.mock("@/features/housekeeping/domains/system/services/mutations", () => ({ + executeLegacySystemMutation: executeSystem, +})); + vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); vi.mock("@/lib/permissions", () => ({ PERMS: { NOTIFICATIONS_EDIT: "notifications.edit" }, @@ -15,7 +19,6 @@ vi.mock("@/lib/db", () => ({ }, AlertLogs: {}, })); -vi.mock("@/lib/services/rcon", () => ({ rcon: { send: vi.fn() } })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); const fakeForm = (data: Record) => ({ @@ -24,6 +27,7 @@ const fakeForm = (data: Record) => ({ beforeEach(() => { vi.clearAllMocks(); + executeSystem.mockResolvedValue({ delivered: true }); vi.mocked(requirePermission).mockResolvedValue({ id: 1, rank: 7, @@ -36,12 +40,16 @@ describe("sendHotelAlert", () => { await sendHotelAlert( fakeForm({ message: "Hello!" }) as unknown as FormData, ); - expect(rcon.send).toHaveBeenCalledWith("hotelalert", { message: "Hello!" }); + expect(executeSystem).toHaveBeenCalledWith( + { id: 1, rank: 7, username: "admin" }, + "operations.alert.broadcast", + { message: "Hello!" }, + ); expect(revalidatePath).toHaveBeenCalledWith("/admin/alerts"); }); it("returns early when message is empty", async () => { await sendHotelAlert(fakeForm({ message: "" }) as unknown as FormData); - expect(rcon.send).not.toHaveBeenCalled(); + expect(executeSystem).not.toHaveBeenCalled(); }); }); diff --git a/src/actions/admin-alerts.ts b/src/actions/admin-alerts.ts index 74414740..754e028d 100644 --- a/src/actions/admin-alerts.ts +++ b/src/actions/admin-alerts.ts @@ -1,11 +1,9 @@ "use server"; -import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { executeLegacySystemMutation } from "@/features/housekeeping/domains/system/services/mutations"; import { requirePermission } from "@/lib/admin/guard"; -import { AlertLogs, db } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -import { rcon } from "@/lib/services/rcon"; /** * Broadcast a hotel-wide alert to every online user via RCON. @@ -14,7 +12,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 actor = await requirePermission(PERMS.NOTIFICATIONS_EDIT); const message = String(formData.get("message") ?? "") .normalize("NFC") @@ -22,26 +20,16 @@ export async function sendHotelAlert(formData: FormData): Promise { .slice(0, 1000); if (!message) return; - try { - await rcon.send("hotelalert", { message }); - } catch { - // Best-effort delivery (dead socket / emulator offline) — never 500 the - // admin page. The emulator writes its own alert_logs row on receipt. - } + await executeLegacySystemMutation(actor, "operations.alert.broadcast", { + message, + }); revalidatePath("/admin/alerts"); } /** Mark every unread ops alert as read. */ export async function markAllAlertsRead(): Promise { - await requirePermission(PERMS.NOTIFICATIONS_VIEW); - try { - await db - .update(AlertLogs) - .set({ isRead: true, updatedAt: new Date() }) - .where(eq(AlertLogs.isRead, false)); - } catch { - /* ignore */ - } + const actor = await requirePermission(PERMS.NOTIFICATIONS_VIEW); + await executeLegacySystemMutation(actor, "operations.alerts.mark-read", {}); revalidatePath("/admin/alerts"); } diff --git a/src/actions/admin-emulator.ts b/src/actions/admin-emulator.ts index 2e08cce7..d8787cba 100644 --- a/src/actions/admin-emulator.ts +++ b/src/actions/admin-emulator.ts @@ -1,8 +1,8 @@ "use server"; import { revalidatePath } from "next/cache"; +import { executeLegacySystemMutation } from "@/features/housekeeping/domains/system/services/mutations"; 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). @@ -11,7 +11,7 @@ import { PERMS } from "@/lib/permissions"; // keys via upsert. We never migrate or drop them. export async function updateEmulatorSetting(formData: FormData): Promise { - await requirePermission(PERMS.SETTINGS_EDIT); + const actor = await requirePermission(PERMS.SETTINGS_EDIT); const key = String(formData.get("key") ?? "") .normalize("NFC") .trim() @@ -20,15 +20,16 @@ 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 executeLegacySystemMutation( + actor, + "configuration.emulator-setting.update", + { key, value }, + ); revalidatePath("/admin/emulator"); } export async function updateEmulatorText(formData: FormData): Promise { - await requirePermission(PERMS.SETTINGS_EDIT); + const actor = await requirePermission(PERMS.SETTINGS_EDIT); const key = String(formData.get("key") ?? "") .normalize("NFC") .trim() @@ -37,9 +38,10 @@ 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 executeLegacySystemMutation( + actor, + "configuration.emulator-text.update", + { key, value }, + ); revalidatePath("/admin/emulator"); } diff --git a/src/actions/admin-maintenance.test.ts b/src/actions/admin-maintenance.test.ts index 27bbd97f..d9d907f9 100644 --- a/src/actions/admin-maintenance.test.ts +++ b/src/actions/admin-maintenance.test.ts @@ -1,24 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { - mockValues, - mockOnDuplicateKeyUpdate, - mockRequirePermission, - mockReload, - mockRevalidatePath, -} = vi.hoisted(() => { - const mockOnDuplicateKeyUpdate = vi.fn().mockResolvedValue(undefined); - const mockValues = vi.fn(() => ({ - onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate, - })); - return { - mockValues, - mockOnDuplicateKeyUpdate, - mockRequirePermission: vi.fn(), - mockReload: vi.fn(), - mockRevalidatePath: vi.fn(), - }; -}); +const { mockExecuteSystem, mockRequirePermission, mockRevalidatePath } = + vi.hoisted(() => { + return { + mockExecuteSystem: vi.fn(), + mockRequirePermission: vi.fn(), + mockRevalidatePath: vi.fn(), + }; + }); vi.mock("@/lib/permissions", () => ({ PERMS: { @@ -28,21 +17,14 @@ vi.mock("@/lib/permissions", () => ({ }, })); -vi.mock("@/lib/db", () => ({ - db: { - insert: vi.fn(() => ({ values: mockValues })), - }, - WebsiteSetting: { key: "key", value: "value" }, +vi.mock("@/features/housekeeping/domains/system/services/mutations", () => ({ + executeLegacySystemMutation: mockExecuteSystem, })); vi.mock("@/lib/admin/guard", () => ({ requirePermission: mockRequirePermission, })); -vi.mock("@/lib/services/site-settings", () => ({ - siteSettings: { reload: mockReload }, -})); - vi.mock("next/cache", () => ({ revalidatePath: mockRevalidatePath, })); @@ -51,10 +33,7 @@ import { saveMaintenance } from "./admin-maintenance"; beforeEach(() => { vi.clearAllMocks(); - mockValues.mockReturnValue({ - onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate, - }); - mockOnDuplicateKeyUpdate.mockResolvedValue(undefined); + mockExecuteSystem.mockResolvedValue(null); }); describe("saveMaintenance", () => { @@ -74,28 +53,12 @@ describe("saveMaintenance", () => { expect(mockRequirePermission).toHaveBeenCalled(); - expect(mockValues).toHaveBeenCalledTimes(3); - expect(mockValues).toHaveBeenCalledWith( - expect.objectContaining({ - key: "maintenance_enabled", - value: "1", - }), + expect(mockExecuteSystem).toHaveBeenCalledWith( + { id: 1, rank: 7, username: "admin" }, + "operations.maintenance.update", + { enabled: true, message: "We will be back soon!", minimumLoginRank: 3 }, ); - expect(mockValues).toHaveBeenCalledWith( - expect.objectContaining({ - key: "maintenance_message", - value: "We will be back soon!", - }), - ); - expect(mockValues).toHaveBeenCalledWith( - expect.objectContaining({ - key: "min_maintenance_login_rank", - value: "3", - }), - ); - expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(3); - expect(mockReload).toHaveBeenCalledOnce(); expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/maintenance"); }); @@ -112,17 +75,10 @@ describe("saveMaintenance", () => { await saveMaintenance(fd); - expect(mockValues).toHaveBeenCalledWith( - expect.objectContaining({ - key: "maintenance_enabled", - value: "0", - }), - ); - expect(mockValues).toHaveBeenCalledWith( - expect.objectContaining({ - key: "min_maintenance_login_rank", - value: "5", - }), + expect(mockExecuteSystem).toHaveBeenCalledWith( + expect.anything(), + "operations.maintenance.update", + expect.objectContaining({ enabled: false, minimumLoginRank: 5 }), ); }); @@ -140,11 +96,10 @@ describe("saveMaintenance", () => { await saveMaintenance(fd); - expect(mockValues).toHaveBeenCalledWith( - expect.objectContaining({ - key: "min_maintenance_login_rank", - value: "5", - }), + expect(mockExecuteSystem).toHaveBeenCalledWith( + expect.anything(), + "operations.maintenance.update", + expect.objectContaining({ minimumLoginRank: 5 }), ); }); @@ -162,11 +117,10 @@ describe("saveMaintenance", () => { await saveMaintenance(fd); - expect(mockValues).toHaveBeenCalledWith( - expect.objectContaining({ - key: "min_maintenance_login_rank", - value: "5", - }), + expect(mockExecuteSystem).toHaveBeenCalledWith( + expect.anything(), + "operations.maintenance.update", + expect.objectContaining({ minimumLoginRank: 5 }), ); }); diff --git a/src/actions/admin-maintenance.ts b/src/actions/admin-maintenance.ts index 25716581..ef6d500e 100644 --- a/src/actions/admin-maintenance.ts +++ b/src/actions/admin-maintenance.ts @@ -1,10 +1,9 @@ "use server"; import { revalidatePath } from "next/cache"; +import { executeLegacySystemMutation } from "@/features/housekeeping/domains/system/services/mutations"; 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 +13,8 @@ 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 } }); -} - export async function saveMaintenance(formData: FormData): Promise { - await requirePermission(PERMS.SETTINGS_EDIT); + const actor = await requirePermission(PERMS.SETTINGS_EDIT); // Checkbox: present only when ticked. Normalise to the '1'/'0' string the // emulator/Laravel side expects. @@ -56,10 +31,10 @@ 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(); + await executeLegacySystemMutation(actor, "operations.maintenance.update", { + enabled: enabled === "1", + message, + minimumLoginRank: minRank, + }); revalidatePath("/admin/maintenance"); } diff --git a/src/actions/admin-settings.ts b/src/actions/admin-settings.ts index 27a5396c..d971bced 100644 --- a/src/actions/admin-settings.ts +++ b/src/actions/admin-settings.ts @@ -1,36 +1,11 @@ "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 { executeLegacySystemMutation } from "@/features/housekeeping/domains/system/services/mutations"; import { requirePermissionRateLimited } from "@/lib/admin/guard"; -import { db, WebsiteSetting } from "@/lib/db"; import { actionOk, adminAction } from "@/lib/foundation/action"; -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); - -function normalizeSettingValue(key: string, value: string): string { - if (key === HABBO_GAMEDATA_HOTEL_SETTING_KEY) { - return normalizeHabboGamedataHotel(value); - } - return value; -} - -function bustGamedataCachesIfNeeded(key: string): void { - if (key === HABBO_GAMEDATA_HOTEL_SETTING_KEY) { - clearOfficialHabboFurnidataCache(); - clearBadgeCache(); - } -} const saveManagedSchema = z.object({ settings: z.record(z.string(), z.string()), @@ -44,79 +19,59 @@ export const saveManagedSettings = adminAction( rateLimitMax: 30, }, async (ctx) => { - 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 result = (await executeLegacySystemMutation( + { id: Number(ctx.session.user.id) }, + "configuration.settings.save", + ctx.data, + )) as { saved: number }; revalidatePath("/admin/settings"); revalidatePath("/admin/catalog"); - return actionOk({ saved: entries.length }); + return actionOk({ saved: result.saved }); }, ); export async function updateSetting(formData: FormData): Promise { - await requirePermissionRateLimited(PERMS.SETTINGS_EDIT); + const actor = await requirePermissionRateLimited(PERMS.SETTINGS_EDIT); const key = String(formData.get("key") ?? "") .normalize("NFC") .trim(); - const value = normalizeSettingValue( - key, - String(formData.get("value") ?? "").normalize("NFC"), - ); + const value = 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 executeLegacySystemMutation(actor, "configuration.setting.update", { + key, + value, + }); revalidatePath("/admin/settings"); } export async function createSetting(formData: FormData): Promise { - await requirePermissionRateLimited(PERMS.SETTINGS_EDIT); + const actor = await requirePermissionRateLimited(PERMS.SETTINGS_EDIT); const key = String(formData.get("key") ?? "") .normalize("NFC") .trim() .slice(0, 255); - const value = normalizeSettingValue( - key, - String(formData.get("value") ?? "").normalize("NFC"), - ); + const value = String(formData.get("value") ?? "").normalize("NFC"); const comment = String(formData.get("comment") ?? "") .normalize("NFC") .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 executeLegacySystemMutation(actor, "configuration.setting.create", { + key, + value, + comment, + }); revalidatePath("/admin/settings"); } export async function deleteSetting(formData: FormData): Promise { - await requirePermissionRateLimited(PERMS.SETTINGS_EDIT); + const actor = 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 executeLegacySystemMutation(actor, "configuration.setting.delete", { + key, + }); revalidatePath("/admin/settings"); } diff --git a/src/actions/commandocentrum.ts b/src/actions/commandocentrum.ts index 107320e3..451792a6 100644 --- a/src/actions/commandocentrum.ts +++ b/src/actions/commandocentrum.ts @@ -1,28 +1,34 @@ "use server"; -import crypto from "node:crypto"; import { revalidatePath } from "next/cache"; import { z } from "zod"; -import { systemMutationService } from "@/features/housekeeping/domains/system/services/mutations"; -import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context"; +import { + executeLegacySystemMutation, + type SystemMutationOperation, +} from "@/features/housekeeping/domains/system/services/mutations"; 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"; +import { actionOk } from "@/lib/safe-action-shared"; 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); +async function executeLegacyRcon( + ctx: { session: { user: { id: string | number } } }, + operation: SystemMutationOperation, + input: unknown, +): Promise { + return executeLegacySystemMutation( + { id: Number(ctx.session.user.id) }, + operation, + input, + ); } /** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */ export const updateCatalog = adminAction( { permission: PERMS.RCON_EXECUTE }, - async () => { - await requireRconOk(await rcon.updateCatalog()); + async (ctx) => { + await executeLegacyRcon(ctx, "rcon.update-catalog", {}); revalidatePath(PATH); return actionOk(); }, @@ -31,8 +37,8 @@ export const updateCatalog = adminAction( /** Reload the chat word filter on the emulator (rcon: updatewordfilter). */ export const updateWordFilter = adminAction( { permission: PERMS.RCON_EXECUTE }, - async () => { - await requireRconOk(await rcon.updateWordFilter()); + async (ctx) => { + await executeLegacyRcon(ctx, "rcon.update-word-filter", {}); revalidatePath(PATH); return actionOk(); }, @@ -41,8 +47,8 @@ export const updateWordFilter = adminAction( /** 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)); + async (ctx) => { + await executeLegacyRcon(ctx, "rcon.update-navigator", {}); revalidatePath(PATH); return actionOk(); }, @@ -57,7 +63,7 @@ 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 })); + await executeLegacyRcon(ctx, "rcon.hotel-alert", { message }); revalidatePath(PATH); return actionOk(); }, @@ -73,7 +79,10 @@ 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)); + await executeLegacyRcon(ctx, "rcon.disconnect-user", { + userId: ctx.data.userId, + username, + }); revalidatePath(PATH); return actionOk(); }, @@ -89,7 +98,10 @@ 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)); + await executeLegacyRcon(ctx, "rcon.alert-user", { + userId: ctx.data.userId, + message, + }); revalidatePath(PATH); return actionOk(); }, @@ -104,9 +116,7 @@ const forwardUserSchema = z.object({ export const forwardUser = adminAction( { permission: PERMS.RCON_EXECUTE, schema: forwardUserSchema }, async (ctx) => { - await requireRconOk( - await rcon.forwardUser(ctx.data.userId, ctx.data.roomId), - ); + await executeLegacyRcon(ctx, "rcon.forward-user", ctx.data); revalidatePath(PATH); return actionOk(); }, @@ -121,9 +131,10 @@ const giveCreditsSchema = z.object({ export const giveCredits = adminAction( { permission: PERMS.RCON_EXECUTE, schema: giveCreditsSchema }, async (ctx) => { - await requireRconOk( - await rcon.giveCredits(ctx.data.userId, ctx.data.credits), - ); + await executeLegacyRcon(ctx, "rcon.give-credits", { + userId: ctx.data.userId, + amount: ctx.data.credits, + }); revalidatePath(PATH); return actionOk(); }, @@ -138,9 +149,7 @@ const giveAmountSchema = z.object({ export const giveDuckets = adminAction( { permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema }, async (ctx) => { - await requireRconOk( - await rcon.giveDuckets(ctx.data.userId, ctx.data.amount), - ); + await executeLegacyRcon(ctx, "rcon.give-duckets", ctx.data); revalidatePath(PATH); return actionOk(); }, @@ -150,9 +159,7 @@ export const giveDuckets = adminAction( export const giveDiamonds = adminAction( { permission: PERMS.RCON_EXECUTE, schema: giveAmountSchema }, async (ctx) => { - await requireRconOk( - await rcon.giveDiamonds(ctx.data.userId, ctx.data.amount), - ); + await executeLegacyRcon(ctx, "rcon.give-diamonds", ctx.data); revalidatePath(PATH); return actionOk(); }, @@ -168,7 +175,10 @@ 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)); + await executeLegacyRcon(ctx, "rcon.give-badge", { + userId: ctx.data.userId, + badge, + }); revalidatePath(PATH); return actionOk(); }, @@ -184,7 +194,10 @@ 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)); + await executeLegacyRcon(ctx, "rcon.set-motto", { + userId: ctx.data.userId, + motto, + }); revalidatePath(PATH); return actionOk(); }, @@ -199,28 +212,9 @@ const setRankSchema = z.object({ export const setRank = adminAction( { permission: PERMS.RCON_EXECUTE, schema: setRankSchema }, async (ctx) => { - const result = await systemMutationService.execute( - { - capability: createHousekeepingCapabilityContext( - { - id: Number(ctx.session.user.id), - username: ctx.session.user.username, - rank: Number(ctx.session.user.rank), - }, - ctx.permissions, - ), - correlationId: crypto.randomUUID(), - }, - "rcon.set-rank", - ctx.data, - ); - if (!result.ok) throw new ActionError(result.error.messageKey); + const result = await executeLegacyRcon(ctx, "rcon.set-rank", ctx.data); revalidatePath(PATH); - if (result.completion) - throw new ActionError( - `Rank saved; emulator synchronization is pending. ASE recovery reference: ${result.correlationId}`, - ); - return actionOk(result.data as Record); + return actionOk(result as Record); }, ); @@ -234,7 +228,10 @@ 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)); + await executeLegacyRcon(ctx, "rcon.execute-command", { + userId: ctx.data.userId, + command, + }); revalidatePath(PATH); return actionOk(); }, @@ -251,9 +248,11 @@ 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), - ); + await executeLegacyRcon(ctx, "rcon.send-gift", { + userId: ctx.data.userId, + itemId: ctx.data.itemId, + message, + }); revalidatePath(PATH); return actionOk(); }, diff --git a/src/actions/emulator.test.ts b/src/actions/emulator.test.ts index b8673932..e7875a3e 100644 --- a/src/actions/emulator.test.ts +++ b/src/actions/emulator.test.ts @@ -1,22 +1,13 @@ // @ts-nocheck import { describe, expect, it, vi } from "vitest"; -import { rcon } from "@/lib/services/rcon"; -const { insertValues } = vi.hoisted(() => { - const insertValues = vi.fn(() => ({ - onDuplicateKeyUpdate: vi.fn().mockResolvedValue([{ affectedRows: 1 }]), - })); - return { insertValues }; -}); +const { executeSystem } = vi.hoisted(() => ({ executeSystem: vi.fn() })); vi.mock("@/lib/permissions", () => ({ PERMS: { SETTINGS_EDIT: "settings.edit" }, })); -vi.mock("@/lib/db", () => ({ - db: { - insert: vi.fn(() => ({ values: insertValues })), - }, - EmulatorSettings: { key: "key", value: "value" }, +vi.mock("@/features/housekeeping/domains/system/services/mutations", () => ({ + executeLegacySystemMutation: executeSystem, })); vi.mock("@/lib/safe-action", () => ({ adminAction: vi.fn( @@ -24,11 +15,10 @@ vi.mock("@/lib/safe-action", () => ({ ), })); vi.mock("@/lib/safe-action-shared", () => ({ actionOk: vi.fn(() => "ok") })); -vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() })); -vi.mock("@/lib/services/rcon", () => ({ rcon: { updateConfig: vi.fn() } })); describe("saveEmulatorSettings", () => { it("saves settings and calls rcon update", async () => { + executeSystem.mockResolvedValue({ saved: 2 }); const handler = (await import("./emulator").then( (m) => m.saveEmulatorSettings, )) as unknown as (ctx: { @@ -41,8 +31,11 @@ describe("saveEmulatorSettings", () => { session: { user: { id: "1" } }, }); - expect(insertValues).toHaveBeenCalledTimes(2); - expect(rcon.updateConfig).toHaveBeenCalled(); + expect(executeSystem).toHaveBeenCalledWith( + { id: 1 }, + "configuration.emulator-settings.save", + { settings: { key1: "val1", key2: "val2" } }, + ); expect(result).toBe("ok"); }); }); diff --git a/src/actions/emulator.ts b/src/actions/emulator.ts index 4c420aad..ebff0edf 100644 --- a/src/actions/emulator.ts +++ b/src/actions/emulator.ts @@ -1,12 +1,10 @@ "use server"; import { z } from "zod"; -import { db, EmulatorSettings } from "@/lib/db"; +import { executeLegacySystemMutation } from "@/features/housekeeping/domains/system/services/mutations"; import { PERMS } from "@/lib/permissions"; import { adminAction } from "@/lib/safe-action"; import { actionOk } from "@/lib/safe-action-shared"; -import { logAudit } from "@/lib/services/audit"; -import { rcon } from "@/lib/services/rcon"; const saveEmulatorSettingsSchema = z.object({ settings: z.record(z.string(), z.string()), @@ -15,23 +13,11 @@ const saveEmulatorSettingsSchema = z.object({ export const saveEmulatorSettings = adminAction( { permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema }, async (ctx) => { - const entries = Object.entries(ctx.data.settings); - - for (const [key, value] of entries) { - await db - .insert(EmulatorSettings) - .values({ key, value: String(value) }) - .onDuplicateKeyUpdate({ set: { value: String(value) } }); - } - - await rcon.updateConfig(); - - logAudit({ - userId: ctx.session.user.id, - action: "emulator_settings_update", - target: "EmulatorSettings", - after: ctx.data.settings, - }); + await executeLegacySystemMutation( + { id: Number(ctx.session.user.id) }, + "configuration.emulator-settings.save", + ctx.data, + ); return actionOk(); }, diff --git a/src/actions/permissions.ts b/src/actions/permissions.ts index f3bed5bf..bcdf4076 100644 --- a/src/actions/permissions.ts +++ b/src/actions/permissions.ts @@ -1,22 +1,10 @@ "use server"; -import crypto from "node:crypto"; -import { and, eq, inArray, sql } from "drizzle-orm"; -import type { ResultSetHeader } from "mysql2"; -import { revalidateTag } from "next/cache"; import { z } from "zod"; -import { systemMutationService } from "@/features/housekeeping/domains/system/services/mutations"; -import { createHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/capability-context"; -import { AclModelPermission, AclPermission, AclRole, db } from "@/lib/db"; +import { executeLegacySystemMutation } from "@/features/housekeeping/domains/system/services/mutations"; import { PERMS } from "@/lib/permission-slugs"; import { adminAction } from "@/lib/safe-action"; -import { ActionError, actionOk } from "@/lib/safe-action-shared"; -import { - createEmulatorRank, - updateEmulatorRank, -} from "@/lib/services/permission-ranks"; -import { rcon } from "@/lib/services/rcon"; -import { logStaffActivity } from "@/lib/services/staff-activity"; +import { actionOk } from "@/lib/safe-action-shared"; const createRankSchema = z.object({ rank_name: z.string().trim().min(1).max(25), @@ -26,25 +14,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"); - revalidateTag("permissions", { expire: 0 }); - return actionOk({ id }); + const result = (await executeLegacySystemMutation( + { id: Number(ctx.session.user.id) }, + "access.rank.create", + { name: ctx.data.rank_name, level: ctx.data.level }, + )) as { id: number }; + return actionOk({ id: result.id }); }, ); @@ -53,27 +28,11 @@ const deleteRankSchema = z.object({ id: z.coerce.number().int().positive() }); export const deleteRank = adminAction( { schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE }, async (ctx) => { - const result = await systemMutationService.execute( - { - capability: createHousekeepingCapabilityContext( - { - id: Number(ctx.session.user.id), - username: ctx.session.user.username, - rank: Number(ctx.session.user.rank), - }, - ctx.permissions, - ), - correlationId: crypto.randomUUID(), - }, + await executeLegacySystemMutation( + { id: Number(ctx.session.user.id) }, "access.rank.delete", ctx.data, ); - if (!result.ok) throw new ActionError(result.error.messageKey); - revalidateTag("permissions", { expire: 0 }); - if (result.completion) - throw new ActionError( - `Rank deleted; emulator synchronization is pending. ASE recovery reference: ${result.correlationId}`, - ); return actionOk(); }, ); @@ -86,22 +45,11 @@ 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"); - revalidateTag("permissions", { expire: 0 }); + await executeLegacySystemMutation( + { id: Number(ctx.session.user.id) }, + "access.rank.update", + ctx.data, + ); return actionOk(); }, ); @@ -114,43 +62,11 @@ 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, - }); - revalidateTag("permissions", { expire: 0 }); + await executeLegacySystemMutation( + { id: Number(ctx.session.user.id) }, + "access.permissions.update", + ctx.data, + ); return actionOk(); }, ); @@ -164,88 +80,11 @@ export const setCmsPermissions = adminAction( 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, - }); - revalidateTag("permissions", { expire: 0 }); - return actionOk({ inserted }); + const result = (await executeLegacySystemMutation( + { id: Number(ctx.session.user.id) }, + "access.permissions.repair", + {}, + )) as { inserted: number }; + return actionOk({ inserted: result.inserted }); }, ); diff --git a/src/actions/system-legacy-adapters.test.ts b/src/actions/system-legacy-adapters.test.ts new file mode 100644 index 00000000..9ad9a309 --- /dev/null +++ b/src/actions/system-legacy-adapters.test.ts @@ -0,0 +1,94 @@ +// @ts-nocheck +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const doubles = vi.hoisted(() => ({ + execute: vi.fn(), + requirePermission: vi.fn(), + requirePermissionRateLimited: vi.fn(), + revalidatePath: vi.fn(), +})); + +vi.mock("@/features/housekeeping/domains/system/services/mutations", () => ({ + executeLegacySystemMutation: doubles.execute, +})); +vi.mock("@/lib/admin/guard", () => ({ + requirePermission: doubles.requirePermission, + requirePermissionRateLimited: doubles.requirePermissionRateLimited, +})); +vi.mock("@/lib/permissions", async () => import("@/lib/permission-slugs")); +vi.mock("next/cache", () => ({ + revalidatePath: doubles.revalidatePath, + revalidateTag: vi.fn(), +})); +vi.mock("@/lib/foundation/action", () => ({ + actionOk: (data = {}) => ({ ok: true, data }), + adminAction: (_options, handler) => handler, +})); +vi.mock("@/lib/safe-action", () => ({ + adminAction: (_options, handler) => handler, +})); +vi.mock("@/lib/safe-action-shared", () => ({ + actionOk: (data = {}) => ({ ok: true, data }), +})); + +import { updateEmulatorSetting } from "./admin-emulator"; +import { createSetting } from "./admin-settings"; +import { updateCatalog } from "./commandocentrum"; +import { setCmsPermissions } from "./permissions"; + +const actor = { id: 42, username: "operator", rank: 9 }; + +beforeEach(() => { + vi.clearAllMocks(); + doubles.execute.mockResolvedValue(null); + doubles.requirePermission.mockResolvedValue(actor); + doubles.requirePermissionRateLimited.mockResolvedValue(actor); +}); + +describe("legacy System adapters", () => { + it("routes the create-setting FormData contract through the canonical upsert", async () => { + const form = new FormData(); + form.set("key", " hotel_name "); + form.set("value", "Epic Hotel"); + form.set("comment", "Display name"); + await createSetting(form); + expect(doubles.execute).toHaveBeenCalledWith( + actor, + "configuration.setting.create", + { key: "hotel_name", value: "Epic Hotel", comment: "Display name" }, + ); + }); + + it("routes emulator FormData through the audited single-key operation", async () => { + const form = new FormData(); + form.set("key", " hotel.name "); + form.set("value", "Epic"); + await updateEmulatorSetting(form); + expect(doubles.execute).toHaveBeenCalledWith( + actor, + "configuration.emulator-setting.update", + { key: "hotel.name", value: "Epic" }, + ); + }); + + it("keeps explicit empty permission lists as canonical revoke-all", async () => { + await setCmsPermissions({ + data: { roleId: 5, permissionSlugs: [] }, + session: { user: { id: 42 } }, + }); + expect(doubles.execute).toHaveBeenCalledWith( + { id: 42 }, + "access.permissions.update", + { roleId: 5, permissionSlugs: [] }, + ); + }); + + it("routes command-center RCON through the external audit boundary", async () => { + await updateCatalog({ session: { user: { id: 42 } } }); + expect(doubles.execute).toHaveBeenCalledWith( + { id: 42 }, + "rcon.update-catalog", + {}, + ); + }); +}); diff --git a/src/features/housekeeping/domains/system/commands/system-commands.test.ts b/src/features/housekeeping/domains/system/commands/system-commands.test.ts index 1f14d391..01c7e2a7 100644 --- a/src/features/housekeeping/domains/system/commands/system-commands.test.ts +++ b/src/features/housekeeping/domains/system/commands/system-commands.test.ts @@ -52,6 +52,8 @@ const reasonRequiredIds = expectedCommandIds.filter( id.startsWith("system.access.") || id.startsWith("system.configuration.setting") || id === "system.configuration.settings.save" || + id === "system.configuration.emulator-setting.update" || + id === "system.configuration.emulator-text.update" || id === "system.operations.alert.broadcast" || id.startsWith("system.operations.rcon.") || id === "system.operations.maintenance.update", diff --git a/src/features/housekeeping/domains/system/commands/system-commands.ts b/src/features/housekeeping/domains/system/commands/system-commands.ts index 40f5a5a1..16bfa96b 100644 --- a/src/features/housekeeping/domains/system/commands/system-commands.ts +++ b/src/features/housekeeping/domains/system/commands/system-commands.ts @@ -172,14 +172,14 @@ export function createSystemCommands( operation: "configuration.emulator-setting.update", capability: PERMS.SETTINGS_EDIT, input: z.object({ key: requiredText(100), value: z.string().max(512) }), - requiresReason: false, + requiresReason: true, }), 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, + requiresReason: true, }), systemCommand(service, { id: "system.operations.alerts.mark-read", diff --git a/src/features/housekeeping/domains/system/services/mutations-production.test.ts b/src/features/housekeeping/domains/system/services/mutations-production.test.ts index 57f376e7..b9678dfd 100644 --- a/src/features/housekeeping/domains/system/services/mutations-production.test.ts +++ b/src/features/housekeeping/domains/system/services/mutations-production.test.ts @@ -2,11 +2,14 @@ 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(() => ({ +const { logAudit, markReadWhere, rconSend } = vi.hoisted(() => ({ + logAudit: vi.fn(), markReadWhere: vi.fn(), rconSend: vi.fn(), })); +vi.mock("@/lib/services/audit", () => ({ logAudit })); + vi.mock("@/lib/db", async (importOriginal) => { const actual = await importOriginal(); return { @@ -47,6 +50,8 @@ describe("System production mutation failures", () => { beforeEach(() => { markReadWhere.mockReset(); rconSend.mockReset(); + logAudit.mockReset(); + logAudit.mockResolvedValue(undefined); }); it("maps a failed alert broadcast to dependency unavailable", async () => { @@ -87,6 +92,37 @@ describe("System production mutation failures", () => { }); }); + it("returns a truthful partial when alert delivery succeeds but outcome audit fails", async () => { + rconSend.mockResolvedValue(true); + logAudit + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("audit unavailable")); + + const result = await systemMutationService.execute( + { + capability: capabilityContext([PERMS.NOTIFICATIONS_EDIT]), + correlationId: "broadcast-audit-partial", + reason: "Approved hotel notice", + }, + "operations.alert.broadcast", + { message: "Hotel notice" }, + ); + + expect(result).toMatchObject({ + ok: true, + data: { delivered: true }, + completion: { + status: "partial", + external: "completed", + audit: "unavailable", + }, + }); + expect(logAudit.mock.calls.map(([entry]) => entry.outcome)).toEqual([ + "intent", + "success", + ]); + }); + it("maps mark-read persistence failure to dependency unavailable", async () => { markReadWhere.mockRejectedValue(new Error("database unavailable")); diff --git a/src/features/housekeeping/domains/system/services/mutations.ts b/src/features/housekeeping/domains/system/services/mutations.ts index 0ba4166c..c79b3917 100644 --- a/src/features/housekeeping/domains/system/services/mutations.ts +++ b/src/features/housekeeping/domains/system/services/mutations.ts @@ -2,6 +2,7 @@ import "server-only"; import { and, count, eq, inArray, sql } from "drizzle-orm"; import type { ResultSetHeader } from "mysql2"; +import { revalidateTag } from "next/cache"; import { MANAGED_SETTING_KEYS } from "@/lib/admin/cms-settings-config"; import { AclModelPermission, @@ -26,7 +27,9 @@ import { clearBadgeCache } from "@/lib/services/habboassets"; import { createEmulatorRankInTransaction, deleteEmulatorRankInTransaction, + fetchEmulatorRankForEdit, prepareEmulatorRankCreation, + RANK_GENERAL_FIELDS, updateEmulatorRank, } from "@/lib/services/permission-ranks"; import { @@ -36,10 +39,7 @@ import { } from "@/lib/services/rank-assignment"; import { rcon } from "@/lib/services/rcon"; import { siteSettings } from "@/lib/services/site-settings"; -import { - logStaffActivity, - logStaffActivityInTransaction, -} from "@/lib/services/staff-activity"; +import { logStaffActivityInTransaction } from "@/lib/services/staff-activity"; import { satisfiesCapability } from "../../../foundation/capability-context"; import { anyCapability, @@ -62,6 +62,7 @@ export type SystemMutationOperation = | "configuration.setting.update" | "configuration.setting.delete" | "configuration.emulator-setting.update" + | "configuration.emulator-settings.save" | "configuration.emulator-text.update" | "operations.alerts.mark-read" | "operations.alert.broadcast" @@ -87,6 +88,7 @@ export interface SystemMutationContext { readonly capability: HousekeepingCapabilityContext; readonly correlationId: string; readonly reason?: string; + readonly legacy?: boolean; } export interface SystemMutationAdapter { @@ -129,6 +131,15 @@ class SystemCommittedExternalFailure extends Error { } } +class SystemCommittedCacheFailure extends Error { + constructor(readonly data: unknown) { + super( + "System database change committed but cache invalidation is incomplete", + ); + this.name = "SystemCommittedCacheFailure"; + } +} + function operationCapability(operation: SystemMutationOperation) { if (operation.startsWith("access.")) { return anyCapability(PERMS.PERMISSIONS_MANAGE); @@ -185,6 +196,14 @@ export function createSystemMutationService( audit: error.audit, }); } + if (error instanceof SystemCommittedCacheFailure) { + return ok(error.data, context.correlationId, { + status: "partial", + external: "not-required", + audit: "persisted", + cache: "unavailable", + }); + } if (error instanceof SystemMutationFailure) { return fail( error.code, @@ -515,13 +534,53 @@ async function upsertWebsiteSetting( key: string, value: string, comment?: string | null, + writer: Pick = db, ): Promise { - await db + await writer .insert(WebsiteSetting) .values({ key, value, ...(comment === undefined ? {} : { comment }) }) .onDuplicateKeyUpdate({ set: { value } }); } +async function loadWebsiteSettingValues( + reader: Pick, + keys: readonly string[], +): Promise> { + if (keys.length === 0) return {}; + const rows = await reader + .select({ key: WebsiteSetting.key, value: WebsiteSetting.value }) + .from(WebsiteSetting) + .where(inArray(WebsiteSetting.key, [...keys])); + return Object.fromEntries(rows.map((row) => [row.key, row.value])); +} + +function mutationAuditEntry( + operation: SystemMutationOperation, + context: SystemMutationContext, + before?: Record, + after?: Record, +) { + return { + userId: context.capability.actor.id, + action: `system.${operation}`, + target: operation, + correlationId: context.correlationId, + outcome: "success" as const, + reason: context.reason, + domain: "system" as const, + before, + after, + }; +} + +async function invalidatePermissionsCache(data: unknown): Promise { + try { + revalidateTag("permissions", { expire: 0 }); + } catch { + throw new SystemCommittedCacheFailure(data); + } +} + async function executeAccessMutation( operation: Extract, input: unknown, @@ -553,6 +612,16 @@ async function executeAccessMutation( description: "CMS role synchronized from permission_ranks", }) .onDuplicateKeyUpdate({ set: { title: name } }); + const after = await fetchEmulatorRankForEdit(tx, id); + await logAudit( + mutationAuditEntry( + operation, + context, + undefined, + (after ?? { id, rank_name: name, level }) as Record, + ), + tx, + ); await logStaffActivityInTransaction( { staffId: context.capability.actor.id, @@ -585,6 +654,7 @@ async function executeAccessMutation( "errors.housekeeping.system.rankNotFound", ); } + const before = await fetchEmulatorRankForEdit(tx, id); const [userCount] = await tx .select({ total: count() }) @@ -616,6 +686,15 @@ async function executeAccessMutation( await tx.delete(AclModelRole).where(eq(AclModelRole.roleId, role.id)); await tx.delete(AclRole).where(eq(AclRole.id, role.id)); } + await logAudit( + mutationAuditEntry( + operation, + context, + before as unknown as Record, + undefined, + ), + tx, + ); await logStaffActivityInTransaction( { staffId: context.capability.actor.id, @@ -636,6 +715,12 @@ async function executeAccessMutation( if (operation === "access.rank.update") { const fields = record(data.fields); + if (Object.keys(fields).length === 0) { + throw new SystemMutationFailure( + "VALIDATION", + "errors.housekeeping.validation", + ); + } const normalizedFields = Object.fromEntries( Object.entries(fields).flatMap(([key, value]) => typeof value === "string" || typeof value === "number" @@ -649,6 +734,32 @@ async function executeAccessMutation( rankId: id, } as const satisfies SystemExternalSyncIntent; await db.transaction(async (tx) => { + if (!(await lockConfiguredRank(tx, id))) { + throw new SystemMutationFailure( + "NOT_FOUND", + "errors.housekeeping.system.rankNotFound", + ); + } + const before = await fetchEmulatorRankForEdit(tx, id); + if (!before) { + throw new SystemMutationFailure( + "NOT_FOUND", + "errors.housekeeping.system.rankNotFound", + ); + } + const knownFields = new Set([ + ...RANK_GENERAL_FIELDS, + ...Object.keys(before.permissions), + ]); + if ( + Object.keys(normalizedFields).length === 0 || + Object.keys(normalizedFields).some((key) => !knownFields.has(key)) + ) { + throw new SystemMutationFailure( + "VALIDATION", + "errors.housekeeping.validation", + ); + } await updateEmulatorRank(tx, id, normalizedFields); if (typeof normalizedFields.rank_name === "string") { await tx @@ -666,6 +777,16 @@ async function executeAccessMutation( }, tx, ); + const after = await fetchEmulatorRankForEdit(tx, id); + await logAudit( + mutationAuditEntry( + operation, + context, + before as unknown as Record, + (after ?? before) as unknown as Record, + ), + tx, + ); await logAudit( syncAuditEntry(intent, context, context.correlationId, "intent"), tx, @@ -677,26 +798,45 @@ async function executeAccessMutation( 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) + ? [ + ...new Set( + 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) => { + const updated = await db.transaction(async (tx) => { + const [lockedRows] = await tx.execute(sql` + SELECT id, slug FROM acl_roles WHERE id = ${roleId} FOR UPDATE + `); + const role = (lockedRows as unknown as { id: number; slug: string }[])[0]; + if (!role) { + throw new SystemMutationFailure( + "NOT_FOUND", + "errors.housekeeping.system.roleNotFound", + ); + } + const permissions = slugs.length + ? await tx + .select({ id: AclPermission.id, slug: AclPermission.slug }) + .from(AclPermission) + .where(inArray(AclPermission.slug, slugs)) + : []; + if (permissions.length !== slugs.length) { + throw new SystemMutationFailure( + "VALIDATION", + "errors.housekeeping.validation", + { permissionSlugs: ["errors.validation.invalid"] }, + ); + } + const beforeRows = await tx + .select({ permissionId: AclModelPermission.permissionId }) + .from(AclModelPermission) + .where( + and( + eq(AclModelPermission.modelId, role.id), + eq(AclModelPermission.modelType, "Role"), + ), + ); await tx .delete(AclModelPermission) .where( @@ -714,18 +854,39 @@ async function executeAccessMutation( })), ); } + await logStaffActivityInTransaction( + { + staffId: context.capability.actor.id, + action: "acl_role_permissions_update", + description: `Updated ${permissions.length} permissions for ${role.slug}`, + targetType: "acl_role", + targetId: role.id, + }, + tx, + ); + await logAudit( + mutationAuditEntry( + operation, + context, + { + roleId: role.id, + permissionIds: beforeRows.map((row) => row.permissionId), + }, + { + roleId: role.id, + permissionSlugs: permissions.map((permission) => permission.slug), + }, + ), + tx, + ); + return { updated: permissions.length }; }); - 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 }; + await invalidatePermissionsCache(updated); + return updated; } - const [dashboardFillResult] = await db.execute(sql` + const inserted = await db.transaction(async (tx) => { + const [dashboardFillResult] = await tx.execute(sql` INSERT INTO acl_model_permissions (model_type, model_id, permission_id) SELECT 'Role', ar.id, ap.id FROM acl_roles ar @@ -742,7 +903,7 @@ async function executeAccessMutation( AND amp2.permission_id = ap.id ) `); - const [midRankViewsResult] = await db.execute(sql` + const [midRankViewsResult] = await tx.execute(sql` INSERT INTO acl_model_permissions (model_type, model_id, permission_id) SELECT 'Role', ar.id, ap.id FROM permission_ranks pr @@ -760,7 +921,7 @@ async function executeAccessMutation( AND amp.permission_id = ap.id ) `); - const [highRankToolsResult] = await db.execute(sql` + const [highRankToolsResult] = await tx.execute(sql` INSERT INTO acl_model_permissions (model_type, model_id, permission_id) SELECT 'Role', ar.id, ap.id FROM permission_ranks pr @@ -787,23 +948,35 @@ async function executeAccessMutation( 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, + const total = + Number((dashboardFillResult as ResultSetHeader).affectedRows) + + Number((midRankViewsResult as ResultSetHeader).affectedRows) + + Number((highRankToolsResult as ResultSetHeader).affectedRows); + await logStaffActivityInTransaction( + { + staffId: context.capability.actor.id, + action: "acl_nav_grants_repair", + description: `Repaired admin nav ACL grants (${total} rows inserted)`, + targetType: "acl", + targetId: 0, + }, + tx, + ); + await logAudit( + mutationAuditEntry(operation, context, undefined, { inserted: total }), + tx, + ); + return total; }); - return { inserted }; + const repaired = { inserted }; + await invalidatePermissionsCache(repaired); + return repaired; } async function executeConfigurationMutation( operation: Extract, input: unknown, + context: SystemMutationContext, ): Promise { const data = record(input); if (operation === "configuration.settings.save") { @@ -814,13 +987,33 @@ async function executeConfigurationMutation( ? [[key, normalizeSettingValue(key, value)] as const] : [], ); - await Promise.all( - entries.map(([key, value]) => upsertWebsiteSetting(key, value)), - ); - await siteSettings.reload(); + await db.transaction(async (tx) => { + const before = await loadWebsiteSettingValues( + tx, + entries.map(([key]) => key), + ); + for (const [key, value] of entries) + await upsertWebsiteSetting(key, value, undefined, tx); + await logAudit( + mutationAuditEntry( + operation, + context, + { settings: before }, + { settings: Object.fromEntries(entries) }, + ), + tx, + ); + }); + const invalidation = await siteSettings.reload(); + if (!invalidation.invalidated) + throw new SystemCommittedCacheFailure({ saved: entries.length }); if (entries.some(([key]) => key === HABBO_GAMEDATA_HOTEL_SETTING_KEY)) { - clearOfficialHabboFurnidataCache(); - clearBadgeCache(); + try { + clearOfficialHabboFurnidataCache(); + clearBadgeCache(); + } catch { + throw new SystemCommittedCacheFailure({ saved: entries.length }); + } } return { saved: entries.length }; } @@ -834,13 +1027,52 @@ async function executeConfigurationMutation( ); } const value = text(data.value, 512, false); - await db - .insert(EmulatorSettings) - .values({ key, value }) - .onDuplicateKeyUpdate({ set: { value } }); + await db.transaction(async (tx) => { + await tx + .insert(EmulatorSettings) + .values({ key, value }) + .onDuplicateKeyUpdate({ set: { value } }); + await logAudit( + mutationAuditEntry(operation, context, undefined, { key, value }), + tx, + ); + }); return null; } + if (operation === "configuration.emulator-settings.save") { + const settings = record(data.settings); + const entries = Object.entries(settings) + .map(([key, value]) => [text(key, 100), text(value, 512, false)] as const) + .filter(([key]) => Boolean(key)); + await db.transaction(async (tx) => { + for (const [key, value] of entries) { + await tx + .insert(EmulatorSettings) + .values({ key, value }) + .onDuplicateKeyUpdate({ set: { value } }); + } + await logAudit( + mutationAuditEntry(operation, context, undefined, { + settings: Object.fromEntries(entries), + }), + tx, + ); + }); + let synchronized = false; + try { + synchronized = await rcon.updateConfig(); + } catch {} + if (!synchronized) { + throw new SystemCommittedExternalFailure( + { saved: entries.length }, + "failed", + "persisted", + ); + } + return { saved: entries.length }; + } + if (operation === "configuration.emulator-text.update") { const key = text(data.key, 100); if (!key) { @@ -850,10 +1082,16 @@ async function executeConfigurationMutation( ); } const value = text(data.value, 4096, false); - await db - .insert(EmulatorTexts) - .values({ key, value }) - .onDuplicateKeyUpdate({ set: { value } }); + await db.transaction(async (tx) => { + await tx + .insert(EmulatorTexts) + .values({ key, value }) + .onDuplicateKeyUpdate({ set: { value } }); + await logAudit( + mutationAuditEntry(operation, context, undefined, { key, value }), + tx, + ); + }); return null; } @@ -865,9 +1103,26 @@ async function executeConfigurationMutation( ); } if (operation === "configuration.setting.delete") { - await db.delete(WebsiteSetting).where(eq(WebsiteSetting.key, key)); - await siteSettings.reload(); - bustGamedataCachesIfNeeded(key); + await db.transaction(async (tx) => { + const before = await loadWebsiteSettingValues(tx, [key]); + await tx.delete(WebsiteSetting).where(eq(WebsiteSetting.key, key)); + await logAudit( + mutationAuditEntry( + operation, + context, + { key, value: before[key] }, + undefined, + ), + tx, + ); + }); + const invalidation = await siteSettings.reload(); + if (!invalidation.invalidated) throw new SystemCommittedCacheFailure(null); + try { + bustGamedataCachesIfNeeded(key); + } catch { + throw new SystemCommittedCacheFailure(null); + } return null; } @@ -876,9 +1131,30 @@ async function executeConfigurationMutation( operation === "configuration.setting.create" ? text(data.comment, 255) || null : undefined; - await upsertWebsiteSetting(key, value, comment); - await siteSettings.reload(); - bustGamedataCachesIfNeeded(key); + await db.transaction(async (tx) => { + const before = await loadWebsiteSettingValues(tx, [key]); + await upsertWebsiteSetting(key, value, comment, tx); + await logAudit( + mutationAuditEntry( + operation, + context, + { key, value: before[key] }, + { + key, + value, + ...(comment === undefined ? {} : { comment }), + }, + ), + tx, + ); + }); + const invalidation = await siteSettings.reload(); + if (!invalidation.invalidated) throw new SystemCommittedCacheFailure(null); + try { + bustGamedataCachesIfNeeded(key); + } catch { + throw new SystemCommittedCacheFailure(null); + } return null; } @@ -888,155 +1164,198 @@ async function executeRconMutation( 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.retry": - return retryExternalSynchronization("set-rank", data, context); - case "rcon.set-rank": { - const userId = positiveInteger(data.userId); - const rank = positiveInteger(data.rank); - const intent = { - kind: "set-rank", - operation, - userId, - rank, - } as const satisfies SystemExternalSyncIntent; - await rankAssignmentCoordinator.commit({ - userId, - rank, - authorize(currentRank) { - if (context.capability.isSuperAdmin) return; - if (currentRank >= context.capability.actor.rank) { - throw new SystemMutationFailure( - "FORBIDDEN", - "errors.housekeeping.system.cannotChangePeerRank", + const usesDurableDatabaseIntent = + operation === "rcon.set-rank" || operation === "rcon.set-rank.retry"; + if (!usesDurableDatabaseIntent) { + await logAudit({ + ...mutationAuditEntry(operation, context), + outcome: "intent", + }); + } + let externalCompleted = false; + try { + 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.retry": + return retryExternalSynchronization("set-rank", data, context); + case "rcon.set-rank": { + const userId = positiveInteger(data.userId); + const rank = positiveInteger(data.rank); + const intent = { + kind: "set-rank", + operation, + userId, + rank, + } as const satisfies SystemExternalSyncIntent; + await rankAssignmentCoordinator.commit({ + userId, + rank, + authorize(currentRank) { + if (context.capability.isSuperAdmin) return; + if (currentRank >= context.capability.actor.rank) { + throw new SystemMutationFailure( + "FORBIDDEN", + "errors.housekeeping.system.cannotChangePeerRank", + ); + } + if (rank >= context.capability.actor.rank) { + throw new SystemMutationFailure( + "FORBIDDEN", + "errors.housekeeping.system.cannotAssignPeerRank", + ); + } + }, + async mutate(tx) { + await logStaffActivityInTransaction( + { + staffId: context.capability.actor.id, + action: "rank_assign", + description: `Assigned rank #${rank} to user #${userId}`, + targetType: "user", + targetId: userId, + }, + tx, ); - } - if (rank >= context.capability.actor.rank) { - throw new SystemMutationFailure( - "FORBIDDEN", - "errors.housekeeping.system.cannotAssignPeerRank", + await logAudit( + syncAuditEntry(intent, context, context.correlationId, "intent"), + tx, ); - } - }, - async mutate(tx) { - await logStaffActivityInTransaction( - { - staffId: context.capability.actor.id, - action: "rank_assign", - description: `Assigned rank #${rank} to user #${userId}`, - targetType: "user", - targetId: userId, - }, - tx, - ); - await logAudit( - syncAuditEntry(intent, context, context.correlationId, "intent"), - tx, - ); - }, - }); - return completeExternalSynchronization(intent, context, undefined, { - userId, - rank, - }); + }, + }); + return completeExternalSynchronization(intent, context, undefined, { + userId, + rank, + }); + } + 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; } - 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; + externalCompleted = true; + if (!usesDurableDatabaseIntent) { + try { + await logAudit( + mutationAuditEntry(operation, context, undefined, { + delivered: true, + }), + ); + } catch { + throw new SystemCommittedExternalFailure( + null, + "completed", + "unavailable", + ); + } + } + return null; + } catch (error) { + if ( + !usesDurableDatabaseIntent && + !externalCompleted && + !(error instanceof SystemCommittedExternalFailure) + ) { + try { + await logAudit({ + ...mutationAuditEntry(operation, context), + outcome: "failure", + }); + } catch { + // Preserve the authoritative external failure. + } + } + throw error; } - return null; } const systemProductionMutationAdapter: SystemMutationAdapter = { @@ -1055,6 +1374,7 @@ const systemProductionMutationAdapter: SystemMutationAdapter = { `configuration.${string}` >, input, + context, ); } if (operation.startsWith("rcon.")) { @@ -1066,10 +1386,21 @@ const systemProductionMutationAdapter: SystemMutationAdapter = { } if (operation === "operations.alerts.mark-read") { - await db - .update(AlertLogs) - .set({ isRead: true, updatedAt: new Date() }) - .where(eq(AlertLogs.isRead, false)); + await db.transaction(async (tx) => { + await tx + .update(AlertLogs) + .set({ isRead: true, updatedAt: new Date() }) + .where(eq(AlertLogs.isRead, false)); + await logAudit( + mutationAuditEntry( + operation, + context, + { unread: true }, + { unread: false }, + ), + tx, + ); + }); return null; } if (operation === "operations.alert.broadcast") { @@ -1080,8 +1411,39 @@ const systemProductionMutationAdapter: SystemMutationAdapter = { "errors.housekeeping.validation", ); } - await requireRcon(await rcon.send("hotelalert", { message })); - return { delivered: true }; + await logAudit({ + ...mutationAuditEntry(operation, context), + outcome: "intent", + }); + let delivered = false; + try { + await requireRcon(await rcon.send("hotelalert", { message })); + delivered = true; + try { + await logAudit( + mutationAuditEntry(operation, context, undefined, { + delivered: true, + }), + ); + } catch { + throw new SystemCommittedExternalFailure( + { delivered: true }, + "completed", + "unavailable", + ); + } + return { delivered: true }; + } catch (error) { + if (!delivered && !(error instanceof SystemCommittedExternalFailure)) { + try { + await logAudit({ + ...mutationAuditEntry(operation, context), + outcome: "failure", + }); + } catch {} + } + throw error; + } } const data = record(input); @@ -1107,10 +1469,30 @@ const systemProductionMutationAdapter: SystemMutationAdapter = { "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(); + await db.transaction(async (tx) => { + const before = await loadWebsiteSettingValues( + tx, + rows.map(([key]) => key), + ); + for (const [key, value, comment] of rows) { + await upsertWebsiteSetting(key, value, comment, tx); + } + await logAudit( + mutationAuditEntry( + operation, + context, + { settings: before }, + { + enabled, + message, + minimumLoginRank, + }, + ), + tx, + ); + }); + const invalidation = await siteSettings.reload(); + if (!invalidation.invalidated) throw new SystemCommittedCacheFailure(null); return null; }, }; @@ -1118,3 +1500,40 @@ const systemProductionMutationAdapter: SystemMutationAdapter = { export const systemMutationService = createSystemMutationService( systemProductionMutationAdapter, ); + +export async function executeLegacySystemMutation( + actor: { readonly id: number }, + operation: SystemMutationOperation, + input: unknown, +): Promise { + const [{ getHousekeepingCapabilityContext }, { createCorrelationId }] = + await Promise.all([ + import("../../../foundation/server-capability-context"), + import("../../../foundation/correlation"), + ]); + const capability = await getHousekeepingCapabilityContext(); + const correlationId = createCorrelationId(); + if (capability.actor.id !== actor.id) { + const error = new Error("errors.housekeeping.forbidden"); + error.name = "ActionError"; + throw error; + } + const result = await systemMutationService.execute( + { capability, correlationId, legacy: true }, + operation, + input, + ); + if (!result.ok) { + const error = new Error(result.error.messageKey); + error.name = "ActionError"; + throw error; + } + if (result.completion) { + const error = new Error( + `System change committed; synchronization or cache invalidation is incomplete. Reference: ${result.correlationId}`, + ); + error.name = "ActionError"; + throw error; + } + return result.data; +} diff --git a/src/features/housekeeping/domains/system/services/rank-mutations-production.test.ts b/src/features/housekeeping/domains/system/services/rank-mutations-production.test.ts index 7b568323..94427f58 100644 --- a/src/features/housekeeping/domains/system/services/rank-mutations-production.test.ts +++ b/src/features/housekeeping/domains/system/services/rank-mutations-production.test.ts @@ -19,6 +19,7 @@ const doubles = vi.hoisted(() => ({ dbUpdate: vi.fn(), deleteEmulatorRank: vi.fn(), deleteEmulatorRankInTransaction: vi.fn(), + fetchEmulatorRankForEdit: vi.fn(), logAudit: vi.fn(), logStaffActivity: vi.fn(), logStaffActivityInTransaction: vi.fn(), @@ -57,10 +58,19 @@ vi.mock("@/lib/services/audit", () => ({ })); vi.mock("@/lib/services/permission-ranks", () => ({ + RANK_GENERAL_FIELDS: new Set([ + "rank_name", + "badge", + "level", + "prefix", + "prefix_color", + "hidden_rank", + ]), createEmulatorRank: doubles.createEmulatorRank, createEmulatorRankInTransaction: doubles.createEmulatorRankInTransaction, deleteEmulatorRank: doubles.deleteEmulatorRank, deleteEmulatorRankInTransaction: doubles.deleteEmulatorRankInTransaction, + fetchEmulatorRankForEdit: doubles.fetchEmulatorRankForEdit, prepareEmulatorRankCreation: doubles.prepareEmulatorRankCreation, updateEmulatorRank: doubles.updateEmulatorRank, })); @@ -142,11 +152,29 @@ beforeEach(() => { doubles.createEmulatorRankInTransaction.mockResolvedValue(undefined); doubles.deleteEmulatorRank.mockResolvedValue(undefined); doubles.deleteEmulatorRankInTransaction.mockResolvedValue(undefined); + doubles.fetchEmulatorRankForEdit.mockResolvedValue({ + id: 7, + rank_name: "Moderator", + badge: "MOD", + level: 6, + prefix: "", + prefix_color: "", + hidden_rank: "0", + log_commands: "1", + room_effect: 0, + auto_credits_amount: 0, + auto_pixels_amount: 0, + auto_gotw_amount: 0, + auto_points_amount: 0, + permissions: { cmd_alert: "1" }, + permissionMaxValues: { cmd_alert: 1 }, + }); doubles.updateEmulatorRank.mockResolvedValue(undefined); doubles.logAudit.mockResolvedValue(undefined); doubles.logStaffActivity.mockResolvedValue(undefined); doubles.logStaffActivityInTransaction.mockResolvedValue(undefined); doubles.dbDelete.mockImplementation(deleteChain); + doubles.dbExecute.mockResolvedValue([[{ id: 7 }]]); doubles.dbInsert.mockImplementation(insertChain); doubles.dbTransaction.mockImplementation(async (run) => run(transactionToken), @@ -186,6 +214,45 @@ function expectPendingSynchronization( } describe("durable rank synchronization", () => { + it("rejects a missing rank update before any write or external synchronization", async () => { + doubles.dbExecute.mockResolvedValueOnce([[]]); + doubles.fetchEmulatorRankForEdit.mockResolvedValueOnce(null); + + const result = await systemMutationService.execute( + serviceContext(PERMS.PERMISSIONS_MANAGE), + "access.rank.update", + { id: 7, fields: { badge: "ADM" } }, + ); + + expect(result).toMatchObject({ ok: false, error: { code: "NOT_FOUND" } }); + expect(doubles.updateEmulatorRank).not.toHaveBeenCalled(); + expect(doubles.rconSend).not.toHaveBeenCalled(); + }); + + it("rejects empty rank update fields without opening a transaction", async () => { + const result = await systemMutationService.execute( + serviceContext(PERMS.PERMISSIONS_MANAGE), + "access.rank.update", + { id: 7, fields: {} }, + ); + + expect(result).toMatchObject({ ok: false, error: { code: "VALIDATION" } }); + expect(doubles.dbTransaction).not.toHaveBeenCalled(); + expect(doubles.updateEmulatorRank).not.toHaveBeenCalled(); + }); + + it("rejects an unknown rank field after locking the real row and before writing", async () => { + const result = await systemMutationService.execute( + serviceContext(PERMS.PERMISSIONS_MANAGE), + "access.rank.update", + { id: 7, fields: { definitely_not_a_rank_field: "1" } }, + ); + + expect(result).toMatchObject({ ok: false, error: { code: "VALIDATION" } }); + expect(doubles.dbTransaction).toHaveBeenCalledOnce(); + expect(doubles.updateEmulatorRank).not.toHaveBeenCalled(); + }); + it.each([ ["access.rank.delete", PERMS.PERMISSIONS_MANAGE, { id: 7 }], ["rcon.set-rank", PERMS.RCON_EXECUTE, { userId: 8, rank: 4 }], diff --git a/src/features/housekeeping/domains/system/services/system-atomic.test.ts b/src/features/housekeeping/domains/system/services/system-atomic.test.ts new file mode 100644 index 00000000..b3c0c6bd --- /dev/null +++ b/src/features/housekeeping/domains/system/services/system-atomic.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +import type { HousekeepingCapabilityContext } from "../../../foundation/contracts"; + +const doubles = vi.hoisted(() => ({ + deleteWhere: vi.fn(), + insertValues: vi.fn(), + logAudit: vi.fn(), + reload: vi.fn(), + selectRows: [] as unknown[][], + transaction: vi.fn(), + txExecute: vi.fn(), +})); + +function selectChain() { + const rows = doubles.selectRows.shift() ?? []; + return { from: () => ({ where: () => Promise.resolve(rows) }) }; +} + +const tx = { + delete: () => ({ where: doubles.deleteWhere }), + execute: doubles.txExecute, + insert: () => ({ values: doubles.insertValues }), + select: () => selectChain(), + update: vi.fn(), +}; + +vi.mock("@/lib/db", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, db: { ...actual.db, transaction: doubles.transaction } }; +}); +vi.mock("@/lib/services/audit", () => ({ logAudit: doubles.logAudit })); +vi.mock("@/lib/services/site-settings", () => ({ + siteSettings: { reload: doubles.reload }, +})); + +import { systemMutationService } from "./mutations"; + +function capability(permission: string): HousekeepingCapabilityContext { + return { + actor: { id: 42, username: "operator", rank: 9 }, + isSuperAdmin: false, + has: (slug) => slug === permission, + hasAny: (...slugs) => slugs.includes(permission), + hasAll: (...slugs) => slugs.every((slug) => slug === permission), + }; +} + +function context(permission: string) { + return { + capability: capability(permission), + correlationId: "system-atomic", + reason: "Approved system change", + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + doubles.selectRows.length = 0; + doubles.transaction.mockImplementation(async (run) => run(tx)); + doubles.txExecute.mockResolvedValue([[{ id: 5, slug: "rank_5" }]]); + doubles.insertValues.mockReturnValue({ + onDuplicateKeyUpdate: vi.fn().mockResolvedValue(undefined), + }); + doubles.reload.mockResolvedValue({ invalidated: true, redis: "invalidated" }); + doubles.logAudit.mockResolvedValue(undefined); +}); + +describe("System atomic database mutations", () => { + it("rejects an unresolved permission slug before replacing any grants", async () => { + doubles.selectRows.push([{ id: 11, slug: "admin.dashboard" }], []); + + const result = await systemMutationService.execute( + context(PERMS.PERMISSIONS_MANAGE), + "access.permissions.update", + { roleId: 5, permissionSlugs: [" admin.dashboard ", "missing.slug"] }, + ); + + expect(result).toMatchObject({ ok: false, error: { code: "VALIDATION" } }); + expect(doubles.deleteWhere).not.toHaveBeenCalled(); + expect(doubles.logAudit).not.toHaveBeenCalled(); + }); + + it("rolls back a managed-settings batch when the second write fails", async () => { + let writes = 0; + doubles.insertValues.mockImplementation(() => ({ + onDuplicateKeyUpdate: async () => { + writes += 1; + if (writes === 2) throw new Error("second write failed"); + }, + })); + + const result = await systemMutationService.execute( + context(PERMS.SETTINGS_EDIT), + "configuration.settings.save", + { settings: { cms_logo: "/logo.png", cms_favicon: "/favicon.ico" } }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { code: "DEPENDENCY_UNAVAILABLE" }, + }); + expect(doubles.transaction).toHaveBeenCalledOnce(); + expect(doubles.reload).not.toHaveBeenCalled(); + }); + + it("rolls back a settings write when its canonical audit fails", async () => { + doubles.logAudit.mockRejectedValueOnce(new Error("audit unavailable")); + const result = await systemMutationService.execute( + context(PERMS.SETTINGS_EDIT), + "configuration.settings.save", + { settings: { cms_logo: "/logo.png" } }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { code: "DEPENDENCY_UNAVAILABLE" }, + }); + expect(doubles.reload).not.toHaveBeenCalled(); + }); + + it("reports a committed settings write as partial when cache invalidation fails", async () => { + doubles.reload.mockResolvedValueOnce({ + invalidated: false, + redis: "unavailable", + }); + const result = await systemMutationService.execute( + context(PERMS.SETTINGS_EDIT), + "configuration.settings.save", + { settings: { cms_logo: "/logo.png" } }, + ); + + expect(result).toMatchObject({ + ok: true, + data: { saved: 1 }, + completion: { status: "partial", cache: "unavailable" }, + }); + }); +}); diff --git a/src/features/housekeeping/foundation/commands/dispatcher.test.ts b/src/features/housekeeping/foundation/commands/dispatcher.test.ts index e157a444..b0249678 100644 --- a/src/features/housekeeping/foundation/commands/dispatcher.test.ts +++ b/src/features/housekeeping/foundation/commands/dispatcher.test.ts @@ -83,6 +83,36 @@ function baseCommand( } describe("dispatchHousekeepingCommand", () => { + it("preserves a committed cache-invalidation partial from the command", async () => { + register( + baseCommand("system.dispatch.cache-partial", { + input: z.object({}), + execute: async (context) => + ok(null, context.correlationId, { + status: "partial", + external: "not-required", + audit: "persisted", + cache: "unavailable", + }), + }), + ); + + const result = await dispatchHousekeepingCommand( + { commandId: "system.dispatch.cache-partial", input: {} }, + dependencies(), + ); + + expect(result).toMatchObject({ + ok: true, + completion: { + status: "partial", + external: "not-required", + audit: "persisted", + cache: "unavailable", + }, + }); + }); + it("returns a typed not-found result for an unknown command", async () => { const result = await dispatchHousekeepingCommand( { commandId: "system.missing", input: {} }, diff --git a/src/features/housekeeping/foundation/commands/dispatcher.ts b/src/features/housekeeping/foundation/commands/dispatcher.ts index 73746702..05ed1089 100644 --- a/src/features/housekeeping/foundation/commands/dispatcher.ts +++ b/src/features/housekeeping/foundation/commands/dispatcher.ts @@ -64,6 +64,7 @@ const housekeepingResultSchema = z.discriminatedUnion("ok", [ status: z.literal("partial"), external: z.enum(["not-required", "completed", "failed"]), audit: z.enum(["persisted", "unavailable"]), + cache: z.enum(["invalidated", "unavailable"]).optional(), }) .optional(), }), diff --git a/src/lib/admin/acl-management-contract.test.ts b/src/lib/admin/acl-management-contract.test.ts index 6703311b..315454f4 100644 --- a/src/lib/admin/acl-management-contract.test.ts +++ b/src/lib/admin/acl-management-contract.test.ts @@ -3,12 +3,17 @@ 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 actionSource = readFileSync("src/actions/permissions.ts", "utf8"); + const serviceSource = readFileSync( + "src/features/housekeeping/domains/system/services/mutations.ts", + "utf8", + ); + expect(actionSource).toContain("PERMS.PERMISSIONS_MANAGE"); + expect(actionSource).toContain("adminAction"); + expect(actionSource).toContain("executeLegacySystemMutation"); + expect(serviceSource).toContain("AclModelPermission"); + expect(actionSource).not.toContain("websiteHousekeepingPermissions"); + expect(actionSource).not.toContain("websiteTeams"); }); it("ships an idempotent ACL completion migration", () => { diff --git a/src/lib/services/rank-entrypoints.test.ts b/src/lib/services/rank-entrypoints.test.ts index 1b637b35..9fe2fc78 100644 --- a/src/lib/services/rank-entrypoints.test.ts +++ b/src/lib/services/rank-entrypoints.test.ts @@ -21,7 +21,7 @@ vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({ peopleMutationService: { execute: doubles.people }, })); vi.mock("@/features/housekeeping/domains/system/services/mutations", () => ({ - systemMutationService: { execute: doubles.system }, + executeLegacySystemMutation: doubles.system, })); vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() })); @@ -54,9 +54,7 @@ beforeEach(() => { correlationId: "operation-42", }); doubles.system.mockResolvedValue({ - ok: true, - data: { rank: 4 }, - correlationId: "operation-42", + rank: 4, }); }); @@ -64,30 +62,28 @@ describe("legacy rank entrypoints", () => { it("delegates legacy rank deletion to the same System transaction", async () => { await deleteRank({ ...context, data: { id: 4 } } as never); expect(doubles.system).toHaveBeenCalledWith( - expect.objectContaining({ - capability: expect.objectContaining({ actor: context.session.user }), - }), + { id: 42 }, "access.rank.delete", { id: 4 }, ); }); it("routes command-centre assignments through the authorized System service", async () => { await setRank({ ...context, data: { userId: 8, rank: 4 } } as never); - expect(doubles.system).toHaveBeenCalledWith( - expect.objectContaining({ - capability: expect.objectContaining({ actor: context.session.user }), - correlationId: expect.any(String), - }), - "rcon.set-rank", - { userId: 8, rank: 4 }, - ); + expect(doubles.system).toHaveBeenCalledWith({ id: 42 }, "rcon.set-rank", { + userId: 8, + rank: 4, + }); }); it("does not present a partially synchronized command-centre assignment as complete", async () => { - doubles.system.mockResolvedValue(partial); + doubles.system.mockRejectedValue( + new Error( + "System change committed; synchronization or cache invalidation is incomplete. Reference: recovery-42", + ), + ); await expect( setRank({ ...context, data: { userId: 8, rank: 4 } } as never), - ).rejects.toThrow("Rank saved"); + ).rejects.toThrow("System change committed"); }); it("routes the old user editor through People, preserving fields and actor identity", async () => {