feat(housekeeping): deliver system vertical
This commit is contained in:
1 parent
0117b45d74
commit
3788ecd9f1
33 files changed
+4548
-423
No files matched your search
@@ -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`
|
||||
@@ -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: {
|
||||
|
||||
+34
-10
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
|
||||
/** Mark every unread ops alert as read. */
|
||||
export async function markAllAlertsRead(): Promise<void> {
|
||||
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 */
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
.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<void> {
|
||||
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<void> {
|
||||
.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");
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
[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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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");
|
||||
}
|
||||
@@ -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<AdminActionContext, "session" | "permissions" | "requestId">,
|
||||
): 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<unknown> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
.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<void> {
|
||||
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");
|
||||
}
|
||||
+114
-106
@@ -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<void> {
|
||||
if (!ok) throw new ActionError(RCON_FAIL);
|
||||
function mutationContext(
|
||||
ctx: Pick<AdminActionContext, "session" | "permissions" | "requestId">,
|
||||
): 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<AdminActionContext, "session" | "permissions" | "requestId">,
|
||||
operation: SystemMutationOperation,
|
||||
input: unknown,
|
||||
): Promise<void> {
|
||||
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();
|
||||
},
|
||||
);
|
||||
+59
-208
@@ -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<AdminActionContext, "session" | "permissions" | "requestId">,
|
||||
): 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<AdminActionContext, "session" | "permissions" | "requestId">,
|
||||
operation: SystemMutationOperation,
|
||||
input: unknown,
|
||||
): Promise<unknown> {
|
||||
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 });
|
||||
},
|
||||
);
|
||||
@@ -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<unknown, unknown>[];
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<I> {
|
||||
readonly id: (typeof SYSTEM_COMMAND_IDS)[number];
|
||||
readonly operation: SystemMutationOperation;
|
||||
readonly capability: string;
|
||||
readonly input: z.ZodType<I>;
|
||||
readonly requiresReason: boolean;
|
||||
readonly attempts?: number;
|
||||
}
|
||||
|
||||
function systemCommand<I>(
|
||||
service: Pick<SystemMutationService, "execute">,
|
||||
options: CommandOptions<I>,
|
||||
): HousekeepingCommand<I, unknown> {
|
||||
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<SystemMutationService, "execute">,
|
||||
) {
|
||||
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);
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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<SystemAccessQueryData>;
|
||||
}
|
||||
|
||||
function state(
|
||||
kind: "loading" | "empty" | "error",
|
||||
title: string,
|
||||
description: string,
|
||||
) {
|
||||
return (
|
||||
<div data-housekeeping-state={kind}>
|
||||
<HousekeepingPageState
|
||||
state={kind}
|
||||
title={title}
|
||||
description={description}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function forbidden() {
|
||||
return (
|
||||
<section
|
||||
role="alert"
|
||||
data-housekeeping-state="forbidden"
|
||||
className="rounded-lg border border-[var(--admin-error)] bg-[var(--admin-surface)] p-4"
|
||||
>
|
||||
<h2 className="font-medium text-[var(--admin-text)]">Access denied</h2>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
||||
Your account cannot manage permissions.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function accessContent(data: SystemAccessQueryData) {
|
||||
if (data.kind === "permission-detail") {
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||
<h2 className="font-medium text-[var(--admin-text)]">
|
||||
{data.rank.name}
|
||||
</h2>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-2 text-sm text-[var(--admin-text-muted)]">
|
||||
<dt>Rank ID</dt>
|
||||
<dd>{data.rank.id}</dd>
|
||||
<dt>Level</dt>
|
||||
<dd>{data.rank.level}</dd>
|
||||
<dt>Users</dt>
|
||||
<dd>{data.rank.userCount}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||
<h2 className="font-medium text-[var(--admin-text)]">
|
||||
CMS permissions
|
||||
</h2>
|
||||
<ul className="mt-3 space-y-1 text-sm text-[var(--admin-text-muted)]">
|
||||
{data.rank.cmsRole?.permissionSlugs.map((slug) => (
|
||||
<li key={slug}>{slug}</li>
|
||||
)) ?? <li>No CMS role is linked.</li>}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[2fr_1fr]">
|
||||
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||
<h2 className="font-medium text-[var(--admin-text)]">
|
||||
Permission ranks
|
||||
</h2>
|
||||
<ul className="mt-3 divide-y divide-[var(--admin-border)]">
|
||||
{data.ranks.map((rank) => (
|
||||
<li
|
||||
key={rank.id}
|
||||
className="flex items-center justify-between py-2 text-sm"
|
||||
>
|
||||
<a
|
||||
href={`/ase/system/access/permissions/${rank.id}`}
|
||||
className="font-medium text-[var(--admin-text)]"
|
||||
>
|
||||
{rank.name}
|
||||
</a>
|
||||
<span className="text-[var(--admin-text-muted)]">
|
||||
{rank.userCount} users
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||
<h2 className="font-medium text-[var(--admin-text)]">ACL summary</h2>
|
||||
<p className="mt-3 text-sm text-[var(--admin-text-muted)]">
|
||||
{data.acl.roles} roles and {data.acl.permissions} permissions
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<div
|
||||
data-housekeeping-state={
|
||||
data.partialDependencies.length > 0 ? "partial" : "ready"
|
||||
}
|
||||
className="space-y-4"
|
||||
>
|
||||
{data.partialDependencies.length > 0 ? (
|
||||
<HousekeepingPageState
|
||||
state="partial"
|
||||
partialLabel="Partial data"
|
||||
title="Some access data is unavailable"
|
||||
description={`Unavailable: ${data.partialDependencies.join(", ")}`}
|
||||
/>
|
||||
) : null}
|
||||
{accessContent(data)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<HousekeepingPageShell
|
||||
title="Access control"
|
||||
description="Review emulator ranks and CMS ACL assignments."
|
||||
>
|
||||
{body}
|
||||
</HousekeepingPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
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 <SystemAccessPage result={result} />;
|
||||
}
|
||||
@@ -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<SystemConfigurationQueryData>;
|
||||
}
|
||||
|
||||
function pageState(
|
||||
kind: "loading" | "empty" | "error",
|
||||
title: string,
|
||||
description: string,
|
||||
) {
|
||||
return (
|
||||
<div data-housekeeping-state={kind}>
|
||||
<HousekeepingPageState
|
||||
state={kind}
|
||||
title={title}
|
||||
description={description}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function forbiddenState() {
|
||||
return (
|
||||
<section
|
||||
role="alert"
|
||||
data-housekeeping-state="forbidden"
|
||||
className="rounded-lg border border-[var(--admin-error)] bg-[var(--admin-surface)] p-4"
|
||||
>
|
||||
<h2 className="font-medium text-[var(--admin-text)]">Access denied</h2>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
||||
Your account cannot view system configuration.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function settingList(title: string, rows: readonly SystemSettingRow[]) {
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||
<h2 className="font-medium text-[var(--admin-text)]">{title}</h2>
|
||||
<dl className="mt-3 divide-y divide-[var(--admin-border)]">
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className="grid gap-1 py-2 text-sm md:grid-cols-[minmax(12rem,1fr)_2fr]"
|
||||
>
|
||||
<dt className="font-medium text-[var(--admin-text)]">{row.key}</dt>
|
||||
<dd className="break-words text-[var(--admin-text-muted)]">
|
||||
{row.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function configurationContent(data: SystemConfigurationQueryData) {
|
||||
return data.kind === "settings" ? (
|
||||
settingList("Website settings", data.settings)
|
||||
) : (
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{settingList("Emulator settings", data.settings)}
|
||||
<div className="space-y-2">
|
||||
{settingList("Emulator texts", data.texts)}
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
Showing {data.texts.length} of {data.textsTotal} text entries.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<div
|
||||
data-housekeeping-state={
|
||||
data.partialDependencies.length > 0 ? "partial" : "ready"
|
||||
}
|
||||
className="space-y-4"
|
||||
>
|
||||
{data.partialDependencies.length > 0 ? (
|
||||
<HousekeepingPageState
|
||||
state="partial"
|
||||
partialLabel="Partial data"
|
||||
title="Some configuration data is unavailable"
|
||||
description={`Unavailable: ${data.partialDependencies.join(", ")}`}
|
||||
/>
|
||||
) : null}
|
||||
{configurationContent(data)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<HousekeepingPageShell
|
||||
title="System configuration"
|
||||
description="Review CMS and emulator configuration sources."
|
||||
>
|
||||
{body}
|
||||
</HousekeepingPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
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 <SystemConfigurationPage result={result} />;
|
||||
}
|
||||
@@ -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<SystemObservabilityQueryData>;
|
||||
}
|
||||
|
||||
function pageState(
|
||||
kind: "loading" | "empty" | "error",
|
||||
title: string,
|
||||
description: string,
|
||||
) {
|
||||
return (
|
||||
<div data-housekeeping-state={kind}>
|
||||
<HousekeepingPageState
|
||||
state={kind}
|
||||
title={title}
|
||||
description={description}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function forbiddenState() {
|
||||
return (
|
||||
<section
|
||||
role="alert"
|
||||
data-housekeeping-state="forbidden"
|
||||
className="rounded-lg border border-[var(--admin-error)] bg-[var(--admin-surface)] p-4"
|
||||
>
|
||||
<h2 className="font-medium text-[var(--admin-text)]">Access denied</h2>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
||||
Your account cannot view this observability source.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function observabilityContent(data: SystemObservabilityQueryData) {
|
||||
if (data.kind === "devops") {
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||
<h2 className="font-medium text-[var(--admin-text)]">
|
||||
Service health
|
||||
</h2>
|
||||
{data.health ? (
|
||||
<dl className="mt-3 grid grid-cols-2 gap-2 text-sm text-[var(--admin-text-muted)]">
|
||||
<dt>Database</dt>
|
||||
<dd>{data.health.dbOk ? "Available" : "Unavailable"}</dd>
|
||||
<dt>Redis</dt>
|
||||
<dd>
|
||||
{data.health.redisOk === null
|
||||
? "Not configured"
|
||||
: data.health.redisOk
|
||||
? "Available"
|
||||
: "Unavailable"}
|
||||
</dd>
|
||||
<dt>Emulator</dt>
|
||||
<dd>{data.health.emulatorOk ? "Available" : "Unavailable"}</dd>
|
||||
<dt>Online users</dt>
|
||||
<dd>{data.health.onlineUsers}</dd>
|
||||
</dl>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-[var(--admin-text-muted)]">
|
||||
Health data is unavailable.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
{observationRows("Recent emulator errors", data.errors)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (data.kind === "devops-errors") {
|
||||
return observationRows("Emulator errors", data.errors);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{data.metrics.map((metric) => (
|
||||
<section
|
||||
key={metric.label}
|
||||
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||
>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
{metric.label}
|
||||
</p>
|
||||
<p className="mt-1 text-xl font-semibold text-[var(--admin-text)]">
|
||||
{metric.value}
|
||||
</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
{observationRows(
|
||||
data.kind === "logs" ? "Recent entries" : "Details",
|
||||
data.rows,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function observationRows(
|
||||
title: string,
|
||||
rows: readonly { id: string; primary: string; secondary?: string }[],
|
||||
) {
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||
<h2 className="font-medium text-[var(--admin-text)]">{title}</h2>
|
||||
<ul className="mt-3 divide-y divide-[var(--admin-border)]">
|
||||
{rows.map((row) => (
|
||||
<li key={row.id} className="py-2 text-sm">
|
||||
<p className="font-medium text-[var(--admin-text)]">
|
||||
{row.primary}
|
||||
</p>
|
||||
{row.secondary ? (
|
||||
<p className="break-words text-[var(--admin-text-muted)]">
|
||||
{row.secondary}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<div
|
||||
data-housekeeping-state={
|
||||
result.data.partialDependencies.length > 0 ? "partial" : "ready"
|
||||
}
|
||||
className="space-y-4"
|
||||
>
|
||||
{result.data.partialDependencies.length > 0 ? (
|
||||
<HousekeepingPageState
|
||||
state="partial"
|
||||
partialLabel="Partial data"
|
||||
title="Some observability data is unavailable"
|
||||
description={`Unavailable: ${result.data.partialDependencies.join(", ")}`}
|
||||
/>
|
||||
) : null}
|
||||
{observabilityContent(result.data)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HousekeepingPageShell
|
||||
title="System observability"
|
||||
description="Review metrics, service health, and operational records."
|
||||
>
|
||||
{body}
|
||||
</HousekeepingPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
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 <SystemObservabilityPage result={result} />;
|
||||
}
|
||||
@@ -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<SystemOperationsQueryData>;
|
||||
}
|
||||
|
||||
function pageState(
|
||||
kind: "loading" | "empty" | "error",
|
||||
title: string,
|
||||
description: string,
|
||||
) {
|
||||
return (
|
||||
<div data-housekeeping-state={kind}>
|
||||
<HousekeepingPageState
|
||||
state={kind}
|
||||
title={title}
|
||||
description={description}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function forbiddenState() {
|
||||
return (
|
||||
<section
|
||||
role="alert"
|
||||
data-housekeeping-state="forbidden"
|
||||
className="rounded-lg border border-[var(--admin-error)] bg-[var(--admin-surface)] p-4"
|
||||
>
|
||||
<h2 className="font-medium text-[var(--admin-text)]">Access denied</h2>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
||||
Your account cannot use this system operation.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function maintenanceSummary(data: {
|
||||
readonly enabled: boolean;
|
||||
readonly message: string;
|
||||
readonly minimumLoginRank: number;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||
<h2 className="font-medium text-[var(--admin-text)]">Maintenance</h2>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-2 text-sm text-[var(--admin-text-muted)]">
|
||||
<dt>Status</dt>
|
||||
<dd>{data.enabled ? "Enabled" : "Disabled"}</dd>
|
||||
<dt>Minimum login rank</dt>
|
||||
<dd>{data.minimumLoginRank}</dd>
|
||||
<dt>Message</dt>
|
||||
<dd>{data.message || "No message configured"}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function operationsContent(data: SystemOperationsQueryData) {
|
||||
if (data.kind === "alerts") {
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||
<h2 className="font-medium text-[var(--admin-text)]">
|
||||
Operational alerts
|
||||
</h2>
|
||||
<ul className="mt-3 divide-y divide-[var(--admin-border)]">
|
||||
{data.alerts.map((alert) => (
|
||||
<li key={alert.id} className="py-2 text-sm">
|
||||
<p className="font-medium text-[var(--admin-text)]">
|
||||
{alert.type} - {alert.severity}
|
||||
</p>
|
||||
<p className="text-[var(--admin-text-muted)]">{alert.message}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (data.kind === "maintenance") return maintenanceSummary(data.maintenance);
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||
<h2 className="font-medium text-[var(--admin-text)]">Command center</h2>
|
||||
<p className="mt-3 text-sm text-[var(--admin-text-muted)]">
|
||||
{data.onlineUsers.count} users online
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1 text-sm text-[var(--admin-text-muted)]">
|
||||
{data.onlineUsers.users.map((user) => (
|
||||
<li key={user.id}>{user.username}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
|
||||
<h2 className="font-medium text-[var(--admin-text)]">Service health</h2>
|
||||
<p className="mt-3 text-sm text-[var(--admin-text-muted)]">
|
||||
{data.health
|
||||
? `Database ${data.health.dbOk ? "available" : "unavailable"}; emulator ${data.health.emulatorOk ? "available" : "unavailable"}.`
|
||||
: "Health data is unavailable."}
|
||||
</p>
|
||||
</section>
|
||||
{data.maintenance ? maintenanceSummary(data.maintenance) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<div
|
||||
data-housekeeping-state={
|
||||
result.data.partialDependencies.length > 0 ? "partial" : "ready"
|
||||
}
|
||||
className="space-y-4"
|
||||
>
|
||||
{result.data.partialDependencies.length > 0 ? (
|
||||
<HousekeepingPageState
|
||||
state="partial"
|
||||
partialLabel="Partial data"
|
||||
title="Some operation data is unavailable"
|
||||
description={`Unavailable: ${result.data.partialDependencies.join(", ")}`}
|
||||
/>
|
||||
) : null}
|
||||
{operationsContent(result.data)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HousekeepingPageShell
|
||||
title="System operations"
|
||||
description="Review alerts, command-center status, and maintenance availability."
|
||||
>
|
||||
{body}
|
||||
</HousekeepingPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
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 <SystemOperationsPage result={result} />;
|
||||
}
|
||||
@@ -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<unknown>) => string;
|
||||
readonly empty: unknown;
|
||||
readonly partial: unknown;
|
||||
readonly ready: unknown;
|
||||
};
|
||||
|
||||
const pageCases: readonly PageCase[] = [
|
||||
{
|
||||
name: "access",
|
||||
render: (result) =>
|
||||
renderToStaticMarkup(<SystemAccessPage result={result as never} />),
|
||||
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(
|
||||
<SystemConfigurationPage result={result as never} />,
|
||||
),
|
||||
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(
|
||||
<SystemObservabilityPage result={result as never} />,
|
||||
),
|
||||
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(<SystemOperationsPage result={result as never} />),
|
||||
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"',
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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<Record<string, string>>;
|
||||
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<readonly SystemAccessRank[]>;
|
||||
loadRankDetail(rankId: number): Promise<SystemAccessRankDetail | null>;
|
||||
loadAclOverview(): Promise<SystemAclOverview>;
|
||||
}
|
||||
|
||||
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<SystemAccessQueryInput, SystemAccessQueryData> {
|
||||
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);
|
||||
@@ -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<readonly SystemSettingRow[]>;
|
||||
loadEmulatorSettings(): Promise<readonly SystemSettingRow[]>;
|
||||
loadEmulatorTexts(): Promise<SystemEmulatorTextResult>;
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
@@ -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<SystemObservationList>;
|
||||
loadLogs(
|
||||
view: "staff" | "audit" | "chat" | "commands" | "trades",
|
||||
): Promise<SystemObservationList>;
|
||||
loadHealth(): Promise<SystemHealthSnapshot>;
|
||||
loadErrors(): Promise<readonly SystemObservationRow[]>;
|
||||
}
|
||||
|
||||
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<T>(query: unknown): Promise<T[]> {
|
||||
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<Record<string, number | bigint>>(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<Record<string, number | bigint>>(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<Record<string, number | bigint>>(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,
|
||||
);
|
||||
@@ -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<readonly SystemAlertRow[]>;
|
||||
loadHealth(): Promise<SystemHealthSnapshot>;
|
||||
loadOnlineUsers(): Promise<SystemOnlineRoster>;
|
||||
loadMaintenance(): Promise<SystemMaintenanceSnapshot>;
|
||||
}
|
||||
|
||||
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<SystemOperationsQueryInput, SystemOperationsQueryData> {
|
||||
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,
|
||||
);
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) }),
|
||||
),
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
@@ -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<typeof import("@/lib/db")>();
|
||||
return {
|
||||
...actual,
|
||||
db: {
|
||||
...actual.db,
|
||||
update: vi.fn(() => ({
|
||||
set: vi.fn(() => ({ where: markReadWhere })),
|
||||
})),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/services/rcon", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/services/rcon")>();
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<unknown>;
|
||||
}
|
||||
|
||||
export interface SystemMutationService {
|
||||
execute(
|
||||
context: SystemMutationContext,
|
||||
operation: SystemMutationOperation,
|
||||
input: unknown,
|
||||
): Promise<HousekeepingResult<unknown>>;
|
||||
}
|
||||
|
||||
class SystemMutationFailure extends Error {
|
||||
constructor(
|
||||
readonly code: HousekeepingErrorCode,
|
||||
readonly messageKey: string,
|
||||
readonly fieldErrors?: Readonly<Record<string, readonly string[]>>,
|
||||
) {
|
||||
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<string, unknown> {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
||||
throw new SystemMutationFailure(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
);
|
||||
}
|
||||
return input as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
if (!result) {
|
||||
throw new SystemMutationFailure(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertWebsiteSetting(
|
||||
key: string,
|
||||
value: string,
|
||||
comment?: string | null,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, ...(comment === undefined ? {} : { comment }) })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
}
|
||||
|
||||
async function executeAccessMutation(
|
||||
operation: Extract<SystemMutationOperation, `access.${string}`>,
|
||||
input: unknown,
|
||||
context: SystemMutationContext,
|
||||
): Promise<unknown> {
|
||||
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<SystemMutationOperation, `configuration.${string}`>,
|
||||
input: unknown,
|
||||
): Promise<unknown> {
|
||||
const data = record(input);
|
||||
if (operation === "configuration.settings.save") {
|
||||
const settings = record(data.settings);
|
||||
const allowedKeys = new Set<string>(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<SystemMutationOperation, `rcon.${string}`>,
|
||||
input: unknown,
|
||||
context: SystemMutationContext,
|
||||
): Promise<unknown> {
|
||||
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<SystemMutationOperation, `access.${string}`>,
|
||||
input,
|
||||
context,
|
||||
);
|
||||
}
|
||||
if (operation.startsWith("configuration.")) {
|
||||
return executeConfigurationMutation(
|
||||
operation as Extract<
|
||||
SystemMutationOperation,
|
||||
`configuration.${string}`
|
||||
>,
|
||||
input,
|
||||
);
|
||||
}
|
||||
if (operation.startsWith("rcon.")) {
|
||||
return executeRconMutation(
|
||||
operation as Extract<SystemMutationOperation, `rcon.${string}`>,
|
||||
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,
|
||||
);
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, ReadonlySet<string>>([
|
||||
[
|
||||
"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", () => {
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user