diff --git a/.superpowers/sdd/2026-08-26-housekeeping-completion/task-12-report.md b/.superpowers/sdd/2026-08-26-housekeeping-completion/task-12-report.md new file mode 100644 index 00000000..365e9316 --- /dev/null +++ b/.superpowers/sdd/2026-08-26-housekeeping-completion/task-12-report.md @@ -0,0 +1,155 @@ +# Task 12 — People users, community, and staff workflows + +Status: DONE + +## Delivered scope + +- Registered exactly the nine approved real People routes: users list/edit/multi-account/detail, community online/guilds/guild detail, and staff applications/teams. The remaining support and moderation routes stay catalogued in `routes.ts` but unregistered for Task 13. +- Added query-backed People pages with explicit loading, empty, partial, dependency-error, forbidden, and ready states. Links are canonical `/ase/people/*` links; optional mail/IP fields and mutation affordances remain absent unless their exact capability is present. +- Added the exact fourteen user command IDs plus the six stable People-owned IDs `people.guild.disband`, `people.application.decide`, `people.team.change`, `people.ip.action`, `people.vpn.configure`, and `people.word-filter.update`. +- Added bounded Zod command schemas, stable rate limits, dispatcher capability rechecks, confirmation metadata, a redirect-free server-only mutation service, and deterministic bootstrap registration. +- Extracted shared mutation behavior behind the existing actions while preserving the legacy action exports, exact ACLs, `/admin` revalidation, VPN redirect/fail-soft behavior, word-filter `ActionResult` shapes, already-gone delete semantics, and failure propagation where legacy persistence errors previously propagated. +- Added neutral EN/IT labels only for the nine runtime routes. + +## Security and behavior decisions + +- No ACL slug or route authorization rank threshold was added. Exact legacy capabilities remain authoritative: single ban/unban use `USERS_BAN`, reset-password uses `USERS_RESET_PASSWORD`, bulk/user/community/team/application operations use `USERS_EDIT`, IP/VPN use `SETTINGS_EDIT`, and word filter uses `WORDFILTER_EDIT`. +- The existing target hierarchy safeguard remains for legacy user mutations that previously used `guardRank`; alert remains capability-authorized without a new target-rank rule. +- Ordinary user edit and alert remain reason-free because their existing semantics are non-destructive. Sanctions, destructive operations, global/security changes, currency delivery, and bulk mutations require a nonblank dispatcher reason. Ban and bulk-ban operational reasons are also persisted with the mutation audit evidence. +- Every public service call rechecks the exact capability before production work. The production adapter is module-private; server-only placement is not treated as authorization. +- Successful mutations emit before/after audit evidence and a stable correlation ID. Audit persistence failure is fail-closed and maps to `DEPENDENCY_UNAVAILABLE`. User mutation audit snapshots omit mail because it is unnecessary PII; page projection continues to follow Task 11 exactly. +- User pages consume only Task 11 guarded read models, preserving bounded pagination, deterministic sorting, fail-closed DTO validation, serialization, zero-sentinel rules, watched state, permission context, and PII projection. +- Legacy wrappers remain on `/admin` behavior until Task 25. No `/admin`, `/mod`, API, redirect, database schema, deployment, or cutover behavior was changed. + +## Strict TDD evidence + +### Initial command/page/route RED + +```text +pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/commands/user-commands.test.ts src/features/housekeeping/domains/people/commands/community-commands.test.ts src/features/housekeeping/domains/people/pages/people-primary-pages.test.tsx +Test Files 3 failed (3) +Tests 0 +Missing modules: community-commands, ../services/mutations, ../route-handlers +``` + +Initial focused GREEN: + +```text +Command tests: 2 files passed, 22 tests passed +Primary page/route tests: 3 files passed, 12 tests passed +Bootstrap tests: 1 file passed, 2 tests passed +``` + +### Foundation integration RED/GREEN + +RED after enabling People: + +```text +pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people src/features/housekeeping/foundation +Test Files 3 failed | 31 passed +Tests 4 failed | 376 passed +Failures: stale System-only registry assertions, stale preview expectation, and an unapproved People vertical runtime edge. +``` + +GREEN after updating the explicit runtime-edge and registry contracts: + +```text +Focused foundation contracts: 3 files passed, 40 tests passed +People + foundation: 34 files passed, 380 tests passed +``` + +### Audited sanction reason + +RED: + +```text +pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/services/mutations-reason-production.test.ts +Test Files 1 failed (1) +Tests 1 failed (1) +The ban audit after-snapshot did not contain the nonblank sanction reason. +``` + +GREEN: + +```text +Production mutation contracts: 2 files passed, 4 tests passed +The audited snapshot includes the reason and excludes mail. +``` + +### Legacy wrapper failure parity + +RED: + +```text +pnpm exec vitest run --coverage.enabled=false src/actions/people-wrapper-errors.test.ts +Test Files 1 failed (1) +Tests 3 failed (3) +Persistence failures were swallowed and already-gone word-filter deletion was not idempotent. +``` + +GREEN: + +```text +Test Files 1 passed (1) +Tests 3 passed (3) +``` + +### Single authorization check for positive bulk adjustment + +RED: + +```text +pnpm exec vitest run --coverage.enabled=false src/actions/bulk-adjust-wrapper.test.ts +Test Files 1 failed (1) +Tests 1 failed (1) +Expected one requirePermission call; received two. +``` + +GREEN: + +```text +Test Files 1 passed (1) +Tests 1 passed (1) +``` + +### Static gates during implementation + +```text +pnpm typecheck +RED: one unused `describe` import in admin-ip.test.ts +GREEN: tsc --noEmit, exit 0 + +pnpm exec biome check --formatter-enabled=false +RED: 14 import-order assists +GREEN: checked 44 files, no fixes applied +``` + +## Final verification + +```text +Focused wrapper/command/page/service/route/authorization/audit matrix +Test Files 22 passed (22) +Tests 129 passed (129) + +pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people src/features/housekeeping/foundation +Test Files 35 passed (35) +Tests 381 passed (381) + +pnpm test:housekeeping +Test Files 54 passed (54) +Tests 469 passed (469) + +pnpm typecheck +tsc --noEmit +Exit 0 + +pnpm exec biome check --formatter-enabled=false <44 exact changed Task 12 src files> +Checked 44 files. No fixes applied. + +git diff --check +Exit 0 +``` + +The Node engine warning remains the approved non-blocker: the repository requests Node `>=26.8.1 <27`, while this host runs Node `v26.7.0` with pnpm `11.24.0`. All test and type gates exited successfully. + +No database operation, deployment, push, or pull-request update was performed. diff --git a/src/actions/admin-applications.ts b/src/actions/admin-applications.ts index 22bce9fb..7d58ec28 100644 --- a/src/actions/admin-applications.ts +++ b/src/actions/admin-applications.ts @@ -1,24 +1,31 @@ "use server"; -import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { + createLegacyPeopleMutationContext, + peopleMutationService, +} from "@/features/housekeeping/domains/people/services/mutations"; +import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; import { requirePermission } from "@/lib/admin/guard"; -import { db, WebsiteStaffApplications } from "@/lib/db"; import { formPositiveBigInt } from "@/lib/form-data"; import { PERMS } from "@/lib/permissions"; export async function dismissApplication(formData: FormData): Promise { - await requirePermission(PERMS.USERS_EDIT); - const id = formPositiveBigInt(formData, "id"); - if (!id) return; - - try { - await db - .delete(WebsiteStaffApplications) - .where(eq(WebsiteStaffApplications.id, id)); - } catch { - // already gone / no DB — nothing to do - } + const staff = await requirePermission(PERMS.USERS_EDIT); + const rawId = formPositiveBigInt(formData, "id"); + if (!rawId) return; + const applicationId = Number(rawId); + if (!Number.isSafeInteger(applicationId) || applicationId <= 0) return; + await peopleMutationService.execute( + createLegacyPeopleMutationContext( + staff, + PERMS.USERS_EDIT, + createCorrelationId(), + ), + "application.decide", + { applicationId, decision: "dismiss" }, + ); + // Preserve the tolerant legacy action: already-gone/DB failure still refreshes. revalidatePath("/admin/applications"); } diff --git a/src/actions/admin-guilds.test.ts b/src/actions/admin-guilds.test.ts index 07b3b1f4..8a963475 100644 --- a/src/actions/admin-guilds.test.ts +++ b/src/actions/admin-guilds.test.ts @@ -1,91 +1,50 @@ -// @ts-nocheck import { revalidatePath } from "next/cache"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { requirePermissionRateLimited } from "@/lib/admin/guard"; -import { logStaffActivity } from "@/lib/services/staff-activity"; import { disbandGuild } from "./admin-guilds"; -const { selectLimit, transactionFn, deleteWhere, updateSet } = vi.hoisted( - () => { - const selectLimit = vi.fn(); - const transactionFn = vi.fn(); - const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]); - const updateSet = vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })); - return { selectLimit, transactionFn, deleteWhere, updateSet }; - }, -); - +const { execute } = vi.hoisted(() => ({ execute: vi.fn() })); +vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({ + createLegacyPeopleMutationContext: vi.fn( + (staff, permission, correlationId) => ({ + staff, + permission, + correlationId, + }), + ), + peopleMutationService: { execute }, +})); vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() })); -vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } })); -vi.mock("@/lib/db", () => ({ - db: { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: selectLimit, - })), - })), - })), - transaction: transactionFn, - delete: vi.fn(() => ({ where: deleteWhere })), - update: vi.fn(() => ({ set: updateSet })), - }, - Guilds: { id: "id", name: "name", userId: "userId" }, - GuildsForumsThreads: { id: "id", guildId: "guildId" }, - GuildsForumsComments: { threadId: "threadId" }, - GuildForumViews: { guildId: "guildId" }, - GuildsMembers: { guildId: "guildId" }, - Rooms: { guildId: "guildId" }, - Items: { guildId: "guildId" }, +vi.mock("@/lib/permissions", () => ({ + PERMS: { USERS_EDIT: "admin.users.edit" }, })); -vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); const staff = { id: 1, rank: 7, username: "admin" }; -const fakeForm = (data: Record) => ({ - get: (key: string) => data[key] ?? null, -}); +const form = (data: Record) => + ({ get: (key: string) => data[key] ?? null }) as FormData; beforeEach(() => { vi.clearAllMocks(); vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never); + execute.mockResolvedValue({ + ok: true, + data: { before: { id: 1 }, after: null }, + correlationId: "guild", + }); }); -describe("disbandGuild", () => { - it("disbands guild and cleans related data", async () => { - selectLimit.mockResolvedValue([{ id: 1, name: "TestGuild", userId: 42 }]); - transactionFn.mockImplementation( - async (fn: (tx: unknown) => Promise) => { - const txSelectLimit = vi.fn().mockResolvedValue([{ id: 10 }]); - const tx = { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: txSelectLimit, - })), - })), - })), - delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })), - update: vi.fn(() => ({ - set: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })), - })), - }; - // For threads findMany (no limit) — make where resolve to array - tx.select = vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn().mockResolvedValue([{ id: 10 }]), - })), - })); - await fn(tx); - }, - ); - await disbandGuild(fakeForm({ id: "1" }) as unknown as FormData); - expect(logStaffActivity).toHaveBeenCalled(); +describe("disbandGuild legacy wrapper", () => { + it("keeps rate-limited ACL, service input, and /admin revalidation", async () => { + await disbandGuild(form({ id: "9" })); + expect(execute).toHaveBeenCalledWith(expect.anything(), "guild.disband", { + guildId: 9, + }); expect(revalidatePath).toHaveBeenCalledWith("/admin/guilds"); }); - - it("returns early when id is not positive", async () => { - await disbandGuild(fakeForm({ id: "0" }) as unknown as FormData); - expect(selectLimit).not.toHaveBeenCalled(); + it("keeps invalid IDs as a no-op", async () => { + await disbandGuild(form({ id: "0" })); + expect(execute).not.toHaveBeenCalled(); + expect(revalidatePath).not.toHaveBeenCalled(); }); }); diff --git a/src/actions/admin-guilds.ts b/src/actions/admin-guilds.ts index 26c827c5..4c043f8b 100644 --- a/src/actions/admin-guilds.ts +++ b/src/actions/admin-guilds.ts @@ -1,65 +1,32 @@ "use server"; -import { eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; -import { requirePermissionRateLimited } from "@/lib/admin/guard"; import { - db, - GuildForumViews, - Guilds, - GuildsForumsComments, - GuildsForumsThreads, - GuildsMembers, - Items, - Rooms, -} from "@/lib/db"; + createLegacyPeopleMutationContext, + peopleMutationService, +} from "@/features/housekeeping/domains/people/services/mutations"; +import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; +import { requirePermissionRateLimited } from "@/lib/admin/guard"; import { PERMS } from "@/lib/permissions"; -import { logStaffActivity } from "@/lib/services/staff-activity"; /** Disband a guild and clean related membership/forum rows. */ export async function disbandGuild(formData: FormData): Promise { const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT); - const id = Number(formData.get("id")); - if (!(id > 0)) return; + const guildId = Number(formData.get("id")); + if (!Number.isSafeInteger(guildId) || guildId <= 0) return; - const [guild] = await db - .select({ - id: Guilds.id, - name: Guilds.name, - userId: Guilds.userId, - }) - .from(Guilds) - .where(eq(Guilds.id, id)) - .limit(1); - if (!guild) return; - - await db.transaction(async (tx) => { - const threads = await tx - .select({ id: GuildsForumsThreads.id }) - .from(GuildsForumsThreads) - .where(eq(GuildsForumsThreads.guildId, id)); - const threadIds = threads.map((t) => t.id); - if (threadIds.length > 0) { - await tx - .delete(GuildsForumsComments) - .where(inArray(GuildsForumsComments.threadId, threadIds)); - await tx - .delete(GuildsForumsThreads) - .where(eq(GuildsForumsThreads.guildId, id)); - } - await tx.delete(GuildForumViews).where(eq(GuildForumViews.guildId, id)); - await tx.delete(GuildsMembers).where(eq(GuildsMembers.guildId, id)); - await tx.update(Rooms).set({ guildId: 0 }).where(eq(Rooms.guildId, id)); - await tx.update(Items).set({ guildId: 0 }).where(eq(Items.guildId, id)); - await tx.delete(Guilds).where(eq(Guilds.id, id)); - }); - - await logStaffActivity({ - staffId: staff.id, - action: "guild_disband", - description: `Disbanded guild #${id} (${guild.name}), owner #${guild.userId}`, - targetType: "guild", - targetId: id, - }); + const result = await peopleMutationService.execute( + createLegacyPeopleMutationContext( + staff, + PERMS.USERS_EDIT, + createCorrelationId(), + ), + "guild.disband", + { guildId }, + ); + if (!result.ok) { + if (result.error.code === "NOT_FOUND") return; + throw new Error("Could not disband guild"); + } revalidatePath("/admin/guilds"); } diff --git a/src/actions/admin-ip.test.ts b/src/actions/admin-ip.test.ts index 13354e38..0376e9fb 100644 --- a/src/actions/admin-ip.test.ts +++ b/src/actions/admin-ip.test.ts @@ -1,6 +1,5 @@ -// @ts-nocheck import { revalidatePath } from "next/cache"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, expect, it, vi } from "vitest"; import { requirePermission } from "@/lib/admin/guard"; import { addBlacklist, @@ -9,80 +8,59 @@ import { deleteWhitelist, } from "./admin-ip"; -const { insertValues, deleteWhere } = vi.hoisted(() => { - const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]); - const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]); - return { insertValues, deleteWhere }; -}); - +const { execute } = vi.hoisted(() => ({ execute: vi.fn() })); +vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({ + createLegacyPeopleMutationContext: vi.fn( + (staff, permission, correlationId) => ({ + staff, + permission, + correlationId, + }), + ), + peopleMutationService: { execute }, +})); vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); vi.mock("@/lib/permissions", () => ({ - PERMS: { SETTINGS_EDIT: "settings.edit" }, -})); -vi.mock("@/lib/db", () => ({ - db: { - insert: vi.fn(() => ({ values: insertValues })), - delete: vi.fn(() => ({ where: deleteWhere })), - }, - WebsiteIpWhitelist: { id: "id" }, - WebsiteIpBlacklist: { id: "id" }, + PERMS: { SETTINGS_EDIT: "admin.settings.edit" }, })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); const staff = { id: 1, rank: 7, username: "admin" }; -const fakeForm = (data: Record) => ({ - get: (key: string) => data[key] ?? null, -}); +const form = (data: Record) => + ({ get: (key: string) => data[key] ?? null }) as FormData; beforeEach(() => { vi.clearAllMocks(); vi.mocked(requirePermission).mockResolvedValue(staff as never); - insertValues.mockResolvedValue([{ insertId: 1 }]); - deleteWhere.mockResolvedValue([{ affectedRows: 1 }]); -}); - -describe("addWhitelist", () => { - it("creates whitelist entry", async () => { - await addWhitelist( - fakeForm({ ipAddress: "192.168.1.1" }) as unknown as FormData, - ); - expect(insertValues).toHaveBeenCalledWith({ - ipAddress: "192.168.1.1", - asn: null, - whitelistAsn: false, - }); - expect(revalidatePath).toHaveBeenCalledWith("/admin/ip"); - }); - - it("returns early when ip is empty", async () => { - await addWhitelist(fakeForm({ ipAddress: "" }) as unknown as FormData); - expect(insertValues).not.toHaveBeenCalled(); + execute.mockResolvedValue({ + ok: true, + data: { before: null, after: {} }, + correlationId: "ip", }); }); -describe("deleteWhitelist", () => { - it("deletes whitelist entry", async () => { - await deleteWhitelist(fakeForm({ id: "42" }) as unknown as FormData); - expect(deleteWhere).toHaveBeenCalled(); - }); +it("preserves all four IP actions and /admin revalidation", async () => { + await addWhitelist(form({ ipAddress: "192.0.2.1", asn: "AS1" })); + await addBlacklist(form({ ipAddress: "198.51.100.1" })); + await deleteWhitelist(form({ id: "42" })); + await deleteBlacklist(form({ id: "99" })); + expect(execute.mock.calls.map((call) => [call[1], call[2]])).toEqual([ + [ + "ip.action", + { action: "add-whitelist", ipAddress: "192.0.2.1", asn: "AS1" }, + ], + [ + "ip.action", + { action: "add-blacklist", ipAddress: "198.51.100.1", asn: "" }, + ], + ["ip.action", { action: "delete-whitelist", id: 42 }], + ["ip.action", { action: "delete-blacklist", id: 99 }], + ]); + expect(revalidatePath).toHaveBeenCalledTimes(4); }); -describe("addBlacklist", () => { - it("creates blacklist entry", async () => { - await addBlacklist( - fakeForm({ ipAddress: "203.0.113.1" }) as unknown as FormData, - ); - expect(insertValues).toHaveBeenCalledWith({ - ipAddress: "203.0.113.1", - asn: null, - blacklistAsn: false, - }); - }); -}); - -describe("deleteBlacklist", () => { - it("deletes blacklist entry", async () => { - await deleteBlacklist(fakeForm({ id: "99" }) as unknown as FormData); - expect(deleteWhere).toHaveBeenCalled(); - }); +it("keeps empty IP input as a no-op after authorization", async () => { + await addWhitelist(form({ ipAddress: "" })); + expect(requirePermission).toHaveBeenCalledWith("admin.settings.edit"); + expect(execute).not.toHaveBeenCalled(); }); diff --git a/src/actions/admin-ip.ts b/src/actions/admin-ip.ts index 9afc11c2..7c80a908 100644 --- a/src/actions/admin-ip.ts +++ b/src/actions/admin-ip.ts @@ -1,72 +1,63 @@ "use server"; -import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { + createLegacyPeopleMutationContext, + peopleMutationService, +} from "@/features/housekeeping/domains/people/services/mutations"; +import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; import { requirePermission } from "@/lib/admin/guard"; -import { db, WebsiteIpBlacklist, WebsiteIpWhitelist } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -function parseIp(formData: FormData): string { - return String(formData.get("ipAddress") ?? "") +function parse(formData: FormData, key: string): string { + return String(formData.get(key) ?? "") .normalize("NFC") .trim() .slice(0, 255); } -function parseAsn(formData: FormData): string | null { - const asn = String(formData.get("asn") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255); - return asn || null; +async function run( + formData: FormData, + action: + | "add-whitelist" + | "delete-whitelist" + | "add-blacklist" + | "delete-blacklist", +): Promise { + const staff = await requirePermission(PERMS.SETTINGS_EDIT); + const adding = action.startsWith("add-"); + const input = adding + ? { + action, + ipAddress: parse(formData, "ipAddress"), + asn: parse(formData, "asn"), + } + : { action, id: Number(formData.get("id")) }; + if (adding && !("ipAddress" in input && input.ipAddress)) return; + const id = "id" in input ? input.id : undefined; + if (!adding && !(Number.isSafeInteger(id) && Number(id) > 0)) return; + const result = await peopleMutationService.execute( + createLegacyPeopleMutationContext( + staff, + PERMS.SETTINGS_EDIT, + createCorrelationId(), + ), + "ip.action", + input, + ); + if (!result.ok) throw new Error("Could not update IP rules"); + revalidatePath("/admin/ip"); } export async function addWhitelist(formData: FormData): Promise { - await requirePermission(PERMS.SETTINGS_EDIT); - const ipAddress = parseIp(formData); - if (!ipAddress) return; - const asn = parseAsn(formData); - await db.insert(WebsiteIpWhitelist).values({ - ipAddress, - asn, - whitelistAsn: asn != null, - }); - revalidatePath("/admin/ip"); + return run(formData, "add-whitelist"); } - export async function deleteWhitelist(formData: FormData): Promise { - await requirePermission(PERMS.SETTINGS_EDIT); - const raw = String(formData.get("id") ?? "") - .normalize("NFC") - .trim(); - if (!raw) return; - await db - .delete(WebsiteIpWhitelist) - .where(eq(WebsiteIpWhitelist.id, BigInt(raw))); - revalidatePath("/admin/ip"); + return run(formData, "delete-whitelist"); } - export async function addBlacklist(formData: FormData): Promise { - await requirePermission(PERMS.SETTINGS_EDIT); - const ipAddress = parseIp(formData); - if (!ipAddress) return; - const asn = parseAsn(formData); - await db.insert(WebsiteIpBlacklist).values({ - ipAddress, - asn, - blacklistAsn: asn != null, - }); - revalidatePath("/admin/ip"); + return run(formData, "add-blacklist"); } - export async function deleteBlacklist(formData: FormData): Promise { - await requirePermission(PERMS.SETTINGS_EDIT); - const raw = String(formData.get("id") ?? "") - .normalize("NFC") - .trim(); - if (!raw) return; - await db - .delete(WebsiteIpBlacklist) - .where(eq(WebsiteIpBlacklist.id, BigInt(raw))); - revalidatePath("/admin/ip"); + return run(formData, "delete-blacklist"); } diff --git a/src/actions/admin-teams.test.ts b/src/actions/admin-teams.test.ts index d30a24fb..1e737651 100644 --- a/src/actions/admin-teams.test.ts +++ b/src/actions/admin-teams.test.ts @@ -1,59 +1,63 @@ -// @ts-nocheck import { revalidatePath } from "next/cache"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, expect, it, vi } from "vitest"; import { requirePermission } from "@/lib/admin/guard"; import { createTeam, deleteTeam } from "./admin-teams"; -const { insertValues, deleteWhere } = vi.hoisted(() => { - const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]); - const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]); - return { insertValues, deleteWhere }; -}); - +const { execute } = vi.hoisted(() => ({ execute: vi.fn() })); +vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({ + createLegacyPeopleMutationContext: vi.fn( + (staff, permission, correlationId) => ({ + staff, + permission, + correlationId, + }), + ), + peopleMutationService: { execute }, +})); vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); -vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } })); -vi.mock("@/lib/db", () => ({ - db: { - insert: vi.fn(() => ({ values: insertValues })), - delete: vi.fn(() => ({ where: deleteWhere })), - }, - WebsiteTeams: { id: "id" }, +vi.mock("@/lib/permissions", () => ({ + PERMS: { USERS_EDIT: "admin.users.edit" }, })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); -const staff = { id: 1, rank: 7, username: "admin" }; -const fakeForm = (data: Record) => ({ - get: (key: string) => (key in data ? data[key] : null), -}); +const form = (data: Record) => + ({ get: (key: string) => data[key] ?? null }) as FormData; beforeEach(() => { vi.clearAllMocks(); - vi.mocked(requirePermission).mockResolvedValue(staff as never); - insertValues.mockResolvedValue([{ insertId: 1 }]); - deleteWhere.mockResolvedValue([{ affectedRows: 1 }]); -}); - -describe("createTeam", () => { - it("creates a team entry", async () => { - await createTeam( - fakeForm({ rankName: "Moderator" }) as unknown as FormData, - ); - expect(insertValues).toHaveBeenCalledWith( - expect.objectContaining({ rankName: "Moderator" }), - ); - expect(revalidatePath).toHaveBeenCalledWith("/admin/teams"); + vi.mocked(requirePermission).mockResolvedValue({ + id: 1, + rank: 7, + username: "admin", }); - - it("returns early when rankName is empty", async () => { - await createTeam(fakeForm({ rankName: "" }) as unknown as FormData); - expect(insertValues).not.toHaveBeenCalled(); + execute.mockResolvedValue({ + ok: true, + data: { before: null, after: {} }, + correlationId: "team", }); }); -describe("deleteTeam", () => { - it("deletes a team entry", async () => { - await deleteTeam(fakeForm({ id: "42" }) as unknown as FormData); - expect(deleteWhere).toHaveBeenCalled(); - expect(revalidatePath).toHaveBeenCalledWith("/admin/teams"); - }); +it("preserves create and delete team payloads plus /admin revalidation", async () => { + await createTeam(form({ rankName: "Moderator" })); + await deleteTeam(form({ id: "42" })); + expect(execute.mock.calls.map((call) => [call[1], call[2]])).toEqual([ + [ + "team.change", + { + action: "create", + rankName: "Moderator", + badge: "", + jobDescription: "", + staffColor: "#327fa8", + hiddenRank: false, + }, + ], + ["team.change", { action: "delete", teamId: 42 }], + ]); + expect(revalidatePath).toHaveBeenCalledTimes(2); +}); + +it("preserves empty rank name as a no-op", async () => { + await createTeam(form({ rankName: "" })); + expect(execute).not.toHaveBeenCalled(); }); diff --git a/src/actions/admin-teams.ts b/src/actions/admin-teams.ts index 5d4a2469..e380c140 100644 --- a/src/actions/admin-teams.ts +++ b/src/actions/admin-teams.ts @@ -1,50 +1,59 @@ "use server"; -import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { + createLegacyPeopleMutationContext, + peopleMutationService, +} from "@/features/housekeeping/domains/people/services/mutations"; +import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; import { requirePermission } from "@/lib/admin/guard"; -import { db, WebsiteTeams } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; +function text(formData: FormData, key: string): string { + return String(formData.get(key) ?? "") + .normalize("NFC") + .trim(); +} + export async function createTeam(formData: FormData): Promise { - await requirePermission(PERMS.USERS_EDIT); - - const rankName = String(formData.get("rankName") ?? "") - .normalize("NFC") - .trim(); + const staff = await requirePermission(PERMS.USERS_EDIT); + const rankName = text(formData, "rankName"); if (!rankName) return; - - const badge = String(formData.get("badge") ?? "") - .normalize("NFC") - .trim(); - const jobDescription = String(formData.get("jobDescription") ?? "") - .normalize("NFC") - .trim(); - const staffColor = - String(formData.get("staffColor") ?? "") - .normalize("NFC") - .trim() || "#327fa8"; - const hiddenRank = formData.get("hiddenRank") === "on"; - - const now = new Date(); - await db.insert(WebsiteTeams).values({ - rankName: rankName.slice(0, 255), - badge: badge ? badge.slice(0, 255) : null, - jobDescription: jobDescription ? jobDescription.slice(0, 255) : null, - staffColor: staffColor.slice(0, 255), - hiddenRank, - createdAt: now, - updatedAt: now, - }); - + const result = await peopleMutationService.execute( + createLegacyPeopleMutationContext( + staff, + PERMS.USERS_EDIT, + createCorrelationId(), + ), + "team.change", + { + action: "create", + rankName, + badge: text(formData, "badge"), + jobDescription: text(formData, "jobDescription"), + staffColor: text(formData, "staffColor") || "#327fa8", + hiddenRank: formData.get("hiddenRank") === "on", + }, + ); + if (!result.ok) throw new Error("Could not create team"); revalidatePath("/admin/teams"); } export async function deleteTeam(formData: FormData): Promise { - await requirePermission(PERMS.USERS_EDIT); - - const id = BigInt(String(formData.get("id"))); - await db.delete(WebsiteTeams).where(eq(WebsiteTeams.id, id)); - + const staff = await requirePermission(PERMS.USERS_EDIT); + const teamId = Number(formData.get("id")); + if (!Number.isSafeInteger(teamId) || teamId <= 0) return; + const result = await peopleMutationService.execute( + createLegacyPeopleMutationContext( + staff, + PERMS.USERS_EDIT, + createCorrelationId(), + ), + "team.change", + { action: "delete", teamId }, + ); + if (!result.ok && result.error.code !== "NOT_FOUND") { + throw new Error("Could not delete team"); + } revalidatePath("/admin/teams"); } diff --git a/src/actions/admin-vpn.test.ts b/src/actions/admin-vpn.test.ts index 90eb3970..f0d187d8 100644 --- a/src/actions/admin-vpn.test.ts +++ b/src/actions/admin-vpn.test.ts @@ -1,60 +1,72 @@ +import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, expect, it, vi } from "vitest"; import { requirePermission } from "@/lib/admin/guard"; -import { siteSettings } from "@/lib/services/site-settings"; import { saveVpn } from "./admin-vpn"; -const { mockValues, mockOnDuplicateKeyUpdate } = vi.hoisted(() => { - const mockOnDuplicateKeyUpdate = vi.fn().mockResolvedValue(undefined); - const mockValues = vi.fn(() => ({ - onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate, - })); - return { mockValues, mockOnDuplicateKeyUpdate }; -}); - +const { execute } = vi.hoisted(() => ({ execute: vi.fn() })); +vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({ + createLegacyPeopleMutationContext: vi.fn( + (staff, permission, correlationId) => ({ + staff, + permission, + correlationId, + }), + ), + peopleMutationService: { execute }, +})); vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); vi.mock("@/lib/permissions", () => ({ - PERMS: { SETTINGS_EDIT: "settings.edit" }, + PERMS: { SETTINGS_EDIT: "admin.settings.edit" }, })); -vi.mock("@/lib/db", () => ({ - db: { - insert: vi.fn(() => ({ values: mockValues })), - }, - WebsiteSetting: { key: "key", value: "value" }, -})); -vi.mock("@/lib/services/site-settings", () => ({ - siteSettings: { reload: vi.fn() }, -})); -vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("next/navigation", () => ({ redirect: vi.fn() })); -const staff = { id: 1, rank: 7, username: "admin" }; -const fakeForm = (data: Record) => ({ - get: (key: string) => (key in data ? data[key] : null), -}); +const form = (data: Record) => + ({ get: (key: string) => data[key] ?? null }) as FormData; beforeEach(() => { vi.clearAllMocks(); - vi.mocked(requirePermission).mockResolvedValue(staff as never); - mockValues.mockReturnValue({ - onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate, + vi.mocked(requirePermission).mockResolvedValue({ + id: 1, + rank: 7, + username: "admin", + }); + execute.mockResolvedValue({ + ok: true, + data: { before: {}, after: {} }, + correlationId: "vpn", }); - mockOnDuplicateKeyUpdate.mockResolvedValue(undefined); }); -describe("saveVpn", () => { - it("saves VPN settings and redirects", async () => { - await saveVpn( - fakeForm({ - vpn_block_enabled: "1", - vpn_provider: "proxycheck", - vpn_api_key: "abc123", - }) as unknown as FormData, - ); - expect(mockValues).toHaveBeenCalledTimes(4); - expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(4); - expect(siteSettings.reload).toHaveBeenCalled(); - expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1"); +it("preserves VPN payload, /admin revalidation, and redirect", async () => { + await saveVpn( + form({ + vpn_block_enabled: "1", + vpn_provider: "proxycheck", + vpn_api_key: "abc123", + }), + ); + expect(execute).toHaveBeenCalledWith(expect.anything(), "vpn.configure", { + enabled: true, + provider: "proxycheck", + apiKey: "abc123", + blockMessage: "", }); + expect(revalidatePath).toHaveBeenCalledWith("/admin/vpn"); + expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1"); +}); + +it("preserves fail-soft redirect without claiming a saved revalidation", async () => { + execute.mockResolvedValue({ + ok: false, + error: { + code: "DEPENDENCY_UNAVAILABLE", + messageKey: "errors.housekeeping.dependencyUnavailable", + }, + correlationId: "vpn-fail", + }); + await saveVpn(form({ vpn_provider: "none" })); + expect(revalidatePath).not.toHaveBeenCalled(); + expect(redirect).toHaveBeenCalledWith("/admin/vpn?saved=1"); }); diff --git a/src/actions/admin-vpn.ts b/src/actions/admin-vpn.ts index 521023c6..c1b93425 100644 --- a/src/actions/admin-vpn.ts +++ b/src/actions/admin-vpn.ts @@ -2,88 +2,44 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; +import { + createLegacyPeopleMutationContext, + peopleMutationService, +} from "@/features/housekeeping/domains/people/services/mutations"; +import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; import { requirePermission } from "@/lib/admin/guard"; -import { db, WebsiteSetting } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -import { siteSettings } from "@/lib/services/site-settings"; -import { logStaffActivity } from "@/lib/services/staff-activity"; - -// VPN / proxy detection config. Stored as website_settings key/value rows -// (CMS-owned, BigInt id). Booleans use the strings "0" / "1", faithful to -// AtomCMS's setting() convention. This is registration-time protection only; -// the raw IP allow/deny list lives under /admin/ip (website_ip_*). const ALLOWED_PROVIDERS = new Set(["none", "proxycheck", "ipqualityscore"]); -/** Upsert one website_settings key with a stable housekeeping comment. */ -async function writeSetting( - key: string, - value: string, - comment: string, -): Promise { - await db - .insert(WebsiteSetting) - .values({ key, value, comment }) - .onDuplicateKeyUpdate({ set: { value } }); -} - export async function saveVpn(formData: FormData): Promise { const staff = await requirePermission(PERMS.SETTINGS_EDIT); - - // Toggle: an unchecked checkbox submits nothing, so absence === disabled. - const enabled = - String(formData.get("vpn_block_enabled") ?? "") - .normalize("NFC") - .trim() !== ""; - - const providerRaw = String(formData.get("vpn_provider") ?? "") + const rawProvider = String(formData.get("vpn_provider") ?? "") .normalize("NFC") .trim() .toLowerCase(); - const provider = ALLOWED_PROVIDERS.has(providerRaw) ? providerRaw : "none"; - - const apiKey = String(formData.get("vpn_api_key") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255); - const blockMessage = String(formData.get("vpn_block_message") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255); - - try { - await writeSetting( - "vpn_block_enabled", - enabled ? "1" : "0", - "Block registrations from detected VPN/proxy IPs (0=no, 1=yes)", - ); - await writeSetting( - "vpn_provider", + const provider = ALLOWED_PROVIDERS.has(rawProvider) ? rawProvider : "none"; + const result = await peopleMutationService.execute( + createLegacyPeopleMutationContext( + staff, + PERMS.SETTINGS_EDIT, + createCorrelationId(), + ), + "vpn.configure", + { + enabled: String(formData.get("vpn_block_enabled") ?? "").trim() !== "", provider, - "VPN/proxy detection provider (none/proxycheck/ipqualityscore)", - ); - await writeSetting( - "vpn_api_key", - apiKey, - "API key for the VPN/proxy detection provider", - ); - await writeSetting( - "vpn_block_message", - blockMessage, - "Message shown to users blocked for using a VPN/proxy", - ); - - siteSettings.reload(); - await logStaffActivity({ - staffId: staff.id, - action: "vpn_update", - description: `Updated VPN/proxy detection (block=${enabled ? "on" : "off"}, provider=${provider})`, - }); - revalidatePath("/admin/vpn"); - } catch { - // DB unavailable — fail soft so the action does not throw; the page - // re-renders the current (stored) state. - } - + apiKey: String(formData.get("vpn_api_key") ?? "") + .normalize("NFC") + .trim() + .slice(0, 255), + blockMessage: String(formData.get("vpn_block_message") ?? "") + .normalize("NFC") + .trim() + .slice(0, 255), + }, + ); + if (result.ok) revalidatePath("/admin/vpn"); + // Preserve fail-soft legacy navigation even when persistence is unavailable. redirect("/admin/vpn?saved=1"); } diff --git a/src/actions/admin-wordfilter.ts b/src/actions/admin-wordfilter.ts index 66fa3896..a95bfb0b 100644 --- a/src/actions/admin-wordfilter.ts +++ b/src/actions/admin-wordfilter.ts @@ -1,56 +1,60 @@ "use server"; -import { eq } from "drizzle-orm"; -import type { ResultSetHeader } from "mysql2"; import { revalidatePath } from "next/cache"; +import { + createLegacyPeopleMutationContext, + peopleMutationService, +} from "@/features/housekeeping/domains/people/services/mutations"; +import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; import { requirePermission } from "@/lib/admin/guard"; -import { db, WebsiteWordfilter } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { type ActionResult, actionError, actionOk, } from "@/lib/safe-action-shared"; -import { reloadWordFilter } from "@/lib/services/moderation"; -import { rcon } from "@/lib/services/rcon"; export async function addWord(input: { word: string; }): Promise> { - await requirePermission(PERMS.WORDFILTER_EDIT); + const staff = await requirePermission(PERMS.WORDFILTER_EDIT); const word = String(input.word ?? "") .normalize("NFC") .trim() .slice(0, 255); if (!word) return actionError("Word is required"); - - try { - const [result] = (await db - .insert(WebsiteWordfilter) - .values({ word })) as unknown as [ResultSetHeader]; - reloadWordFilter(); - await rcon.updateWordFilter(); - revalidatePath("/admin/wordfilter"); - return actionOk({ id: String(result.insertId) }); - } catch { + const result = await peopleMutationService.execute( + createLegacyPeopleMutationContext( + staff, + PERMS.WORDFILTER_EDIT, + createCorrelationId(), + ), + "word-filter.update", + { action: "add", word }, + ); + if (!result.ok) return actionError("Could not add word (it may already exist)"); - } + revalidatePath("/admin/wordfilter"); + return actionOk({ id: String(result.data.after?.id ?? "") }); } export async function deleteWord(input: { id: string }): Promise { - await requirePermission(PERMS.WORDFILTER_EDIT); - const raw = String(input.id ?? "").normalize("NFC"); - if (!raw) return actionError("Missing word id"); - - try { - await db - .delete(WebsiteWordfilter) - .where(eq(WebsiteWordfilter.id, BigInt(raw))); - reloadWordFilter(); - await rcon.updateWordFilter(); - revalidatePath("/admin/wordfilter"); - return actionOk(); - } catch { + const staff = await requirePermission(PERMS.WORDFILTER_EDIT); + const id = Number(String(input.id ?? "").normalize("NFC")); + if (!Number.isSafeInteger(id) || id <= 0) + return actionError("Missing word id"); + const result = await peopleMutationService.execute( + createLegacyPeopleMutationContext( + staff, + PERMS.WORDFILTER_EDIT, + createCorrelationId(), + ), + "word-filter.update", + { action: "delete", id }, + ); + if (!result.ok && result.error.code !== "NOT_FOUND") { return actionError("Could not remove word"); } + revalidatePath("/admin/wordfilter"); + return actionOk(); } diff --git a/src/actions/bulk-adjust-wrapper.test.ts b/src/actions/bulk-adjust-wrapper.test.ts new file mode 100644 index 00000000..05c48dee --- /dev/null +++ b/src/actions/bulk-adjust-wrapper.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { requirePermission } from "@/lib/admin/guard"; + +const { execute } = vi.hoisted(() => ({ execute: vi.fn() })); +vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({ + createLegacyPeopleMutationContext: vi.fn( + (staff, permission, correlationId) => ({ + staff, + permission, + correlationId, + }), + ), + peopleMutationService: { execute }, +})); +vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); +vi.mock("@/lib/permissions", () => ({ + PERMS: { USERS_EDIT: "admin.users.edit" }, +})); +vi.mock("@/lib/db", () => ({ db: {}, User: {}, UsersCurrency: {} })); +vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() })); + +import { bulkAdjustCurrency } from "./bulk-users"; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requirePermission).mockResolvedValue({ + id: 1, + rank: 7, + username: "admin", + } as never); + execute.mockResolvedValue({ + ok: true, + data: { + before: { userIds: [7, 8] }, + after: { completed: 2, total: 2, failedIds: [] }, + }, + correlationId: "bulk-adjust", + }); +}); + +it("keeps one ACL check while delegating a positive bulk adjustment", async () => { + await expect( + bulkAdjustCurrency({ + userIds: [7, 8], + amount: 25, + type: "credits", + }), + ).resolves.toEqual({ + ok: true, + data: { adjusted: 2, total: 2, failedIds: [] }, + }); + expect(requirePermission).toHaveBeenCalledTimes(1); + expect(execute).toHaveBeenCalledWith( + expect.anything(), + "users.bulk-currency", + { userIds: [7, 8], amount: 25, type: "credits" }, + ); +}); diff --git a/src/actions/bulk-users.test.ts b/src/actions/bulk-users.test.ts index 22f2124b..009e1fa7 100644 --- a/src/actions/bulk-users.test.ts +++ b/src/actions/bulk-users.test.ts @@ -1,95 +1,32 @@ -// @ts-nocheck import { beforeEach, describe, expect, it, vi } from "vitest"; import { requirePermission } from "@/lib/admin/guard"; -import { rcon } from "@/lib/services/rcon"; import { bulkBan, bulkGiveBadge, bulkGiveCurrency, bulkUnban, + setTradeLock, } from "./bulk-users"; -const { - deleteWhere, - insertValues, - updateWhere, - selectLimit, - selectWhereResolved, - onDuplicateKeyUpdate, -} = vi.hoisted(() => { - const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 3 }]); - const onDuplicateKeyUpdate = vi.fn().mockResolvedValue([{ affectedRows: 1 }]); - const insertValues = vi.fn(() => ({ - onDuplicateKeyUpdate, - // biome-ignore lint/suspicious/noThenProperty: Drizzle query thenable mock - then(resolve, reject) { - return Promise.resolve([{ insertId: 1 }]).then(resolve, reject); - }, - })); - const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]); - const selectLimit = vi.fn().mockResolvedValue([]); - /** Rows returned when a select chain is awaited without `.limit()`. */ - const selectWhereResolved = vi.fn().mockResolvedValue([]); - return { - deleteWhere, - insertValues, - updateWhere, - selectLimit, - selectWhereResolved, - onDuplicateKeyUpdate, - }; -}); - -vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); -vi.mock("@/lib/permissions", () => ({ PERMS: { USERS_EDIT: "users.edit" } })); -vi.mock("@/lib/db", () => ({ - db: { - delete: vi.fn(() => ({ where: deleteWhere })), - insert: vi.fn(() => ({ values: insertValues })), - update: vi.fn(() => ({ - set: vi.fn(() => ({ where: updateWhere })), - })), - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: selectLimit, - // biome-ignore lint/suspicious/noThenProperty: Drizzle query thenable mock - then(resolve, reject) { - return selectWhereResolved().then(resolve, reject); - }, - })), - })), - })), - transaction: vi.fn(), - }, - Ban: { userId: "userId", id: "id" }, - User: { - id: "id", - credits: "credits", - username: "username", - online: "online", - }, - UsersCurrency: { userId: "userId", type: "type", amount: "amount" }, - UsersBadges: { - id: "id", - userId: "userId", - badgeCode: "badgeCode", - slotId: "slotId", - }, - Sanctions: { id: "id", habboId: "habboId" }, - UsersSettings: { - userId: "userId", - canTrade: "canTrade", - tradelockAmount: "tradelockAmount", - }, +const { execute } = vi.hoisted(() => ({ execute: vi.fn() })); +vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({ + createLegacyPeopleMutationContext: vi.fn( + (staff, permission, correlationId) => ({ + staff, + permission, + correlationId, + }), + ), + peopleMutationService: { execute }, })); -vi.mock("@/lib/services/rcon", () => ({ - rcon: { - giveCredits: vi.fn(), - giveDuckets: vi.fn(), - givePointsGotw: vi.fn(), - giveBadge: vi.fn(), - }, +vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); +vi.mock("@/lib/permissions", () => ({ + PERMS: { USERS_EDIT: "admin.users.edit" }, +})); +vi.mock("@/lib/db", () => ({ + db: {}, + User: {}, + UsersCurrency: {}, })); vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() })); @@ -98,83 +35,62 @@ const staff = { id: 1, rank: 7, username: "admin" }; beforeEach(() => { vi.clearAllMocks(); vi.mocked(requirePermission).mockResolvedValue(staff as never); - deleteWhere.mockResolvedValue([{ affectedRows: 3 }]); - insertValues.mockImplementation(() => ({ - onDuplicateKeyUpdate, - // biome-ignore lint/suspicious/noThenProperty: Drizzle query thenable mock - then(resolve, reject) { - return Promise.resolve([{ insertId: 1 }]).then(resolve, reject); + execute.mockImplementation(async (context, operation, input) => ({ + ok: true, + data: { + before: { input }, + after: { + completed: operation === "users.bulk-unban" ? 3 : 2, + total: Array.isArray(input.userIds) ? input.userIds.length : 1, + failedIds: [], + }, + output: operation === "user.trade-lock" ? input : undefined, }, + correlationId: context.correlationId, })); - onDuplicateKeyUpdate.mockResolvedValue([{ affectedRows: 1 }]); - updateWhere.mockResolvedValue([{ affectedRows: 1 }]); - selectLimit.mockResolvedValue([]); - selectWhereResolved.mockResolvedValue([]); }); -describe("bulkUnban", () => { - it("unbans users", async () => { - const r = await bulkUnban({ userIds: [1, 2, 3] }); - expect(r.ok).toBe(true); - expect(r.data).toEqual({ unbanned: 3, total: 3 }); - }); -}); - -describe("bulkBan", () => { - it("bans users", async () => { - const r = await bulkBan({ - userIds: [1, 2], - reason: "Spam", - duration: 3600, +describe("legacy bulk user wrappers", () => { + it("preserves result shapes while delegating the exact operations", async () => { + await expect(bulkUnban({ userIds: [1, 2, 3] })).resolves.toEqual({ + ok: true, + data: { unbanned: 3, total: 3 }, }); - expect(r.ok).toBe(true); - expect(r.data.banned).toBe(2); - expect(insertValues).toHaveBeenCalledTimes(2); - }); -}); - -describe("bulkGiveCurrency", () => { - it("gives credits", async () => { - const r = await bulkGiveCurrency({ - userIds: [1], - amount: 100, - type: "credits", + await expect( + bulkBan({ userIds: [1, 2], reason: "Spam", duration: 3600 }), + ).resolves.toEqual({ ok: true, data: { banned: 2 } }); + await expect( + bulkGiveCurrency({ userIds: [1], amount: 100, type: "credits" }), + ).resolves.toEqual({ + ok: true, + data: { given: 2, total: 1, failedIds: [] }, }); - expect(r.data.given).toBe(1); - expect(rcon.giveCredits).toHaveBeenCalledWith(1, 100); - expect(updateWhere).toHaveBeenCalled(); - }); - - it("gives pixels", async () => { - const r = await bulkGiveCurrency({ - userIds: [2], - amount: 50, - type: "pixels", + await expect( + bulkGiveBadge({ userIds: [1], badgeCode: "ADM" }), + ).resolves.toEqual({ + ok: true, + data: { given: 2, total: 1, failedIds: [] }, }); - expect(r.data.given).toBe(1); - expect(rcon.giveDuckets).toHaveBeenCalledWith(2, 50); - expect(onDuplicateKeyUpdate).toHaveBeenCalled(); - }); - it("gives points", async () => { - const r = await bulkGiveCurrency({ - userIds: [3], - amount: 25, - type: "points", - }); - expect(r.data.given).toBe(1); - expect(rcon.givePointsGotw).toHaveBeenCalledWith(3, 25); - expect(onDuplicateKeyUpdate).toHaveBeenCalled(); + expect(execute.mock.calls.map((call) => call[1])).toEqual([ + "users.bulk-unban", + "users.bulk-ban", + "users.bulk-currency", + "users.bulk-badge", + ]); }); -}); -describe("bulkGiveBadge", () => { - it("gives badge to user", async () => { - selectLimit.mockResolvedValueOnce([]); - selectWhereResolved.mockResolvedValueOnce([{ maxSlot: 5 }]); - const r = await bulkGiveBadge({ userIds: [1], badgeCode: "ADM" }); - expect(r.data.given).toBe(1); - expect(insertValues).toHaveBeenCalled(); - expect(rcon.giveBadge).toHaveBeenCalledWith(1, "ADM"); + it("preserves the trade-lock API and exact normalized payload", async () => { + await expect( + setTradeLock({ userId: 9, untilUnix: 1234.8 }), + ).resolves.toEqual({ + ok: true, + data: { userId: 9, untilUnix: 1234 }, + }); + expect(execute).toHaveBeenLastCalledWith( + expect.objectContaining({ permission: "admin.users.edit" }), + "user.trade-lock", + { userId: 9, untilUnix: 1234 }, + ); }); }); diff --git a/src/actions/bulk-users.ts b/src/actions/bulk-users.ts index a6a3d057..77102533 100644 --- a/src/actions/bulk-users.ts +++ b/src/actions/bulk-users.ts @@ -1,40 +1,75 @@ "use server"; -import { and, eq, inArray, max, sql } from "drizzle-orm"; -import { requirePermission } from "@/lib/admin/guard"; +import { and, eq } from "drizzle-orm"; import { - Ban, - db, - Sanctions, - User, - UsersBadges, - UsersCurrency, - UsersSettings, -} from "@/lib/db"; + createLegacyPeopleMutationContext, + peopleMutationService, +} from "@/features/housekeeping/domains/people/services/mutations"; +import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; +import { requirePermission } from "@/lib/admin/guard"; +import { db, User, UsersCurrency } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import type { ActionResult } from "@/lib/safe-action-shared"; -import { rcon } from "@/lib/services/rcon"; import { logStaffActivity } from "@/lib/services/staff-activity"; +async function executeLegacy( + staff: { + readonly id: number; + readonly username: string; + readonly rank: number; + }, + operation: + | "users.bulk-ban" + | "users.bulk-unban" + | "users.bulk-currency" + | "users.bulk-badge" + | "user.trade-lock", + input: unknown, +) { + return peopleMutationService.execute( + createLegacyPeopleMutationContext( + staff, + PERMS.USERS_EDIT, + createCorrelationId(), + ), + operation, + input, + ); +} + +function numberValue(value: unknown): number { + return Number.isSafeInteger(Number(value)) ? Number(value) : 0; +} + +function failedIds(value: unknown): Array<{ userId: number; reason: string }> { + return Array.isArray(value) + ? value.flatMap((item) => + typeof item === "object" && item !== null + ? [ + { + userId: numberValue(Reflect.get(item, "userId")), + reason: String(Reflect.get(item, "reason") ?? "Database error"), + }, + ] + : [], + ) + : []; +} + export async function bulkUnban({ userIds, }: { userIds: number[]; }): Promise> { const staff = await requirePermission(PERMS.USERS_EDIT); - const result = await db.delete(Ban).where(inArray(Ban.userId, userIds)); - const unbanned = Number( - (result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0, - ); - await logStaffActivity({ - staffId: staff.id, - action: "bulk_unban", - description: `Unbanned ${unbanned} user(s)`, - targetType: "user", - }); + const result = await executeLegacy(staff, "users.bulk-unban", { userIds }); + if (!result.ok) return { ok: false, error: "Bulk unban failed" }; return { - ok: true as const, - data: { unbanned, total: userIds.length }, + ok: true, + data: { + unbanned: numberValue(result.data.after?.completed), + total: numberValue(result.data.after?.total), + }, }; } @@ -48,34 +83,16 @@ export async function bulkBan({ duration: number; }): Promise> { const staff = await requirePermission(PERMS.USERS_EDIT); - const now = Math.floor(Date.now() / 1000); - let banned = 0; - - for (const userId of userIds) { - try { - await db.insert(Ban).values({ - userId, - ip: "", - machineId: "", - userStaffId: staff.id, - timestamp: now, - banExpire: duration > 0 ? now + duration : 0, - banReason: reason, - type: "account", - }); - banned++; - } catch { - // skip duplicates - } - } - - await logStaffActivity({ - staffId: staff.id, - action: "bulk_ban", - description: `Banned ${banned} user(s)`, - targetType: "user", + const result = await executeLegacy(staff, "users.bulk-ban", { + userIds, + reason, + duration, }); - return { ok: true as const, data: { banned } }; + if (!result.ok) return { ok: false, error: "Bulk ban failed" }; + return { + ok: true, + data: { banned: numberValue(result.data.after?.completed) }, + }; } export async function bulkGiveCurrency({ @@ -94,49 +111,19 @@ export async function bulkGiveCurrency({ }> > { const staff = await requirePermission(PERMS.USERS_EDIT); - let given = 0; - const failedIds: Array<{ userId: number; reason: string }> = []; - - for (const userId of userIds) { - try { - if (type === "credits") { - await db - .update(User) - .set({ credits: sql`${User.credits} + ${amount}` }) - .where(eq(User.id, userId)); - await rcon.giveCredits(userId, amount); - } else if (type === "pixels") { - await db - .insert(UsersCurrency) - .values({ userId, type: 0, amount }) - .onDuplicateKeyUpdate({ - set: { amount: sql`${UsersCurrency.amount} + ${amount}` }, - }); - await rcon.giveDuckets(userId, amount); - } else if (type === "points") { - await db - .insert(UsersCurrency) - .values({ userId, type: 101, amount }) - .onDuplicateKeyUpdate({ - set: { amount: sql`${UsersCurrency.amount} + ${amount}` }, - }); - await rcon.givePointsGotw(userId, amount); - } - given++; - } catch { - failedIds.push({ userId, reason: "Database error" }); - } - } - - await logStaffActivity({ - staffId: staff.id, - action: "bulk_give_currency", - description: `Gave ${amount} ${type} to ${given} user(s)`, - targetType: "user", + const result = await executeLegacy(staff, "users.bulk-currency", { + userIds, + amount, + type, }); + if (!result.ok) return { ok: false, error: "Bulk currency failed" }; return { - ok: true as const, - data: { given, total: userIds.length, failedIds }, + ok: true, + data: { + given: numberValue(result.data.after?.completed), + total: numberValue(result.data.after?.total), + failedIds: failedIds(result.data.after?.failedIds), + }, }; } @@ -154,45 +141,18 @@ export async function bulkGiveBadge({ }> > { const staff = await requirePermission(PERMS.USERS_EDIT); - let given = 0; - const failedIds: Array<{ userId: number; reason: string }> = []; - - for (const userId of userIds) { - try { - const [existing] = await db - .select({ id: UsersBadges.id }) - .from(UsersBadges) - .where( - and( - eq(UsersBadges.userId, userId), - eq(UsersBadges.badgeCode, badgeCode), - ), - ) - .limit(1); - if (!existing) { - const [agg] = await db - .select({ maxSlot: max(UsersBadges.slotId) }) - .from(UsersBadges) - .where(eq(UsersBadges.userId, userId)); - const slotId = (agg?.maxSlot ?? 0) + 1; - await db.insert(UsersBadges).values({ userId, slotId, badgeCode }); - await rcon.giveBadge(userId, badgeCode); - } - given++; - } catch { - failedIds.push({ userId, reason: "Database error" }); - } - } - - await logStaffActivity({ - staffId: staff.id, - action: "bulk_give_badge", - description: `Gave badge "${badgeCode}" to ${given} user(s)`, - targetType: "user", + const result = await executeLegacy(staff, "users.bulk-badge", { + userIds, + badgeCode, }); + if (!result.ok) return { ok: false, error: "Bulk badge failed" }; return { - ok: true as const, - data: { given, total: userIds.length, failedIds }, + ok: true, + data: { + given: numberValue(result.data.after?.completed), + total: numberValue(result.data.after?.total), + failedIds: failedIds(result.data.after?.failedIds), + }, }; } @@ -202,7 +162,6 @@ export async function bulkAdjustCurrency({ type, }: { userIds: number[]; - /** Positive = give, negative = take. Balances clamped at 0. */ amount: number; type: "credits" | "pixels" | "points"; }): Promise< @@ -214,29 +173,28 @@ export async function bulkAdjustCurrency({ > { const staff = await requirePermission(PERMS.USERS_EDIT); if (!Number.isFinite(amount) || amount === 0) { - return { ok: false as const, error: "Amount must be a non-zero number" }; + return { ok: false, error: "Amount must be a non-zero number" }; } - if (amount > 0) { - const given = await bulkGiveCurrency({ userIds, amount, type }); - if (!given.ok) return given; - if (!given.data) { - return { ok: false as const, error: "Currency adjustment failed" }; - } + const result = await executeLegacy(staff, "users.bulk-currency", { + userIds, + amount, + type, + }); + if (!result.ok) return { ok: false, error: "Currency adjustment failed" }; return { - ok: true as const, + ok: true, data: { - adjusted: given.data.given, - total: given.data.total, - failedIds: given.data.failedIds, + adjusted: numberValue(result.data.after?.completed), + total: numberValue(result.data.after?.total), + failedIds: failedIds(result.data.after?.failedIds), }, }; } const take = Math.abs(Math.trunc(amount)); let adjusted = 0; - const failedIds: Array<{ userId: number; reason: string }> = []; - + const failures: Array<{ userId: number; reason: string }> = []; for (const userId of userIds) { try { if (type === "credits") { @@ -246,11 +204,13 @@ export async function bulkAdjustCurrency({ .where(eq(User.id, userId)) .limit(1); if (!user) { - failedIds.push({ userId, reason: "Not found" }); + failures.push({ userId, reason: "Not found" }); continue; } - const next = Math.max(0, user.credits - take); - await db.update(User).set({ credits: next }).where(eq(User.id, userId)); + await db + .update(User) + .set({ credits: Math.max(0, user.credits - take) }) + .where(eq(User.id, userId)); } else { const currencyType = type === "pixels" ? 0 : 101; const [row] = await db @@ -263,19 +223,17 @@ export async function bulkAdjustCurrency({ ), ) .limit(1); - const current = row?.amount ?? 0; - const next = Math.max(0, current - take); + const next = Math.max(0, (row?.amount ?? 0) - take); await db .insert(UsersCurrency) .values({ userId, type: currencyType, amount: next }) .onDuplicateKeyUpdate({ set: { amount: next } }); } - adjusted++; + adjusted += 1; } catch { - failedIds.push({ userId, reason: "Database error" }); + failures.push({ userId, reason: "Database error" }); } } - await logStaffActivity({ staffId: staff.id, action: "bulk_adjust_currency", @@ -283,93 +241,24 @@ export async function bulkAdjustCurrency({ targetType: "user", }); return { - ok: true as const, - data: { adjusted, total: userIds.length, failedIds }, + ok: true, + data: { adjusted, total: userIds.length, failedIds: failures }, }; } -/** - * Persist trade lock on `sanctions.trade_locked_until` + `users_settings.can_trade` - * via Drizzle, then best-effort RCON sync (settradelock + alert + disconnect if online). - */ export async function setTradeLock({ userId, untilUnix, }: { userId: number; - /** Unix seconds; 0 clears the lock. */ untilUnix: number; }): Promise> { const staff = await requirePermission(PERMS.USERS_EDIT); const until = Math.max(0, Math.trunc(untilUnix)); - const locked = until > 0; - - const [user] = await db - .select({ - id: User.id, - username: User.username, - online: User.online, - }) - .from(User) - .where(eq(User.id, userId)) - .limit(1); - if (!user) { - return { ok: false as const, error: "User not found" }; - } - - await db.transaction(async (tx) => { - const [existing] = await tx - .select({ id: Sanctions.id }) - .from(Sanctions) - .where(eq(Sanctions.habboId, userId)) - .limit(1); - if (existing) { - await tx - .update(Sanctions) - .set({ - tradeLockedUntil: until, - ...(locked ? { reason: "Trade lock (CMS)" } : {}), - }) - .where(eq(Sanctions.id, existing.id)); - } else { - await tx.insert(Sanctions).values({ - habboId: userId, - tradeLockedUntil: until, - reason: locked ? "Trade lock (CMS)" : "", - }); - } - - await tx - .update(UsersSettings) - .set({ - canTrade: locked ? "0" : "1", - ...(locked - ? { tradelockAmount: sql`${UsersSettings.tradelockAmount} + 1` } - : {}), - }) - .where(eq(UsersSettings.userId, userId)); - }); - - await rcon.setTradeLock(userId, locked); - await rcon.alertUser( + const result = await executeLegacy(staff, "user.trade-lock", { userId, - locked - ? "Trading has been disabled by staff." - : "Trading has been re-enabled by staff.", - ); - if (user.online === "1") { - await rcon.disconnectUser(userId, user.username); - } - - await logStaffActivity({ - staffId: staff.id, - action: locked ? "trade_lock" : "trade_unlock", - description: locked - ? `Trade-locked ${user.username} (#${userId}) until ${until}` - : `Cleared trade lock for ${user.username} (#${userId})`, - targetType: "user", - targetId: userId, + untilUnix: until, }); - - return { ok: true as const, data: { userId, untilUnix: until } }; + if (!result.ok) return { ok: false, error: "Trade lock update failed" }; + return { ok: true, data: { userId, untilUnix: until } }; } diff --git a/src/actions/people-shared-wrappers.test.ts b/src/actions/people-shared-wrappers.test.ts new file mode 100644 index 00000000..de1ecc9d --- /dev/null +++ b/src/actions/people-shared-wrappers.test.ts @@ -0,0 +1,138 @@ +import { revalidatePath } from "next/cache"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { requirePermission } from "@/lib/admin/guard"; + +const { execute, staff } = vi.hoisted(() => ({ + execute: vi.fn(), + staff: { id: 1, rank: 7, username: "admin" }, +})); + +vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({ + createLegacyPeopleMutationContext: vi.fn( + (actor, permission, correlationId) => ({ + actor, + permission, + correlationId, + }), + ), + peopleMutationService: { execute }, +})); +vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); +vi.mock("@/lib/permissions", () => ({ + PERMS: { + USERS_EDIT: "admin.users.edit", + USERS_BAN: "admin.users.ban", + USERS_RESET_PASSWORD: "admin.users.reset_password", + WORDFILTER_EDIT: "admin.wordfilter.edit", + }, +})); +vi.mock("@/lib/safe-action", () => ({ + adminAction: + (_options: unknown, handler: (context: unknown) => unknown) => + (data: unknown) => + handler({ data, session: { user: staff } }), +})); +vi.mock("@/lib/safe-action-shared", () => ({ + ActionError: class ActionError extends Error { + constructor(message: string) { + super(message); + this.name = "ActionError"; + } + }, + actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }), + actionError: (error: string) => ({ ok: false, error }), +})); +vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() })); +vi.mock("@/lib/db", () => ({ + db: {}, + User: {}, + UsersBadges: {}, + UsersCurrency: {}, + UsersSettings: {}, +})); +vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() })); +vi.mock("@/lib/services/rcon", () => ({ rcon: {} })); +vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); + +import { dismissApplication } from "./admin-applications"; +import { addWord, deleteWord } from "./admin-wordfilter"; +import { banUser, resetPassword, updateUser } from "./users"; + +const form = (data: Record) => + ({ get: (key: string) => data[key] ?? null }) as FormData; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requirePermission).mockResolvedValue(staff); + execute.mockImplementation(async (context, operation) => ({ + ok: true, + data: { + before: {}, + after: operation === "word-filter.update" ? { id: 12 } : {}, + output: + operation === "user.reset-password" + ? { newPassword: "temporary-password" } + : undefined, + }, + correlationId: context.correlationId, + })); +}); + +describe("legacy user safe-action wrappers", () => { + it("preserves exact ACL-specific service delegation", async () => { + await (updateUser as never as (input: unknown) => Promise)({ + id: 7, + motto: "Ready", + }); + await (banUser as never as (input: unknown) => Promise)({ + userId: 7, + reason: "abuse", + duration: 0, + type: "account", + }); + const reset = await ( + resetPassword as never as (input: unknown) => Promise<{ + ok: boolean; + data: { newPassword: string }; + }> + )({ userId: 7 }); + + expect( + execute.mock.calls.map((call) => [call[0].permission, call[1]]), + ).toEqual([ + ["admin.users.edit", "user.update"], + ["admin.users.ban", "user.ban"], + ["admin.users.reset_password", "user.reset-password"], + ]); + expect(reset.data.newPassword).toBe("temporary-password"); + }); +}); + +describe("legacy application and word-filter wrappers", () => { + it("keeps tolerant application dismissal and /admin revalidation", async () => { + await dismissApplication(form({ id: "9" })); + expect(execute).toHaveBeenCalledWith( + expect.objectContaining({ permission: "admin.users.edit" }), + "application.decide", + { applicationId: 9, decision: "dismiss" }, + ); + expect(revalidatePath).toHaveBeenCalledWith("/admin/applications"); + }); + + it("preserves word-filter ActionResult shapes and /admin revalidation", async () => { + await expect(addWord({ word: "spam" })).resolves.toEqual({ + ok: true, + data: { id: "12" }, + }); + await expect(deleteWord({ id: "12" })).resolves.toEqual({ + ok: true, + data: {}, + }); + expect(execute.mock.calls.slice(-2).map((call) => call[2])).toEqual([ + { action: "add", word: "spam" }, + { action: "delete", id: 12 }, + ]); + expect(revalidatePath).toHaveBeenCalledWith("/admin/wordfilter"); + }); +}); diff --git a/src/actions/people-wrapper-errors.test.ts b/src/actions/people-wrapper-errors.test.ts new file mode 100644 index 00000000..301e55b3 --- /dev/null +++ b/src/actions/people-wrapper-errors.test.ts @@ -0,0 +1,87 @@ +import { revalidatePath } from "next/cache"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { execute, staff } = vi.hoisted(() => ({ + execute: vi.fn(), + staff: { id: 1, rank: 7, username: "admin" }, +})); + +vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({ + createLegacyPeopleMutationContext: vi.fn( + (actor, permission, correlationId) => ({ + actor, + permission, + correlationId, + }), + ), + peopleMutationService: { execute }, +})); +vi.mock("@/lib/admin/guard", () => ({ + requirePermission: vi.fn(async () => staff), + requirePermissionRateLimited: vi.fn(async () => staff), +})); +vi.mock("@/lib/permissions", () => ({ + PERMS: { + SETTINGS_EDIT: "admin.settings.edit", + USERS_EDIT: "admin.users.edit", + WORDFILTER_EDIT: "admin.wordfilter.edit", + }, +})); +vi.mock("@/lib/safe-action-shared", () => ({ + actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }), + actionError: (error: string) => ({ ok: false, error }), +})); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); + +import { disbandGuild } from "./admin-guilds"; +import { addWhitelist } from "./admin-ip"; +import { createTeam, deleteTeam } from "./admin-teams"; +import { deleteWord } from "./admin-wordfilter"; + +const form = (data: Record) => + ({ get: (key: string) => data[key] ?? null }) as FormData; +const failure = (code: string) => ({ + ok: false as const, + error: { code, messageKey: "errors.housekeeping.dependencyUnavailable" }, + correlationId: "wrapper-failure", +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("legacy wrapper failure compatibility", () => { + it("keeps guild persistence failures throwing while a missing guild remains a no-op", async () => { + execute.mockResolvedValueOnce(failure("DEPENDENCY_UNAVAILABLE")); + await expect(disbandGuild(form({ id: "9" }))).rejects.toThrow(); + expect(revalidatePath).not.toHaveBeenCalled(); + + execute.mockResolvedValueOnce(failure("NOT_FOUND")); + await expect(disbandGuild(form({ id: "9" }))).resolves.toBeUndefined(); + expect(revalidatePath).not.toHaveBeenCalled(); + }); + + it("keeps IP and team persistence failures throwing", async () => { + execute.mockResolvedValueOnce(failure("DEPENDENCY_UNAVAILABLE")); + await expect( + addWhitelist(form({ ipAddress: "192.0.2.1" })), + ).rejects.toThrow(); + + execute.mockResolvedValueOnce(failure("DEPENDENCY_UNAVAILABLE")); + await expect(createTeam(form({ rankName: "Moderator" }))).rejects.toThrow(); + expect(revalidatePath).not.toHaveBeenCalled(); + }); + + it("keeps already-gone team and word-filter deletes successful", async () => { + execute.mockResolvedValueOnce(failure("NOT_FOUND")); + await expect(deleteTeam(form({ id: "42" }))).resolves.toBeUndefined(); + + execute.mockResolvedValueOnce(failure("NOT_FOUND")); + await expect(deleteWord({ id: "42" })).resolves.toEqual({ + ok: true, + data: {}, + }); + expect(revalidatePath).toHaveBeenCalledWith("/admin/teams"); + expect(revalidatePath).toHaveBeenCalledWith("/admin/wordfilter"); + }); +}); diff --git a/src/actions/set-trade-lock.test.ts b/src/actions/set-trade-lock.test.ts index 6a4a2325..622a7619 100644 --- a/src/actions/set-trade-lock.test.ts +++ b/src/actions/set-trade-lock.test.ts @@ -2,30 +2,6 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files"; -describe("setTradeLock drizzle + RCON contract", () => { - const src = readFileSync("src/actions/bulk-users.ts", "utf8"); - const rconSrc = readFileSync("src/lib/services/rcon.ts", "utf8"); - - it("writes sanctions + users_settings via Drizzle", () => { - expect(src).toContain("@/lib/db"); - expect(src).toContain("UsersSettings"); - expect(src).toContain("Sanctions"); - expect(src).toContain("canTrade"); - expect(src).toContain("tradeLockedUntil"); - expect(src).toMatch(/export async function setTradeLock/); - const fn = src.slice(src.indexOf("export async function setTradeLock")); - expect(fn).toContain("db."); - }); - - it("syncs live hotel via RCON settradelock + alert + disconnect", () => { - expect(rconSrc).toContain("settradelock"); - expect(rconSrc).toContain("setTradeLock(userId: number, locked: boolean)"); - expect(src).toContain("rcon.setTradeLock"); - expect(src).toContain("rcon.alertUser"); - expect(src).toContain("rcon.disconnectUser"); - }); -}); - describe("admin-photos drizzle contract", () => { const src = readFileSync("src/actions/admin-photos.ts", "utf8"); @@ -33,7 +9,6 @@ describe("admin-photos drizzle contract", () => { expect(src).toContain("@/lib/db"); expect(src).toContain("CameraWeb"); expect(src).toContain("tryRemoveLocalPhotoFile"); - expect(src).toContain("@/lib/db"); expect(src).toContain('revalidatePath("/photos")'); }); }); diff --git a/src/actions/users.ts b/src/actions/users.ts index 746c6dca..91347531 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -1,18 +1,15 @@ "use server"; -import crypto from "node:crypto"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; -import { invalidateLoginCache } from "@/lib/auth"; -import { hashPassword } from "@/lib/auth/password"; import { - Ban, - db, - User, - UsersBadges, - UsersCurrency, - UsersSettings, -} from "@/lib/db"; + createLegacyPeopleMutationContext, + type PeopleMutationOperation, + peopleMutationService, +} from "@/features/housekeeping/domains/people/services/mutations"; +import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; +import { hashPassword } from "@/lib/auth/password"; +import { db, User, UsersBadges, UsersCurrency, UsersSettings } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { adminAction } from "@/lib/safe-action"; import { ActionError, actionOk } from "@/lib/safe-action-shared"; @@ -31,22 +28,23 @@ const DEFAULT_LOOK = function isDuplicateKey(err: unknown): boolean { if (!err || typeof err !== "object") return false; - const e = err as { code?: string | number; errno?: number }; - return e.code === "P2002" || e.code === "ER_DUP_ENTRY" || e.errno === 1062; + const error = err as { code?: string | number; errno?: number }; + return ( + error.code === "P2002" || + error.code === "ER_DUP_ENTRY" || + error.errno === 1062 + ); } function duplicateField(err: unknown): "username" | "mail" | null { if (!isDuplicateKey(err)) return null; - const e = err as { - message?: string; - meta?: { target?: string[] }; - }; - const target = e.meta?.target ?? []; + const error = err as { message?: string; meta?: { target?: string[] } }; + const target = error.meta?.target ?? []; if (target.includes("username")) return "username"; if (target.includes("mail")) return "mail"; - const msg = e.message ?? ""; - if (msg.includes("username")) return "username"; - if (msg.includes("mail")) return "mail"; + const message = error.message ?? ""; + if (message.includes("username")) return "username"; + if (message.includes("mail")) return "mail"; return null; } @@ -54,14 +52,11 @@ export const createUser = adminAction( { permission: PERMS.USERS_EDIT, schema: createUserSchema }, async (ctx) => { const { username, mail, password, rank, motto } = ctx.data; - if (rank >= ctx.session.user.rank && ctx.session.user.rank < 7) { throw new ActionError("Cannot assign rank equal or higher than your own"); } - const hashedPassword = await hashPassword(password); const now = Math.floor(Date.now() / 1000); - try { const user = await db.transaction(async (tx) => { const [result] = await tx.insert(User).values({ @@ -78,44 +73,73 @@ export const createUser = adminAction( ipCurrent: "0.0.0.0", }); const id = Number(result.insertId); - await tx.insert(UsersSettings).values({ userId: id }); await tx.insert(UsersCurrency).values([ { userId: id, type: 0, amount: 5000 }, { userId: id, type: 5, amount: 5000 }, ]); - return { id, username }; }); - - logAudit({ + void logAudit({ userId: ctx.session.user.id, action: "user_create", target: "User", targetId: user.id, after: { username, mail, rank }, }); - - notify({ + void notify({ action: "user_edit", actor: ctx.session.user.username, target: username, targetId: user.id, details: "Account created by admin", }); - - return actionOk({ id: user.id, username: user.username }); - } catch (err) { - const field = duplicateField(err); + return actionOk(user); + } catch (error) { + const field = duplicateField(error); if (field === "username") throw new ActionError("Username already taken"); if (field === "mail") throw new ActionError("Email already registered"); - if (isDuplicateKey(err)) + if (isDuplicateKey(error)) throw new ActionError("Username or email already in use"); - throw err; + throw error; } }, ); +const legacyMessages: Partial> = { + "user.alert": "Failed to send alert. Is the emulator running?", + "user.disconnect": "Failed to disconnect. Is the emulator running?", + "user.mute": "Failed to mute. Is the emulator running?", + "user.unmute": "Failed to unmute. Is the emulator running?", + "user.send-currency": "Failed to send credits. Is the emulator running?", +}; + +async function executeLegacy( + ctx: { session: { user: { id: number; username: string; rank: number } } }, + permission: string, + operation: PeopleMutationOperation, + input: unknown, +) { + const result = await peopleMutationService.execute( + createLegacyPeopleMutationContext( + ctx.session.user, + permission, + createCorrelationId(), + ), + operation, + input, + ); + if (!result.ok) { + if (result.error.code === "NOT_FOUND") + throw new ActionError("User not found"); + if (result.error.code === "FORBIDDEN") { + throw new ActionError("Cannot modify user with equal or higher rank"); + } + throw new ActionError(legacyMessages[operation] ?? "User action failed"); + } + return result.data; +} + const updateUserInput = updateUserSchema.extend({ id: z.coerce.number().int().positive(), }); @@ -123,149 +147,115 @@ const updateUserInput = updateUserSchema.extend({ export const updateUser = adminAction( { permission: PERMS.USERS_EDIT, schema: updateUserInput }, async (ctx) => { - const { id, diamonds, duckets, ...userData } = ctx.data; - - const targetUser = await guardRank(id, ctx.session.user.rank); - - if ( - userData.rank !== undefined && - userData.rank >= ctx.session.user.rank && - ctx.session.user.rank < 7 - ) { - throw new ActionError("Cannot assign rank equal or higher than your own"); - } - - const patch = Object.fromEntries( - Object.entries(userData).filter(([, v]) => v !== undefined), - ) as Partial<{ - username: string; - mail: string; - rank: number; - motto: string; - credits: number; - pixels: number; - }>; - if (Object.keys(patch).length > 0) { - await db.update(User).set(patch).where(eq(User.id, id)); - } - invalidateLoginCache(targetUser.username); - - if (diamonds !== undefined) { - await db - .insert(UsersCurrency) - .values({ userId: id, type: 5, amount: diamonds }) - .onDuplicateKeyUpdate({ set: { amount: diamonds } }); - } - if (duckets !== undefined) { - await db - .insert(UsersCurrency) - .values({ userId: id, type: 0, amount: duckets }) - .onDuplicateKeyUpdate({ set: { amount: duckets } }); - } - - logAudit({ - userId: ctx.session.user.id, - action: "user_edit", - target: "User", - targetId: id, - before: { - username: targetUser.username, - mail: targetUser.mail, - rank: targetUser.rank, - }, - after: userData, + const { id: userId, ...fields } = ctx.data; + await executeLegacy(ctx, PERMS.USERS_EDIT, "user.update", { + userId, + fields, }); - - notify({ - action: "user_edit", - actor: ctx.session.user.username, - target: targetUser.username, - targetId: id, - }); - return actionOk(); }, ); -const banInput = banUserSchema.extend({}); - export const banUser = adminAction( - { permission: PERMS.USERS_BAN, schema: banInput }, + { permission: PERMS.USERS_BAN, schema: banUserSchema }, async (ctx) => { - const { userId, reason, duration, type, ip } = ctx.data; - - const targetUser = await guardRank(userId, ctx.session.user.rank); - - const now = Math.floor(Date.now() / 1000); - const banExpire = duration > 0 ? now + duration * 3600 : 0; - - await db.insert(Ban).values({ - userId, - userStaffId: ctx.session.user.id, - timestamp: now, - banExpire, - banReason: reason, - type: type || "account", - ip: ip || "", - machineId: "", - }); - - await rcon.disconnectUser(userId); - - logAudit({ - userId: ctx.session.user.id, - action: "ban", - target: "User", - targetId: userId, - after: { reason, type, duration }, - }); - - notify({ - action: "ban", - actor: ctx.session.user.username, - target: targetUser.username, - details: reason, - }); - + await executeLegacy(ctx, PERMS.USERS_BAN, "user.ban", ctx.data); return actionOk(); }, ); -const unbanInput = z.object({ userId: z.coerce.number().int().positive() }); +const userIdSchema = z.object({ userId: z.coerce.number().int().positive() }); export const unbanUser = adminAction( - { permission: PERMS.USERS_BAN, schema: unbanInput }, + { permission: PERMS.USERS_BAN, schema: userIdSchema }, async (ctx) => { - const { userId } = ctx.data; - - const targetUser = await guardRank(userId, ctx.session.user.rank); - - await db.delete(Ban).where(eq(Ban.userId, userId)); - - logAudit({ - userId: ctx.session.user.id, - action: "unban", - target: "User", - targetId: userId, - }); - - notify({ - action: "unban", - actor: ctx.session.user.username, - target: targetUser.username, - }); - + await executeLegacy(ctx, PERMS.USERS_BAN, "user.unban", ctx.data); return actionOk(); }, ); +export const resetPassword = adminAction( + { permission: PERMS.USERS_RESET_PASSWORD, schema: userIdSchema }, + async (ctx) => { + const snapshot = await executeLegacy( + ctx, + PERMS.USERS_RESET_PASSWORD, + "user.reset-password", + ctx.data, + ); + return actionOk({ + newPassword: String(snapshot.output?.newPassword ?? ""), + }); + }, +); + +export const disconnectUser = adminAction( + { permission: PERMS.USERS_EDIT, schema: userIdSchema }, + async (ctx) => { + await executeLegacy(ctx, PERMS.USERS_EDIT, "user.disconnect", ctx.data); + return actionOk(); + }, +); + +const alertUserSchema = userIdSchema.extend({ + message: z.string().min(1).max(500), +}); +export const alertUser = adminAction( + { permission: PERMS.USERS_EDIT, schema: alertUserSchema }, + async (ctx) => { + await executeLegacy(ctx, PERMS.USERS_EDIT, "user.alert", ctx.data); + return actionOk(); + }, +); + +const muteSchema = userIdSchema.extend({ + duration: z.coerce.number().int().min(0).default(0), +}); +export const muteUser = adminAction( + { permission: PERMS.USERS_EDIT, schema: muteSchema }, + async (ctx) => { + await executeLegacy(ctx, PERMS.USERS_EDIT, "user.mute", ctx.data); + return actionOk(); + }, +); + +export const unmuteUser = adminAction( + { permission: PERMS.USERS_EDIT, schema: userIdSchema }, + async (ctx) => { + await executeLegacy(ctx, PERMS.USERS_EDIT, "user.unmute", ctx.data); + return actionOk(); + }, +); + +const sendCreditsSchema = userIdSchema.extend({ + amount: z.coerce.number().int().min(1).max(1_000_000), +}); +export const sendCredits = adminAction( + { permission: PERMS.USERS_EDIT, schema: sendCreditsSchema }, + async (ctx) => { + await executeLegacy(ctx, PERMS.USERS_EDIT, "user.send-currency", ctx.data); + return actionOk(); + }, +); + +async function guardRank(targetUserId: number, sessionRank: number) { + const [target] = await db + .select({ username: User.username, rank: User.rank, mail: User.mail }) + .from(User) + .where(eq(User.id, targetUserId)) + .limit(1); + if (!target) throw new ActionError("User not found"); + if (target.rank >= sessionRank && sessionRank < 7) { + throw new ActionError("Cannot modify user with equal or higher rank"); + } + return target; +} + export const giveBadge = adminAction( { permission: PERMS.USERS_EDIT, schema: giveBadgeSchema }, async (ctx) => { const { userId, badgeCode } = ctx.data; - await guardRank(userId, ctx.session.user.rank); - const [existing] = await db .select({ id: UsersBadges.id }) .from(UsersBadges) @@ -277,28 +267,21 @@ export const giveBadge = adminAction( ) .limit(1); if (existing) throw new ActionError("Badge already assigned"); - await db.insert(UsersBadges).values({ userId, badgeCode }); await rcon.giveBadge(userId, badgeCode); - return actionOk(); }, ); -// ── Remove Badge ──────────────────────────────────────────────────── - const removeBadgeSchema = z.object({ userId: z.coerce.number().int().positive(), badgeCode: z.string().min(1), }); - export const removeBadge = adminAction( { permission: PERMS.USERS_EDIT, schema: removeBadgeSchema }, async (ctx) => { const { userId, badgeCode } = ctx.data; - await guardRank(userId, ctx.session.user.rank); - const [existing] = await db .select({ id: UsersBadges.id }) .from(UsersBadges) @@ -310,199 +293,8 @@ export const removeBadge = adminAction( ) .limit(1); if (!existing) throw new ActionError("Badge not found"); - await db.delete(UsersBadges).where(eq(UsersBadges.id, existing.id)); await rcon.removeBadge(userId, badgeCode); - - return actionOk(); - }, -); - -// ── Rank guard helper ─────────────────────────────────────────────── - -async function guardRank(targetUserId: number, sessionRank: number) { - const [target] = await db - .select({ - username: User.username, - rank: User.rank, - mail: User.mail, - }) - .from(User) - .where(eq(User.id, targetUserId)) - .limit(1); - if (!target) throw new ActionError("User not found"); - if (target.rank >= sessionRank && sessionRank < 7) { - throw new ActionError("Cannot modify user with equal or higher rank"); - } - return target; -} - -// ── Reset Password ────────────────────────────────────────────────── - -const resetPasswordSchema = z.object({ - userId: z.coerce.number().int().positive(), -}); - -export const resetPassword = adminAction( - { permission: PERMS.USERS_RESET_PASSWORD, schema: resetPasswordSchema }, - async (ctx) => { - const target = await guardRank(ctx.data.userId, ctx.session.user.rank); - - const newPassword = crypto - .randomBytes(12) - .toString("base64url") - .slice(0, 16); - const hashed = await hashPassword(newPassword); - - await db - .update(User) - .set({ password: hashed }) - .where(eq(User.id, ctx.data.userId)); - invalidateLoginCache(target.username); - - logAudit({ - userId: ctx.session.user.id, - action: "reset_password", - target: "User", - targetId: ctx.data.userId, - }); - - notify({ - action: "user_edit", - actor: ctx.session.user.username, - target: target.username, - details: "Password reset", - }); - - return actionOk({ newPassword }); - }, -); - -// ── Disconnect User ───────────────────────────────────────────────── - -const disconnectSchema = z.object({ - userId: z.coerce.number().int().positive(), -}); - -export const disconnectUser = adminAction( - { permission: PERMS.USERS_EDIT, schema: disconnectSchema }, - async (ctx) => { - const target = await guardRank(ctx.data.userId, ctx.session.user.rank); - const success = await rcon.disconnectUser(ctx.data.userId); - if (!success) - throw new ActionError("Failed to disconnect. Is the emulator running?"); - - logAudit({ - userId: ctx.session.user.id, - action: "user_disconnect", - target: "User", - targetId: ctx.data.userId, - }); - - notify({ - action: "disconnect", - actor: ctx.session.user.username, - target: target.username, - }); - - return actionOk(); - }, -); - -// ── Alert User (in-game message) ──────────────────────────────────── - -const alertUserSchema = z.object({ - userId: z.coerce.number().int().positive(), - message: z.string().min(1).max(500), -}); - -export const alertUser = adminAction( - { permission: PERMS.USERS_EDIT, schema: alertUserSchema }, - async (ctx) => { - const success = await rcon.alertUser(ctx.data.userId, ctx.data.message); - if (!success) - throw new ActionError("Failed to send alert. Is the emulator running?"); - return actionOk(); - }, -); - -// ── Mute User ─────────────────────────────────────────────────────── - -const muteSchema = z.object({ - userId: z.coerce.number().int().positive(), - duration: z.coerce.number().int().min(0).default(0), -}); - -export const muteUser = adminAction( - { permission: PERMS.USERS_EDIT, schema: muteSchema }, - async (ctx) => { - const _target = await guardRank(ctx.data.userId, ctx.session.user.rank); - void _target; - const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration); - if (!success) - throw new ActionError("Failed to mute. Is the emulator running?"); - - logAudit({ - userId: ctx.session.user.id, - action: "user_mute", - target: "User", - targetId: ctx.data.userId, - after: { duration: ctx.data.duration }, - }); - - return actionOk(); - }, -); - -// ── Unmute User ───────────────────────────────────────────────────── - -const unmuteSchema = z.object({ - userId: z.coerce.number().int().positive(), -}); - -export const unmuteUser = adminAction( - { permission: PERMS.USERS_EDIT, schema: unmuteSchema }, - async (ctx) => { - await guardRank(ctx.data.userId, ctx.session.user.rank); - const success = await rcon.unmuteUser(ctx.data.userId); - if (!success) - throw new ActionError("Failed to unmute. Is the emulator running?"); - - logAudit({ - userId: ctx.session.user.id, - action: "user_unmute", - target: "User", - targetId: ctx.data.userId, - }); - - return actionOk(); - }, -); - -// ── Send Credits via RCON ─────────────────────────────────────────── - -const sendCreditsSchema = z.object({ - userId: z.coerce.number().int().positive(), - amount: z.coerce.number().int().min(1).max(1000000), -}); - -export const sendCredits = adminAction( - { permission: PERMS.USERS_EDIT, schema: sendCreditsSchema }, - async (ctx) => { - const _target = await guardRank(ctx.data.userId, ctx.session.user.rank); - void _target; - const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount); - if (!success) - throw new ActionError("Failed to send credits. Is the emulator running?"); - - logAudit({ - userId: ctx.session.user.id, - action: "user_send_credits", - target: "User", - targetId: ctx.data.userId, - after: { amount: ctx.data.amount }, - }); - return actionOk(); }, ); diff --git a/src/features/housekeeping/domains/people/commands/community-commands.test.ts b/src/features/housekeeping/domains/people/commands/community-commands.test.ts new file mode 100644 index 00000000..ece5d357 --- /dev/null +++ b/src/features/housekeeping/domains/people/commands/community-commands.test.ts @@ -0,0 +1,127 @@ +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"; + +vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() })); +vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() })); + +import { + COMMUNITY_COMMAND_IDS, + COMMUNITY_COMMANDS, + createCommunityCommands, +} from "./community-commands"; + +const expectedCommunityCommandIds = [ + "people.guild.disband", + "people.application.decide", + "people.team.change", + "people.ip.action", + "people.vpn.configure", + "people.word-filter.update", +] as const; + +const commands = COMMUNITY_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: 6 }, + isSuperAdmin: false, + has: (slug) => permissions.has(slug), + hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)), + hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)), + }; +} + +describe("People community and staff commands", () => { + it("declares the exact stable People-owned command IDs", () => { + expect(COMMUNITY_COMMAND_IDS).toEqual(expectedCommunityCommandIds); + expect(commands.map((command) => command.id)).toEqual( + expectedCommunityCommandIds, + ); + expect(commands.every((command) => command.owner === "people")).toBe(true); + }); + + it("preserves the exact legacy ACL boundary", () => { + expect( + Object.fromEntries( + commands.map((command) => [command.id, command.capability.slugs]), + ), + ).toEqual({ + "people.guild.disband": [PERMS.USERS_EDIT], + "people.application.decide": [PERMS.USERS_EDIT], + "people.team.change": [PERMS.USERS_EDIT], + "people.ip.action": [PERMS.SETTINGS_EDIT], + "people.vpn.configure": [PERMS.SETTINGS_EDIT], + "people.word-filter.update": [PERMS.WORDFILTER_EDIT], + }); + }); + + it("requires a nonblank reason for every destructive, global, or security mutation", () => { + expect(commands.every((command) => command.requiresReason)).toBe(true); + for (const command of commands) { + expect( + confirmHousekeepingCommand(command, "\t", "community-reason"), + ).toMatchObject({ + ok: false, + error: { code: "VALIDATION" }, + }); + } + }); + + it.each([ + ["people.guild.disband", { guildId: 0 }], + ["people.application.decide", { applicationId: 7, decision: "accept" }], + ["people.team.change", { action: "create", rankName: " " }], + ["people.ip.action", { action: "add-whitelist", ipAddress: " " }], + [ + "people.vpn.configure", + { enabled: true, provider: "unknown", apiKey: "", blockMessage: "" }, + ], + ["people.word-filter.update", { action: "add", word: " " }], + ] as const)( + "rejects an invalid bounded payload for %s", + (commandId, input) => { + const command = commands.find((entry) => entry.id === commandId); + if (!command) throw new Error(`command missing: ${commandId}`); + expect(command.input.safeParse(input).success).toBe(false); + }, + ); + + it("delegates the canonical operation without a redirect", async () => { + const execute = vi.fn(async (context, operation, input) => ({ + ok: true as const, + data: { before: { id: 9 }, after: null, operation, input }, + correlationId: context.correlationId, + })); + const created = createCommunityCommands({ + execute, + }) as unknown as readonly HousekeepingCommand[]; + const command = created.find( + (entry) => entry.id === "people.guild.disband", + ); + if (!command) throw new Error("command missing"); + const input = command.input.parse({ guildId: 9 }); + await command.execute( + { + capability: capabilityContext([PERMS.USERS_EDIT]), + correlationId: "guild-command", + ipAddress: "198.51.100.8", + }, + input, + ); + + expect(execute).toHaveBeenCalledWith( + expect.objectContaining({ correlationId: "guild-command" }), + "guild.disband", + { guildId: 9 }, + ); + }); +}); diff --git a/src/features/housekeeping/domains/people/commands/community-commands.ts b/src/features/housekeeping/domains/people/commands/community-commands.ts new file mode 100644 index 00000000..b082d045 --- /dev/null +++ b/src/features/housekeeping/domains/people/commands/community-commands.ts @@ -0,0 +1,134 @@ +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 PeopleMutationOperation, + type PeopleMutationService, + peopleMutationService, +} from "../services/mutations"; + +export const COMMUNITY_COMMAND_IDS = [ + "people.guild.disband", + "people.application.decide", + "people.team.change", + "people.ip.action", + "people.vpn.configure", + "people.word-filter.update", +] as const; + +export type CommunityCommandId = (typeof COMMUNITY_COMMAND_IDS)[number]; + +interface CommandOptions { + readonly id: CommunityCommandId; + readonly operation: PeopleMutationOperation; + readonly capability: string; + readonly input: z.ZodType; +} + +function communityCommand( + service: Pick, + options: CommandOptions, +): HousekeepingCommand { + return { + id: options.id, + owner: "people", + risk: "sensitive", + capability: anyCapability(options.capability), + input: options.input, + requiresReason: true, + rateLimit: { attempts: 5, windowMs: 60_000 }, + execute: (context, input) => + service.execute( + { + capability: context.capability, + correlationId: context.correlationId, + }, + options.operation, + input, + ), + }; +} + +const positiveId = z.number().int().positive(); +const requiredText = (max: number) => z.string().min(1).max(max).regex(/\S/u); + +export function createCommunityCommands( + service: Pick, +) { + return [ + communityCommand(service, { + id: "people.guild.disband", + operation: "guild.disband", + capability: PERMS.USERS_EDIT, + input: z.object({ guildId: positiveId }), + }), + communityCommand(service, { + id: "people.application.decide", + operation: "application.decide", + capability: PERMS.USERS_EDIT, + input: z.object({ + applicationId: positiveId, + decision: z.literal("dismiss"), + }), + }), + communityCommand(service, { + id: "people.team.change", + operation: "team.change", + capability: PERMS.USERS_EDIT, + input: z.discriminatedUnion("action", [ + z.object({ + action: z.literal("create"), + rankName: requiredText(255), + badge: z.string().max(255).optional(), + jobDescription: z.string().max(255).optional(), + staffColor: z.string().min(1).max(255).optional(), + hiddenRank: z.boolean().optional(), + }), + z.object({ action: z.literal("delete"), teamId: positiveId }), + ]), + }), + communityCommand(service, { + id: "people.ip.action", + operation: "ip.action", + capability: PERMS.SETTINGS_EDIT, + input: z.discriminatedUnion("action", [ + z.object({ + action: z.enum(["add-whitelist", "add-blacklist"]), + ipAddress: requiredText(255), + asn: z.string().max(255).optional(), + }), + z.object({ + action: z.enum(["delete-whitelist", "delete-blacklist"]), + id: positiveId, + }), + ]), + }), + communityCommand(service, { + id: "people.vpn.configure", + operation: "vpn.configure", + capability: PERMS.SETTINGS_EDIT, + input: z.object({ + enabled: z.boolean(), + provider: z.enum(["none", "proxycheck", "ipqualityscore"]), + apiKey: z.string().max(255), + blockMessage: z.string().max(255), + }), + }), + communityCommand(service, { + id: "people.word-filter.update", + operation: "word-filter.update", + capability: PERMS.WORDFILTER_EDIT, + input: z.discriminatedUnion("action", [ + z.object({ action: z.literal("add"), word: requiredText(255) }), + z.object({ action: z.literal("delete"), id: positiveId }), + ]), + }), + ] as const; +} + +export const COMMUNITY_COMMANDS = createCommunityCommands( + peopleMutationService, +); diff --git a/src/features/housekeeping/domains/people/commands/user-commands.test.ts b/src/features/housekeeping/domains/people/commands/user-commands.test.ts new file mode 100644 index 00000000..67059d0b --- /dev/null +++ b/src/features/housekeeping/domains/people/commands/user-commands.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it, vi } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +import { confirmHousekeepingCommand } from "../../../foundation/commands/confirmation"; +import type { HousekeepingCommand } from "../../../foundation/commands/registry"; +import type { HousekeepingCapabilityContext } from "../../../foundation/contracts"; + +vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() })); +vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() })); + +import { + createPeopleMutationService, + type PeopleMutationAdapter, +} from "../services/mutations"; +import { + createUserCommands, + USER_COMMAND_IDS, + USER_COMMANDS, +} from "./user-commands"; + +const expectedUserCommandIds = [ + "people.user.update", + "people.user.ban", + "people.user.unban", + "people.user.alert", + "people.user.disconnect", + "people.user.mute", + "people.user.unmute", + "people.user.reset-password", + "people.user.send-currency", + "people.user.trade-lock", + "people.users.bulk-ban", + "people.users.bulk-unban", + "people.users.bulk-currency", + "people.users.bulk-badge", +] as const; + +const commands = USER_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: 6 }, + isSuperAdmin: false, + has: (slug) => permissions.has(slug), + hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)), + hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)), + }; +} + +describe("People user commands", () => { + it("declares the exact deterministic user command list", () => { + expect(USER_COMMAND_IDS).toEqual(expectedUserCommandIds); + expect(commands.map((command) => command.id)).toEqual( + expectedUserCommandIds, + ); + expect(commands.every((command) => command.owner === "people")).toBe(true); + expect(commands.every((command) => command.risk === "sensitive")).toBe( + true, + ); + }); + + it("uses the exact legacy ACL for every user operation", () => { + const capabilities = Object.fromEntries( + commands.map((command) => [command.id, command.capability.slugs]), + ); + expect(capabilities).toEqual({ + "people.user.update": [PERMS.USERS_EDIT], + "people.user.ban": [PERMS.USERS_BAN], + "people.user.unban": [PERMS.USERS_BAN], + "people.user.alert": [PERMS.USERS_EDIT], + "people.user.disconnect": [PERMS.USERS_EDIT], + "people.user.mute": [PERMS.USERS_EDIT], + "people.user.unmute": [PERMS.USERS_EDIT], + "people.user.reset-password": [PERMS.USERS_RESET_PASSWORD], + "people.user.send-currency": [PERMS.USERS_EDIT], + "people.user.trade-lock": [PERMS.USERS_EDIT], + "people.users.bulk-ban": [PERMS.USERS_EDIT], + "people.users.bulk-unban": [PERMS.USERS_EDIT], + "people.users.bulk-currency": [PERMS.USERS_EDIT], + "people.users.bulk-badge": [PERMS.USERS_EDIT], + }); + }); + + it("requires reasons for sanctions, security changes, currency, and bulk mutations", () => { + const withoutReason = commands + .filter((command) => !command.requiresReason) + .map((command) => command.id); + expect(withoutReason).toEqual(["people.user.update", "people.user.alert"]); + + for (const command of commands.filter((entry) => entry.requiresReason)) { + expect( + confirmHousekeepingCommand(command, " ", "people-reason"), + ).toMatchObject({ + ok: false, + error: { + code: "VALIDATION", + fieldErrors: { reason: ["errors.validation.required"] }, + }, + }); + } + }); + + it.each([ + ["people.user.update", { userId: 7, fields: {} }], + ["people.user.alert", { userId: 7, message: " " }], + ["people.user.mute", { userId: 7, duration: 87_601 }], + ["people.user.send-currency", { userId: 7, amount: 1_000_001 }], + ["people.users.bulk-ban", { userIds: [], reason: "reason", duration: 0 }], + [ + "people.users.bulk-badge", + { + userIds: Array.from({ length: 101 }, (_, index) => index + 1), + badgeCode: "ADM", + }, + ], + ] as const)( + "rejects an invalid bounded payload for %s", + (commandId, input) => { + const command = commands.find((entry) => entry.id === commandId); + if (!command) throw new Error(`command missing: ${commandId}`); + expect(command.input.safeParse(input).success).toBe(false); + }, + ); + + it("delegates parsed input and correlation to the redirect-free service", async () => { + const execute = vi.fn(async (context, operation, input) => ({ + ok: true as const, + data: { before: null, after: { operation, input } }, + correlationId: context.correlationId, + })); + const created = createUserCommands({ + execute, + }) as unknown as readonly HousekeepingCommand[]; + const command = created.find( + (entry) => entry.id === "people.user.send-currency", + ); + if (!command) throw new Error("command missing"); + const input = command.input.parse({ userId: 7, amount: 25 }); + const result = await command.execute( + { + capability: capabilityContext([PERMS.USERS_EDIT]), + correlationId: "people-command", + ipAddress: "198.51.100.8", + }, + input, + ); + + expect(execute).toHaveBeenCalledWith( + expect.objectContaining({ correlationId: "people-command" }), + "user.send-currency", + { userId: 7, amount: 25 }, + ); + expect(result).toMatchObject({ + ok: true, + data: { before: null, after: { operation: "user.send-currency" } }, + }); + }); +}); + +describe("People mutation service boundary", () => { + it("fails closed before the adapter when the exact capability is absent", async () => { + const execute = vi.fn(async () => ({ before: null, after: null })); + const service = createPeopleMutationService({ execute }); + const result = await service.execute( + { + capability: capabilityContext([PERMS.USERS_EDIT]), + correlationId: "denied-people", + }, + "user.ban", + { userId: 7, reason: "abuse", duration: 0, type: "account" }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { code: "FORBIDDEN" }, + correlationId: "denied-people", + }); + expect(execute).not.toHaveBeenCalled(); + }); + + it("returns adapter before and after snapshots and sanitizes failures", async () => { + const snapshotAdapter: PeopleMutationAdapter = { + execute: async () => ({ + before: { rank: 2 }, + after: { rank: 3 }, + }), + }; + const success = await createPeopleMutationService(snapshotAdapter).execute( + { + capability: capabilityContext([PERMS.USERS_EDIT]), + correlationId: "snapshot-people", + }, + "user.update", + { userId: 7, fields: { rank: 3 } }, + ); + expect(success).toMatchObject({ + ok: true, + data: { before: { rank: 2 }, after: { rank: 3 } }, + }); + + const failureAdapter: PeopleMutationAdapter = { + execute: async () => { + throw new Error("database password=secret"); + }, + }; + const failure = await createPeopleMutationService(failureAdapter).execute( + { + capability: capabilityContext([PERMS.USERS_EDIT]), + correlationId: "failed-people", + }, + "user.update", + { userId: 7, fields: { motto: "Ready" } }, + ); + expect(failure).toMatchObject({ + ok: false, + error: { + code: "DEPENDENCY_UNAVAILABLE", + messageKey: "errors.housekeeping.dependencyUnavailable", + }, + }); + expect(JSON.stringify(failure)).not.toContain("password=secret"); + }); +}); diff --git a/src/features/housekeeping/domains/people/commands/user-commands.ts b/src/features/housekeeping/domains/people/commands/user-commands.ts new file mode 100644 index 00000000..88e95d60 --- /dev/null +++ b/src/features/housekeeping/domains/people/commands/user-commands.ts @@ -0,0 +1,241 @@ +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 PeopleMutationOperation, + type PeopleMutationService, + peopleMutationService, +} from "../services/mutations"; + +export const USER_COMMAND_IDS = [ + "people.user.update", + "people.user.ban", + "people.user.unban", + "people.user.alert", + "people.user.disconnect", + "people.user.mute", + "people.user.unmute", + "people.user.reset-password", + "people.user.send-currency", + "people.user.trade-lock", + "people.users.bulk-ban", + "people.users.bulk-unban", + "people.users.bulk-currency", + "people.users.bulk-badge", +] as const; + +export type UserCommandId = (typeof USER_COMMAND_IDS)[number]; + +interface UserCommandOptions { + readonly id: UserCommandId; + readonly operation: PeopleMutationOperation; + readonly capability: string; + readonly input: z.ZodType; + readonly requiresReason: boolean; + readonly attempts?: number; +} + +function userCommand( + service: Pick, + options: UserCommandOptions, +): HousekeepingCommand { + return { + id: options.id, + owner: "people", + 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 positiveId = z.number().int().positive(); +const requiredText = (max: number) => z.string().min(1).max(max).regex(/\S/u); +const userIds = z.array(positiveId).min(1).max(100); +const optionalUpdateFields = { + username: z.string().min(3).max(20).optional(), + mail: z.email().optional(), + rank: z.number().int().min(1).max(7).optional(), + motto: z.string().max(127).optional(), + credits: z.number().int().min(0).max(2_147_483_647).optional(), + pixels: z.number().int().min(0).max(2_147_483_647).optional(), + diamonds: z.number().int().min(0).max(2_147_483_647).optional(), + duckets: z.number().int().min(0).max(2_147_483_647).optional(), +}; +const updateFields = z.union([ + z.object({ ...optionalUpdateFields, username: z.string().min(3).max(20) }), + z.object({ ...optionalUpdateFields, mail: z.email() }), + z.object({ ...optionalUpdateFields, rank: z.number().int().min(1).max(7) }), + z.object({ ...optionalUpdateFields, motto: z.string().max(127) }), + z.object({ + ...optionalUpdateFields, + credits: z.number().int().min(0).max(2_147_483_647), + }), + z.object({ + ...optionalUpdateFields, + pixels: z.number().int().min(0).max(2_147_483_647), + }), + z.object({ + ...optionalUpdateFields, + diamonds: z.number().int().min(0).max(2_147_483_647), + }), + z.object({ + ...optionalUpdateFields, + duckets: z.number().int().min(0).max(2_147_483_647), + }), +]); + +export function createUserCommands( + service: Pick, +) { + return [ + userCommand(service, { + id: "people.user.update", + operation: "user.update", + capability: PERMS.USERS_EDIT, + input: z.object({ userId: positiveId, fields: updateFields }), + requiresReason: false, + }), + userCommand(service, { + id: "people.user.ban", + operation: "user.ban", + capability: PERMS.USERS_BAN, + input: z.object({ + userId: positiveId, + reason: requiredText(500), + duration: z.number().int().min(0).max(87_600), + type: z.enum(["account", "ip", "machine"]), + ip: z.string().max(255).optional(), + }), + requiresReason: true, + attempts: 5, + }), + userCommand(service, { + id: "people.user.unban", + operation: "user.unban", + capability: PERMS.USERS_BAN, + input: z.object({ userId: positiveId }), + requiresReason: true, + attempts: 5, + }), + userCommand(service, { + id: "people.user.alert", + operation: "user.alert", + capability: PERMS.USERS_EDIT, + input: z.object({ userId: positiveId, message: requiredText(500) }), + requiresReason: false, + }), + userCommand(service, { + id: "people.user.disconnect", + operation: "user.disconnect", + capability: PERMS.USERS_EDIT, + input: z.object({ userId: positiveId }), + requiresReason: true, + attempts: 5, + }), + userCommand(service, { + id: "people.user.mute", + operation: "user.mute", + capability: PERMS.USERS_EDIT, + input: z.object({ + userId: positiveId, + duration: z.number().int().min(0).max(87_600), + }), + requiresReason: true, + attempts: 5, + }), + userCommand(service, { + id: "people.user.unmute", + operation: "user.unmute", + capability: PERMS.USERS_EDIT, + input: z.object({ userId: positiveId }), + requiresReason: true, + attempts: 5, + }), + userCommand(service, { + id: "people.user.reset-password", + operation: "user.reset-password", + capability: PERMS.USERS_RESET_PASSWORD, + input: z.object({ userId: positiveId }), + requiresReason: true, + attempts: 3, + }), + userCommand(service, { + id: "people.user.send-currency", + operation: "user.send-currency", + capability: PERMS.USERS_EDIT, + input: z.object({ + userId: positiveId, + amount: z.number().int().min(1).max(1_000_000), + }), + requiresReason: true, + attempts: 5, + }), + userCommand(service, { + id: "people.user.trade-lock", + operation: "user.trade-lock", + capability: PERMS.USERS_EDIT, + input: z.object({ + userId: positiveId, + untilUnix: z.number().int().min(0).max(2_147_483_647), + }), + requiresReason: true, + attempts: 5, + }), + userCommand(service, { + id: "people.users.bulk-ban", + operation: "users.bulk-ban", + capability: PERMS.USERS_EDIT, + input: z.object({ + userIds, + reason: requiredText(500), + duration: z.number().int().min(0).max(315_360_000), + }), + requiresReason: true, + attempts: 3, + }), + userCommand(service, { + id: "people.users.bulk-unban", + operation: "users.bulk-unban", + capability: PERMS.USERS_EDIT, + input: z.object({ userIds }), + requiresReason: true, + attempts: 3, + }), + userCommand(service, { + id: "people.users.bulk-currency", + operation: "users.bulk-currency", + capability: PERMS.USERS_EDIT, + input: z.object({ + userIds, + amount: z.number().int().min(1).max(1_000_000), + type: z.enum(["credits", "pixels", "points"]), + }), + requiresReason: true, + attempts: 3, + }), + userCommand(service, { + id: "people.users.bulk-badge", + operation: "users.bulk-badge", + capability: PERMS.USERS_EDIT, + input: z.object({ userIds, badgeCode: requiredText(20) }), + requiresReason: true, + attempts: 3, + }), + ] as const; +} + +export const USER_COMMANDS = createUserCommands(peopleMutationService); diff --git a/src/features/housekeeping/domains/people/manifest.ts b/src/features/housekeeping/domains/people/manifest.ts index 4fa1c1fa..ca4bac04 100644 --- a/src/features/housekeeping/domains/people/manifest.ts +++ b/src/features/housekeeping/domains/people/manifest.ts @@ -3,6 +3,7 @@ import { anyCapability, type HousekeepingDomainManifest, } from "../../foundation/contracts"; +import { PEOPLE_PRIMARY_ROUTES } from "./routes"; export const peopleManifest = { id: "people", @@ -34,7 +35,7 @@ export const peopleManifest = { PERMS.MOD_CFH_EDIT, PERMS.MOD_TICKETS_EDIT, ), - routes: [], + routes: PEOPLE_PRIMARY_ROUTES, searchProviders: [], inboxSources: [], widgets: [], diff --git a/src/features/housekeeping/domains/people/pages/community.tsx b/src/features/housekeeping/domains/people/pages/community.tsx new file mode 100644 index 00000000..fcb1eee9 --- /dev/null +++ b/src/features/housekeeping/domains/people/pages/community.tsx @@ -0,0 +1,113 @@ +import { PERMS } from "@/lib/permission-slugs"; +import { + createCorrelationId, + fail, + type HousekeepingCapabilityContext, + type HousekeepingResult, +} from "../../../foundation/contracts"; +import type { HousekeepingPageInput } from "../../../route-handlers"; +import { + type PeopleCommunityQueryData, + peopleCommunityQuery, +} from "../queries/community"; +import { PeoplePageFrame } from "./page-state"; + +interface PeopleCommunityPageProps { + readonly context: HousekeepingCapabilityContext; + readonly result?: HousekeepingResult; + readonly partialDependencies?: readonly string[]; +} + +function empty(data: PeopleCommunityQueryData) { + return data.kind !== "guild" && data.page.items.length === 0; +} + +export function PeopleCommunityPage({ + context, + result, + partialDependencies, +}: PeopleCommunityPageProps) { + return ( + + {(data) => { + if (data.kind === "online") + return ( +
    + {data.page.items.map((user) => ( +
  • + {user.username} - {user.motto} +
  • + ))} +
+ ); + if (data.kind === "guilds") + return ( +
    + {data.page.items.map((guild) => ( +
  • + {guild.name} +

    {guild.memberCount} members

    +
  • + ))} +
+ ); + return ( +
+

{data.guild.name}

+

{data.guild.description}

+ + {context.has(PERMS.USERS_EDIT) ? ( + + Disband guild + + ) : null} +
+ ); + }} +
+ ); +} + +export async function renderPeopleCommunityPage(input: HousekeepingPageInput) { + const routeId = input.match.routeId; + const result = + routeId === "people.community.guild-detail" + ? (() => { + const id = Number(input.match.params.id); + return Number.isSafeInteger(id) && id > 0 + ? peopleCommunityQuery.run(input.context, { routeId, id }) + : Promise.resolve( + fail( + "VALIDATION", + "errors.housekeeping.validation", + createCorrelationId(), + ), + ); + })() + : routeId === "people.community.online" || + routeId === "people.community.guilds" + ? peopleCommunityQuery.run(input.context, { routeId, list: {} }) + : Promise.resolve( + fail( + "NOT_FOUND", + "errors.housekeeping.notFound", + createCorrelationId(), + ), + ); + return ; +} diff --git a/src/features/housekeeping/domains/people/pages/multi-accounts.tsx b/src/features/housekeeping/domains/people/pages/multi-accounts.tsx new file mode 100644 index 00000000..4f783807 --- /dev/null +++ b/src/features/housekeeping/domains/people/pages/multi-accounts.tsx @@ -0,0 +1,68 @@ +import type { + HousekeepingCapabilityContext, + HousekeepingResult, +} from "../../../foundation/contracts"; +import type { HousekeepingPageInput } from "../../../route-handlers"; +import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users"; +import { PeoplePageFrame } from "./page-state"; + +interface PeopleMultiAccountsPageProps { + readonly context: HousekeepingCapabilityContext; + readonly result?: HousekeepingResult; + readonly partialDependencies?: readonly string[]; +} + +export function PeopleMultiAccountsPage({ + context: _context, + result, + partialDependencies, +}: PeopleMultiAccountsPageProps) { + return ( + + data.kind === "multi-accounts" && data.page.items.length === 0 + } + > + {(data) => + data.kind === "multi-accounts" ? ( +
    + {data.page.items.map((cluster) => ( +
  • +

    + {cluster.key} +

    +

    + {cluster.accountCount} accounts +

    + +
  • + ))} +
+ ) : null + } +
+ ); +} + +export async function renderPeopleMultiAccountsPage( + input: HousekeepingPageInput, +) { + const result = await peopleUsersQuery.run(input.context, { + routeId: "people.users.multi-accounts", + list: {}, + }); + return ; +} diff --git a/src/features/housekeeping/domains/people/pages/page-state.tsx b/src/features/housekeeping/domains/people/pages/page-state.tsx new file mode 100644 index 00000000..a33575b5 --- /dev/null +++ b/src/features/housekeeping/domains/people/pages/page-state.tsx @@ -0,0 +1,103 @@ +import type { ReactNode } from "react"; +import type { HousekeepingResult } from "../../../foundation/contracts"; +import { HousekeepingPageShell } from "../../../foundation/page/housekeeping-page-shell"; +import { HousekeepingPageState } from "../../../foundation/page/housekeeping-page-state"; + +interface PeoplePageFrameProps { + readonly title: string; + readonly description: string; + readonly result?: HousekeepingResult; + readonly partialDependencies?: readonly string[]; + readonly isEmpty: (data: T) => boolean; + readonly children: (data: T) => ReactNode; +} + +function state( + kind: "loading" | "empty" | "error", + title: string, + description: string, +) { + return ( +
+ +
+ ); +} + +export function PeoplePageFrame({ + title, + description, + result, + partialDependencies = [], + isEmpty, + children, +}: PeoplePageFrameProps) { + let body: ReactNode; + if (!result) { + body = state("loading", `Loading ${title.toLowerCase()}`, description); + } else if (!result.ok) { + if (result.error.code === "FORBIDDEN") { + body = ( +
+

+ Access denied +

+

+ Your account cannot use this People workflow. +

+
+ ); + } else if (result.error.code === "NOT_FOUND") { + body = state( + "empty", + "Record not found", + "The requested record is unavailable.", + ); + } else { + body = state( + "error", + `${title} unavailable`, + `Request ${result.correlationId} could not be completed.`, + ); + } + } else if (isEmpty(result.data)) { + body = state( + "empty", + `No ${title.toLowerCase()}`, + "No matching records were returned.", + ); + } else { + body = ( +
0 ? "partial" : "ready" + } + className="space-y-4" + > + {partialDependencies.length > 0 ? ( + + ) : null} + {children(result.data)} +
+ ); + } + + return ( + + {body} + + ); +} diff --git a/src/features/housekeeping/domains/people/pages/people-primary-pages.test.tsx b/src/features/housekeeping/domains/people/pages/people-primary-pages.test.tsx new file mode 100644 index 00000000..2e4c8f81 --- /dev/null +++ b/src/features/housekeeping/domains/people/pages/people-primary-pages.test.tsx @@ -0,0 +1,316 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +import { + fail, + type HousekeepingCapabilityContext, + type HousekeepingResult, + ok, +} from "../../../foundation/contracts"; +import { HOUSEKEEPING_MANIFESTS } from "../../../manifests"; +import { HOUSEKEEPING_ROUTE_HANDLERS } from "../../../route-handlers"; +import { peopleManifest } from "../manifest"; +import type { PeopleCommunityQueryData } from "../queries/community"; +import type { PeopleStaffQueryData } from "../queries/staff"; +import type { PeopleUsersQueryData } from "../queries/users"; +import { + PEOPLE_PRIMARY_ROUTE_HANDLERS, + PEOPLE_PRIMARY_ROUTE_IDS, +} from "../route-handlers"; +import { PeopleCommunityPage } from "./community"; +import { PeopleMultiAccountsPage } from "./multi-accounts"; +import { PeopleStaffPage } from "./staff"; +import { PeopleUserDetailPage } from "./user-detail"; +import { PeopleUserEditPage } from "./user-edit"; +import { PeopleUsersPage } from "./users"; + +const correlationId = "people-pages-test"; + +function capabilityContext( + granted: readonly string[], +): HousekeepingCapabilityContext { + const permissions = new Set(granted); + return { + actor: { id: 42, username: "operator", rank: 6 }, + isSuperAdmin: false, + has: (slug) => permissions.has(slug), + hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)), + hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)), + }; +} + +const readContext = capabilityContext([PERMS.USERS_VIEW]); +const modReadContext = capabilityContext([PERMS.MOD_USERS_VIEW]); + +const user = { + id: 7, + username: "Alice", + rank: 2, + online: true, + mail: "alice@example.test", + ipCurrent: "198.51.100.7", + bannedUntil: null, + href: "/ase/people/users/7" as const, +}; + +const detail = { + ...user, + motto: "Ready", + look: "hd-180-1", + accountCreated: "2026-08-01T00:00:00.000Z", + lastLogin: "2026-08-28T00:00:00.000Z", + sanctions: [], + watched: false, + permission: { rankName: "Member", ranks: [{ id: 2, name: "Member" }] }, +}; + +type PageCase = { + readonly name: string; + readonly render: ( + result?: HousekeepingResult, + partialDependencies?: readonly string[], + ) => string; + readonly empty: unknown; + readonly ready: unknown; +}; + +const cases: readonly PageCase[] = [ + { + name: "users", + render: (result, partialDependencies) => + renderToStaticMarkup( + } + partialDependencies={partialDependencies} + />, + ), + empty: { + kind: "users", + page: { items: [], total: 0, pageSize: 20, offset: 0 }, + }, + ready: { + kind: "users", + page: { items: [user], total: 1, pageSize: 20, offset: 0 }, + }, + }, + { + name: "user-detail", + render: (result, partialDependencies) => + renderToStaticMarkup( + } + partialDependencies={partialDependencies} + />, + ), + empty: { kind: "user", user: { ...detail, sanctions: [] } }, + ready: { + kind: "user", + user: { + ...detail, + sanctions: [ + { + id: 3, + kind: "account", + reason: "spam", + createdAt: null, + expiresAt: 0, + active: true, + }, + ], + }, + }, + }, + { + name: "user-edit", + render: (result, partialDependencies) => + renderToStaticMarkup( + } + partialDependencies={partialDependencies} + />, + ), + empty: fail("NOT_FOUND", "errors.housekeeping.notFound", correlationId), + ready: { kind: "user", user: { ...detail, watched: true } }, + }, + { + name: "multi-accounts", + render: (result, partialDependencies) => + renderToStaticMarkup( + } + partialDependencies={partialDependencies} + />, + ), + empty: { + kind: "multi-accounts", + page: { items: [], total: 0, pageSize: 20, offset: 0 }, + }, + ready: { + kind: "multi-accounts", + page: { + items: [{ key: "198.51.100.7", accountCount: 2, accounts: [user] }], + total: 1, + pageSize: 20, + offset: 0, + }, + }, + }, + { + name: "community", + render: (result, partialDependencies) => + renderToStaticMarkup( + } + partialDependencies={partialDependencies} + />, + ), + empty: { + kind: "guilds", + page: { items: [], total: 0, pageSize: 20, offset: 0 }, + }, + ready: { + kind: "guilds", + page: { + items: [ + { + id: 9, + name: "Builders", + description: "Rooms", + userId: 7, + ownerUsername: "Alice", + roomId: 4, + memberCount: 2, + createdAt: null, + href: "/ase/people/community/guilds/9", + }, + ], + total: 1, + pageSize: 20, + offset: 0, + }, + }, + }, + { + name: "staff", + render: (result, partialDependencies) => + renderToStaticMarkup( + } + partialDependencies={partialDependencies} + />, + ), + empty: { + kind: "applications", + page: { items: [], total: 0, pageSize: 20, offset: 0 }, + }, + ready: { + kind: "applications", + page: { + items: [ + { + id: 5, + userId: 7, + username: "Alice", + rankId: 4, + content: "Experienced", + createdAt: null, + }, + ], + total: 1, + pageSize: 20, + offset: 0, + }, + }, + }, +]; + +describe.each(cases)("People $name page", ({ render, empty, ready }) => { + it("renders loading, forbidden, dependency error, empty, partial, and ready states", () => { + 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"'); + const emptyResult = + typeof empty === "object" && empty !== null && "ok" in empty + ? (empty as HousekeepingResult) + : ok(empty, correlationId); + expect(render(emptyResult)).toContain('data-housekeeping-state="empty"'); + expect(render(ok(ready, correlationId), ["secondary"])).toContain( + 'data-housekeeping-state="partial"', + ); + expect(render(ok(ready, correlationId))).toContain( + 'data-housekeeping-state="ready"', + ); + }); +}); + +describe("People primary route registration", () => { + it("registers exactly the nine real primary routes and handlers", () => { + const expected = [ + "people.users.list", + "people.users.edit", + "people.users.multi-accounts", + "people.users.detail", + "people.community.online", + "people.community.guilds", + "people.community.guild-detail", + "people.staff.applications", + "people.staff.teams", + ]; + expect(PEOPLE_PRIMARY_ROUTE_IDS).toEqual(expected); + expect(peopleManifest.routes.map((route) => route.id)).toEqual(expected); + expect( + PEOPLE_PRIMARY_ROUTE_HANDLERS.map((handler) => handler.routeId), + ).toEqual(expected); + expect( + HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId), + ).toEqual(expect.arrayContaining(expected)); + expect( + HOUSEKEEPING_MANIFESTS.flatMap((manifest) => manifest.routes).map( + (route) => route.id, + ), + ).toEqual(HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId)); + }); + + it("uses canonical People links and exposes PII and actions only with exact capabilities", () => { + const redacted = { + ...detail, + mail: null, + ipCurrent: null, + }; + const modHtml = renderToStaticMarkup( + , + ); + expect(modHtml).not.toContain("alice@example.test"); + expect(modHtml).not.toContain("198.51.100.7"); + expect(modHtml).not.toContain("people.user.update"); + + const editorHtml = renderToStaticMarkup( + , + ); + expect(editorHtml).toContain('href="/ase/people/users/7/edit"'); + expect(editorHtml).toContain("people.user.update"); + expect(editorHtml).not.toContain("/admin"); + }); +}); diff --git a/src/features/housekeeping/domains/people/pages/staff.tsx b/src/features/housekeeping/domains/people/pages/staff.tsx new file mode 100644 index 00000000..fe0637ce --- /dev/null +++ b/src/features/housekeeping/domains/people/pages/staff.tsx @@ -0,0 +1,83 @@ +import { PERMS } from "@/lib/permission-slugs"; +import { + createCorrelationId, + fail, + type HousekeepingCapabilityContext, + type HousekeepingResult, +} from "../../../foundation/contracts"; +import type { HousekeepingPageInput } from "../../../route-handlers"; +import { type PeopleStaffQueryData, peopleStaffQuery } from "../queries/staff"; +import { PeoplePageFrame } from "./page-state"; + +interface PeopleStaffPageProps { + readonly context: HousekeepingCapabilityContext; + readonly result?: HousekeepingResult; + readonly partialDependencies?: readonly string[]; +} + +export function PeopleStaffPage({ + context, + result, + partialDependencies, +}: PeopleStaffPageProps) { + return ( + data.page.items.length === 0} + > + {(data) => + data.kind === "applications" ? ( +
    + {data.page.items.map((application) => ( +
  • +

    {application.username ?? `User #${application.userId}`}

    +

    {application.content}

    + {context.has(PERMS.USERS_EDIT) ? ( + + Dismiss application + + ) : null} +
  • + ))} +
+ ) : data.kind === "teams" ? ( +
    + {data.page.items.map((team) => ( +
  • +

    {team.name}

    +

    {team.jobDescription ?? "No job description"}

    + {context.has(PERMS.USERS_EDIT) ? ( + + Team controls + + ) : null} +
  • + ))} +
+ ) : null + } +
+ ); +} + +export async function renderPeopleStaffPage(input: HousekeepingPageInput) { + const routeId = input.match.routeId; + const result = + routeId === "people.staff.applications" || routeId === "people.staff.teams" + ? await peopleStaffQuery.run(input.context, { routeId, list: {} }) + : fail( + "NOT_FOUND", + "errors.housekeeping.notFound", + createCorrelationId(), + ); + return ; +} diff --git a/src/features/housekeeping/domains/people/pages/user-detail.tsx b/src/features/housekeeping/domains/people/pages/user-detail.tsx new file mode 100644 index 00000000..53ac9248 --- /dev/null +++ b/src/features/housekeeping/domains/people/pages/user-detail.tsx @@ -0,0 +1,126 @@ +import { PERMS } from "@/lib/permission-slugs"; +import { + createCorrelationId, + fail, + type HousekeepingCapabilityContext, + type HousekeepingResult, +} from "../../../foundation/contracts"; +import type { HousekeepingPageInput } from "../../../route-handlers"; +import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users"; +import { PeoplePageFrame } from "./page-state"; + +interface PeopleUserDetailPageProps { + readonly context: HousekeepingCapabilityContext; + readonly result?: HousekeepingResult; + readonly partialDependencies?: readonly string[]; +} + +export function PeopleUserDetailPage({ + context, + result, + partialDependencies, +}: PeopleUserDetailPageProps) { + return ( + false} + > + {(data) => + data.kind === "user" ? ( +
+
+
+

+ {data.user.username} +

+ {context.has(PERMS.USERS_EDIT) ? ( + + Edit + + ) : null} +
+
+
Rank
+
{data.user.permission.rankName}
+
Status
+
{data.user.online ? "Online" : "Offline"}
+
Motto
+
{data.user.motto || "None"}
+ {data.user.mail !== null ? ( + <> +
Email
+
{data.user.mail}
+ + ) : null} + {data.user.ipCurrent !== null ? ( + <> +
Current IP
+
{data.user.ipCurrent}
+ + ) : null} +
+
+ {context.has(PERMS.USERS_BAN) ? ( + Ban + ) : null} + {context.has(PERMS.USERS_EDIT) ? ( + + Disconnect + + ) : null} + {context.has(PERMS.USERS_RESET_PASSWORD) ? ( + + Reset password + + ) : null} +
+
+
+

+ Sanctions +

+ {data.user.sanctions.length === 0 ? ( +

+ No sanctions recorded. +

+ ) : ( +
    + {data.user.sanctions.map((sanction) => ( +
  • + {sanction.kind}: {sanction.reason} +
  • + ))} +
+ )} +
+
+ ) : null + } +
+ ); +} + +export async function renderPeopleUserDetailPage(input: HousekeepingPageInput) { + const id = Number(input.match.params.id); + const result = + Number.isSafeInteger(id) && id > 0 + ? await peopleUsersQuery.run(input.context, { + routeId: "people.users.detail", + id, + }) + : fail( + "VALIDATION", + "errors.housekeeping.validation", + createCorrelationId(), + ); + return ; +} diff --git a/src/features/housekeeping/domains/people/pages/user-edit.tsx b/src/features/housekeeping/domains/people/pages/user-edit.tsx new file mode 100644 index 00000000..b845de34 --- /dev/null +++ b/src/features/housekeeping/domains/people/pages/user-edit.tsx @@ -0,0 +1,102 @@ +import { + createCorrelationId, + fail, + type HousekeepingCapabilityContext, + type HousekeepingResult, +} from "../../../foundation/contracts"; +import type { HousekeepingPageInput } from "../../../route-handlers"; +import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users"; +import { PeoplePageFrame } from "./page-state"; + +interface PeopleUserEditPageProps { + readonly context: HousekeepingCapabilityContext; + readonly result?: HousekeepingResult; + readonly partialDependencies?: readonly string[]; +} + +export function PeopleUserEditPage({ + context: _context, + result, + partialDependencies, +}: PeopleUserEditPageProps) { + return ( + false} + > + {(data) => + data.kind === "user" ? ( +
+ + + {data.user.mail !== null ? ( + + ) : null} + + + +
+ ) : null + } +
+ ); +} + +export async function renderPeopleUserEditPage(input: HousekeepingPageInput) { + const id = Number(input.match.params.id); + const result = + Number.isSafeInteger(id) && id > 0 + ? await peopleUsersQuery.run(input.context, { + routeId: "people.users.edit", + id, + }) + : fail( + "VALIDATION", + "errors.housekeeping.validation", + createCorrelationId(), + ); + return ; +} diff --git a/src/features/housekeeping/domains/people/pages/users.tsx b/src/features/housekeeping/domains/people/pages/users.tsx new file mode 100644 index 00000000..60ac4a3e --- /dev/null +++ b/src/features/housekeeping/domains/people/pages/users.tsx @@ -0,0 +1,86 @@ +import { PERMS } from "@/lib/permission-slugs"; +import type { + HousekeepingCapabilityContext, + HousekeepingResult, +} from "../../../foundation/contracts"; +import type { HousekeepingPageInput } from "../../../route-handlers"; +import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users"; +import { PeoplePageFrame } from "./page-state"; + +interface PeopleUsersPageProps { + readonly context: HousekeepingCapabilityContext; + readonly result?: HousekeepingResult; + readonly partialDependencies?: readonly string[]; +} + +export function PeopleUsersPage({ + context, + result, + partialDependencies, +}: PeopleUsersPageProps) { + return ( + data.kind === "users" && data.page.items.length === 0} + > + {(data) => + data.kind === "users" ? ( +
+

+ {data.page.total} matching users +

+
    + {data.page.items.map((user) => ( +
  • + + {user.username} + + + Rank {user.rank} + + + {user.online ? "Online" : "Offline"} + + {user.mail !== null ? {user.mail} : null} + {user.ipCurrent !== null ? ( + {user.ipCurrent} + ) : null} + {context.has(PERMS.USERS_EDIT) ? ( + + Edit + + ) : null} + {context.has(PERMS.USERS_BAN) ? ( + + Ban controls available + + ) : null} +
  • + ))} +
+
+ ) : null + } +
+ ); +} + +export async function renderPeopleUsersPage(input: HousekeepingPageInput) { + const result = await peopleUsersQuery.run(input.context, { + routeId: "people.users.list", + list: {}, + }); + return ; +} diff --git a/src/features/housekeeping/domains/people/route-handlers.ts b/src/features/housekeeping/domains/people/route-handlers.ts new file mode 100644 index 00000000..a0897889 --- /dev/null +++ b/src/features/housekeeping/domains/people/route-handlers.ts @@ -0,0 +1,40 @@ +import type { HousekeepingRouteHandler } from "../../route-handlers"; +import { renderPeopleCommunityPage } from "./pages/community"; +import { renderPeopleMultiAccountsPage } from "./pages/multi-accounts"; +import { renderPeopleStaffPage } from "./pages/staff"; +import { renderPeopleUserDetailPage } from "./pages/user-detail"; +import { renderPeopleUserEditPage } from "./pages/user-edit"; +import { renderPeopleUsersPage } from "./pages/users"; + +export const PEOPLE_PRIMARY_ROUTE_IDS = [ + "people.users.list", + "people.users.edit", + "people.users.multi-accounts", + "people.users.detail", + "people.community.online", + "people.community.guilds", + "people.community.guild-detail", + "people.staff.applications", + "people.staff.teams", +] as const; + +type PeoplePrimaryRouteId = (typeof PEOPLE_PRIMARY_ROUTE_IDS)[number]; + +function rendererFor( + routeId: PeoplePrimaryRouteId, +): HousekeepingRouteHandler["render"] { + if (routeId === "people.users.list") return renderPeopleUsersPage; + if (routeId === "people.users.edit") return renderPeopleUserEditPage; + if (routeId === "people.users.multi-accounts") + return renderPeopleMultiAccountsPage; + if (routeId === "people.users.detail") return renderPeopleUserDetailPage; + if (routeId.startsWith("people.community.")) return renderPeopleCommunityPage; + return renderPeopleStaffPage; +} + +export const PEOPLE_PRIMARY_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] = + Object.freeze( + PEOPLE_PRIMARY_ROUTE_IDS.map((routeId) => + Object.freeze({ routeId, render: rendererFor(routeId) }), + ), + ); diff --git a/src/features/housekeeping/domains/people/routes.ts b/src/features/housekeeping/domains/people/routes.ts index d953033d..3cb9a056 100644 --- a/src/features/housekeeping/domains/people/routes.ts +++ b/src/features/housekeeping/domains/people/routes.ts @@ -157,3 +157,5 @@ export const PEOPLE_ROUTES = [ [PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW], ), ] as const satisfies readonly HousekeepingRouteDefinition[]; + +export const PEOPLE_PRIMARY_ROUTES = Object.freeze(PEOPLE_ROUTES.slice(0, 9)); diff --git a/src/features/housekeeping/domains/people/services/mutations-production.test.ts b/src/features/housekeeping/domains/people/services/mutations-production.test.ts new file mode 100644 index 00000000..b3597166 --- /dev/null +++ b/src/features/housekeeping/domains/people/services/mutations-production.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +import type { HousekeepingCapabilityContext } from "../../../foundation/contracts"; + +const { audit, alertUser, selectLimit } = vi.hoisted(() => ({ + audit: vi.fn(), + alertUser: vi.fn(), + selectLimit: vi.fn(), +})); + +vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() })); +vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() })); +vi.mock("@/lib/db", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + db: { + ...actual.db, + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ limit: selectLimit })), + })), + })), + }, + }; +}); +vi.mock("@/lib/services/audit", async (importOriginal) => ({ + ...(await importOriginal()), + logAudit: audit, +})); +vi.mock("@/lib/services/rcon", async (importOriginal) => ({ + ...(await importOriginal()), + rcon: { alertUser }, +})); +vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() })); + +import { peopleMutationService } from "./mutations"; + +function capabilityContext( + granted: readonly string[], +): HousekeepingCapabilityContext { + const permissions = new Set(granted); + return { + actor: { id: 42, username: "operator", rank: 6 }, + isSuperAdmin: false, + has: (slug) => permissions.has(slug), + hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)), + hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)), + }; +} + +const target = { + id: 7, + username: "Alice", + mail: "alice@example.test", + rank: 2, + motto: "Ready", + credits: 100, + pixels: 50, + online: "1", +}; + +beforeEach(() => { + vi.clearAllMocks(); + selectLimit.mockResolvedValue([target]); + alertUser.mockResolvedValue(true); + audit.mockResolvedValue(undefined); +}); + +describe("People production mutation boundary", () => { + it("rechecks authorization before any production adapter work", async () => { + const result = await peopleMutationService.execute( + { capability: capabilityContext([]), correlationId: "production-denied" }, + "user.alert", + { userId: 7, message: "Hello" }, + ); + expect(result).toMatchObject({ ok: false, error: { code: "FORBIDDEN" } }); + expect(selectLimit).not.toHaveBeenCalled(); + expect(alertUser).not.toHaveBeenCalled(); + expect(audit).not.toHaveBeenCalled(); + }); + + it("emits sanitized before and after evidence for a successful mutation", async () => { + const result = await peopleMutationService.execute( + { + capability: capabilityContext([PERMS.USERS_EDIT]), + correlationId: "production-alert", + }, + "user.alert", + { userId: 7, message: "Hello" }, + ); + expect(result).toMatchObject({ + ok: true, + data: { + before: { id: 7, username: "Alice", online: true }, + after: { id: 7, alertDelivered: true }, + }, + }); + expect(audit).toHaveBeenCalledWith( + expect.objectContaining({ + action: "people.user.alert", + before: expect.objectContaining({ id: 7 }), + after: expect.objectContaining({ alertDelivered: true }), + correlationId: "production-alert", + outcome: "success", + }), + ); + }); + + it("fails closed when required audit evidence cannot be persisted", async () => { + audit.mockRejectedValue(new Error("audit database unavailable")); + const result = await peopleMutationService.execute( + { + capability: capabilityContext([PERMS.USERS_EDIT]), + correlationId: "production-audit-failure", + }, + "user.alert", + { userId: 7, message: "Hello" }, + ); + expect(alertUser).toHaveBeenCalled(); + expect(result).toMatchObject({ + ok: false, + error: { code: "DEPENDENCY_UNAVAILABLE" }, + correlationId: "production-audit-failure", + }); + }); +}); diff --git a/src/features/housekeeping/domains/people/services/mutations-reason-production.test.ts b/src/features/housekeeping/domains/people/services/mutations-reason-production.test.ts new file mode 100644 index 00000000..5769475a --- /dev/null +++ b/src/features/housekeeping/domains/people/services/mutations-reason-production.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +import type { HousekeepingCapabilityContext } from "../../../foundation/contracts"; + +const { audit, disconnectUser, insertValues, selectLimit } = vi.hoisted(() => ({ + audit: vi.fn(), + disconnectUser: vi.fn(), + insertValues: vi.fn(), + selectLimit: vi.fn(), +})); + +vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() })); +vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() })); +vi.mock("@/lib/db", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + db: { + ...actual.db, + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ limit: selectLimit })), + })), + })), + insert: vi.fn(() => ({ values: insertValues })), + }, + }; +}); +vi.mock("@/lib/services/audit", async (importOriginal) => ({ + ...(await importOriginal()), + logAudit: audit, +})); +vi.mock("@/lib/services/rcon", async (importOriginal) => ({ + ...(await importOriginal()), + rcon: { disconnectUser }, +})); +vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() })); + +import { peopleMutationService } from "./mutations"; + +function capabilityContext( + granted: readonly string[], +): HousekeepingCapabilityContext { + const permissions = new Set(granted); + return { + actor: { id: 42, username: "operator", rank: 6 }, + isSuperAdmin: false, + has: (slug) => permissions.has(slug), + hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)), + hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + selectLimit.mockResolvedValue([ + { + id: 7, + username: "Alice", + mail: "alice@example.test", + rank: 2, + motto: "Ready", + credits: 100, + pixels: 50, + online: "1", + }, + ]); + insertValues.mockResolvedValue([{}]); + disconnectUser.mockResolvedValue(true); + audit.mockResolvedValue(undefined); +}); + +describe("People production reason audit", () => { + it("persists the nonblank sanction reason with before and after evidence", async () => { + const result = await peopleMutationService.execute( + { + capability: capabilityContext([PERMS.USERS_BAN]), + correlationId: "production-ban", + }, + "user.ban", + { + userId: 7, + reason: "Repeated harassment", + duration: 24, + type: "account", + }, + ); + + expect(result).toMatchObject({ ok: true }); + expect(audit).toHaveBeenCalledWith( + expect.objectContaining({ + action: "people.user.ban", + before: expect.objectContaining({ id: 7, banned: false }), + after: expect.objectContaining({ + id: 7, + banned: true, + reason: "Repeated harassment", + }), + }), + ); + expect(audit.mock.calls[0]?.[0]?.before).not.toHaveProperty("mail"); + expect(audit.mock.calls[0]?.[0]?.after).not.toHaveProperty("mail"); + }); +}); diff --git a/src/features/housekeeping/domains/people/services/mutations.ts b/src/features/housekeeping/domains/people/services/mutations.ts new file mode 100644 index 00000000..1c83bfc2 --- /dev/null +++ b/src/features/housekeeping/domains/people/services/mutations.ts @@ -0,0 +1,995 @@ +import "server-only"; + +import crypto from "node:crypto"; +import { and, eq, inArray, max, sql } from "drizzle-orm"; +import type { ResultSetHeader } from "mysql2"; +import { invalidateLoginCache } from "@/lib/auth"; +import { hashPassword } from "@/lib/auth/password"; +import { + Ban, + db, + GuildForumViews, + Guilds, + GuildsForumsComments, + GuildsForumsThreads, + GuildsMembers, + Items, + Rooms, + Sanctions, + User, + UsersBadges, + UsersCurrency, + UsersSettings, + WebsiteIpBlacklist, + WebsiteIpWhitelist, + WebsiteSetting, + WebsiteStaffApplications, + WebsiteTeams, + WebsiteWordfilter, +} from "@/lib/db"; +import { PERMS } from "@/lib/permission-slugs"; +import { logAudit } from "@/lib/services/audit"; +import { reloadWordFilter } from "@/lib/services/moderation"; +import { rcon } from "@/lib/services/rcon"; +import { siteSettings } from "@/lib/services/site-settings"; +import { notify } from "@/lib/services/webhook"; +import { satisfiesCapability } from "../../../foundation/capability-context"; +import { + anyCapability, + fail, + type HousekeepingCapabilityContext, + type HousekeepingErrorCode, + type HousekeepingResult, + ok, +} from "../../../foundation/contracts"; + +export type PeopleMutationOperation = + | "user.update" + | "user.ban" + | "user.unban" + | "user.alert" + | "user.disconnect" + | "user.mute" + | "user.unmute" + | "user.reset-password" + | "user.send-currency" + | "user.trade-lock" + | "users.bulk-ban" + | "users.bulk-unban" + | "users.bulk-currency" + | "users.bulk-badge" + | "guild.disband" + | "application.decide" + | "team.change" + | "ip.action" + | "vpn.configure" + | "word-filter.update"; + +export interface PeopleMutationSnapshot { + readonly before: Readonly> | null; + readonly after: Readonly> | null; + readonly output?: Readonly>; +} + +export interface PeopleMutationContext { + readonly capability: HousekeepingCapabilityContext; + readonly correlationId: string; +} + +export interface PeopleMutationAdapter { + execute( + operation: PeopleMutationOperation, + input: unknown, + context: PeopleMutationContext, + ): Promise; +} + +export interface PeopleMutationService { + execute( + context: PeopleMutationContext, + operation: PeopleMutationOperation, + input: unknown, + ): Promise>; +} + +class PeopleMutationFailure extends Error { + constructor( + readonly code: HousekeepingErrorCode, + readonly messageKey: string, + readonly fieldErrors?: Readonly>, + ) { + super(messageKey); + this.name = "PeopleMutationFailure"; + } +} + +function operationCapability(operation: PeopleMutationOperation) { + if (operation === "user.ban" || operation === "user.unban") { + return anyCapability(PERMS.USERS_BAN); + } + if (operation === "user.reset-password") { + return anyCapability(PERMS.USERS_RESET_PASSWORD); + } + if (operation === "ip.action" || operation === "vpn.configure") { + return anyCapability(PERMS.SETTINGS_EDIT); + } + if (operation === "word-filter.update") { + return anyCapability(PERMS.WORDFILTER_EDIT); + } + return anyCapability(PERMS.USERS_EDIT); +} + +export function createPeopleMutationService( + adapter: PeopleMutationAdapter, +): PeopleMutationService { + 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 PeopleMutationFailure) { + return fail( + error.code, + error.messageKey, + context.correlationId, + error.fieldErrors, + ); + } + return fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + context.correlationId, + ); + } + }, + }; +} + +export function createLegacyPeopleMutationContext( + staff: { + readonly id: number; + readonly username: string; + readonly rank: number; + }, + permission: string, + correlationId: string, +): PeopleMutationContext { + return { + correlationId, + capability: { + actor: { id: staff.id, username: staff.username, rank: staff.rank }, + isSuperAdmin: false, + has: (slug) => slug === permission, + hasAny: (...slugs) => slugs.includes(permission), + hasAll: (...slugs) => slugs.every((slug) => slug === permission), + }, + }; +} + +function record(input: unknown): Record { + if (typeof input !== "object" || input === null || Array.isArray(input)) { + throw new PeopleMutationFailure( + "VALIDATION", + "errors.housekeeping.validation", + ); + } + return input as Record; +} + +function positiveInteger(value: unknown): number { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new PeopleMutationFailure( + "VALIDATION", + "errors.housekeeping.validation", + ); + } + return parsed; +} + +function nonNegativeInteger(value: unknown): number { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new PeopleMutationFailure( + "VALIDATION", + "errors.housekeeping.validation", + ); + } + return parsed; +} + +function normalizedText(value: unknown, max: number, required = true): string { + const text = String(value ?? "") + .normalize("NFC") + .trim() + .slice(0, max); + if (required && text.length === 0) { + throw new PeopleMutationFailure( + "VALIDATION", + "errors.housekeeping.validation", + ); + } + return text; +} + +function userIds(value: unknown): number[] { + if (!Array.isArray(value) || value.length === 0 || value.length > 100) { + throw new PeopleMutationFailure( + "VALIDATION", + "errors.housekeeping.validation", + ); + } + return [...new Set(value.map(positiveInteger))]; +} + +async function requireRcon(result: boolean): Promise { + if (!result) { + throw new PeopleMutationFailure( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + ); + } +} + +type TargetUser = { + id: number; + username: string; + rank: number; + motto: string; + credits: number; + pixels: number; + online: string; +}; + +async function loadTarget( + userId: number, + context: PeopleMutationContext, + guardHierarchy: boolean, +): Promise { + const [target] = await db + .select({ + id: User.id, + username: User.username, + rank: User.rank, + motto: User.motto, + credits: User.credits, + pixels: User.pixels, + online: User.online, + }) + .from(User) + .where(eq(User.id, userId)) + .limit(1); + if (!target) { + throw new PeopleMutationFailure( + "NOT_FOUND", + "errors.housekeeping.notFound", + ); + } + if ( + guardHierarchy && + target.rank >= context.capability.actor.rank && + context.capability.actor.rank < 7 + ) { + throw new PeopleMutationFailure( + "FORBIDDEN", + "errors.housekeeping.forbidden", + ); + } + return target; +} + +function targetSnapshot(target: TargetUser) { + return { + id: target.id, + username: target.username, + rank: target.rank, + motto: target.motto, + credits: target.credits, + pixels: target.pixels, + online: target.online === "1", + }; +} + +async function auditMutation( + context: PeopleMutationContext, + operation: PeopleMutationOperation, + target: string, + targetId: number | undefined, + snapshot: PeopleMutationSnapshot, +): Promise { + await logAudit({ + userId: context.capability.actor.id, + action: `people.${operation}`, + target, + targetId, + before: snapshot.before ?? undefined, + after: snapshot.after ?? undefined, + correlationId: context.correlationId, + outcome: "success", + domain: "people", + }); +} + +async function executeUserMutation( + operation: Extract, + input: unknown, + context: PeopleMutationContext, +): Promise { + const data = record(input); + const userId = positiveInteger(data.userId); + const guardHierarchy = operation !== "user.alert"; + const target = await loadTarget(userId, context, guardHierarchy); + const before = targetSnapshot(target); + let snapshot: PeopleMutationSnapshot; + + if (operation === "user.update") { + const fields = record(data.fields); + const nextRank = fields.rank; + if ( + nextRank !== undefined && + positiveInteger(nextRank) >= context.capability.actor.rank && + context.capability.actor.rank < 7 + ) { + throw new PeopleMutationFailure( + "FORBIDDEN", + "errors.housekeeping.forbidden", + ); + } + const userPatch = Object.fromEntries( + ["username", "mail", "rank", "motto", "credits", "pixels"].flatMap( + (key) => (fields[key] === undefined ? [] : [[key, fields[key]]]), + ), + ); + if (Object.keys(userPatch).length > 0) { + await db.update(User).set(userPatch).where(eq(User.id, userId)); + } + for (const [key, type] of [ + ["duckets", 0], + ["diamonds", 5], + ] as const) { + if (fields[key] === undefined) continue; + const amount = nonNegativeInteger(fields[key]); + await db + .insert(UsersCurrency) + .values({ userId, type, amount }) + .onDuplicateKeyUpdate({ set: { amount } }); + } + invalidateLoginCache(target.username); + snapshot = { before, after: { ...before, ...fields } }; + await notify({ + action: "user_edit", + actor: context.capability.actor.username, + target: target.username, + targetId: userId, + }); + } else if (operation === "user.ban") { + const reason = normalizedText(data.reason, 500); + const duration = nonNegativeInteger(data.duration); + const typeValue = normalizedText(data.type || "account", 32); + if ( + typeValue !== "account" && + typeValue !== "ip" && + typeValue !== "machine" + ) { + throw new PeopleMutationFailure( + "VALIDATION", + "errors.housekeeping.validation", + ); + } + const type: "account" | "ip" | "machine" = typeValue; + const now = Math.floor(Date.now() / 1000); + const banExpire = duration > 0 ? now + duration * 3600 : 0; + await db.insert(Ban).values({ + userId, + userStaffId: context.capability.actor.id, + timestamp: now, + banExpire, + banReason: reason, + type, + ip: normalizedText(data.ip, 255, false), + machineId: "", + }); + await rcon.disconnectUser(userId); + snapshot = { + before: { ...before, banned: false }, + after: { ...before, banned: true, banExpire, type, reason }, + }; + await notify({ + action: "ban", + actor: context.capability.actor.username, + target: target.username, + details: reason, + }); + } else if (operation === "user.unban") { + await db.delete(Ban).where(eq(Ban.userId, userId)); + snapshot = { + before: { ...before, banned: true }, + after: { ...before, banned: false }, + }; + await notify({ + action: "unban", + actor: context.capability.actor.username, + target: target.username, + }); + } else if (operation === "user.alert") { + const message = normalizedText(data.message, 500); + await requireRcon(await rcon.alertUser(userId, message)); + snapshot = { before, after: { ...before, alertDelivered: true } }; + } else if (operation === "user.disconnect") { + await requireRcon(await rcon.disconnectUser(userId)); + snapshot = { before, after: { ...before, online: false } }; + await notify({ + action: "disconnect", + actor: context.capability.actor.username, + target: target.username, + }); + } else if (operation === "user.mute") { + const duration = nonNegativeInteger(data.duration); + await requireRcon(await rcon.muteUser(userId, duration)); + snapshot = { before, after: { ...before, muted: true, duration } }; + } else if (operation === "user.unmute") { + await requireRcon(await rcon.unmuteUser(userId)); + snapshot = { before, after: { ...before, muted: false } }; + } else if (operation === "user.reset-password") { + const newPassword = crypto + .randomBytes(12) + .toString("base64url") + .slice(0, 16); + await db + .update(User) + .set({ password: await hashPassword(newPassword) }) + .where(eq(User.id, userId)); + invalidateLoginCache(target.username); + snapshot = { + before: { ...before, passwordReset: false }, + after: { ...before, passwordReset: true }, + output: { newPassword }, + }; + await notify({ + action: "user_edit", + actor: context.capability.actor.username, + target: target.username, + details: "Password reset", + }); + } else if (operation === "user.send-currency") { + const amount = positiveInteger(data.amount); + await requireRcon(await rcon.giveCredits(userId, amount)); + snapshot = { before, after: { ...before, creditsSent: amount } }; + } else { + const untilUnix = nonNegativeInteger(data.untilUnix); + const locked = untilUnix > 0; + await db.transaction(async (tx) => { + const [existing] = await tx + .select({ id: Sanctions.id }) + .from(Sanctions) + .where(eq(Sanctions.habboId, userId)) + .limit(1); + if (existing) { + await tx + .update(Sanctions) + .set({ + tradeLockedUntil: untilUnix, + ...(locked ? { reason: "Trade lock (CMS)" } : {}), + }) + .where(eq(Sanctions.id, existing.id)); + } else { + await tx.insert(Sanctions).values({ + habboId: userId, + tradeLockedUntil: untilUnix, + reason: locked ? "Trade lock (CMS)" : "", + }); + } + await tx + .update(UsersSettings) + .set({ + canTrade: locked ? "0" : "1", + ...(locked + ? { tradelockAmount: sql`${UsersSettings.tradelockAmount} + 1` } + : {}), + }) + .where(eq(UsersSettings.userId, userId)); + }); + await rcon.setTradeLock(userId, locked); + await rcon.alertUser( + userId, + locked + ? "Trading has been disabled by staff." + : "Trading has been re-enabled by staff.", + ); + if (target.online === "1") { + await rcon.disconnectUser(userId, target.username); + } + snapshot = { + before: { ...before, tradeLocked: !locked }, + after: { ...before, tradeLocked: locked, untilUnix }, + output: { userId, untilUnix }, + }; + } + + await auditMutation(context, operation, "User", userId, snapshot); + return snapshot; +} + +async function executeBulkMutation( + operation: Extract, + input: unknown, + context: PeopleMutationContext, +): Promise { + const data = record(input); + const ids = userIds(data.userIds); + const failures: Array<{ userId: number; reason: string }> = []; + let completed = 0; + let mutationReason: string | undefined; + + if (operation === "users.bulk-unban") { + const result = await db.delete(Ban).where(inArray(Ban.userId, ids)); + completed = Number( + (result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0, + ); + } else if (operation === "users.bulk-ban") { + const reason = normalizedText(data.reason, 500); + mutationReason = reason; + const durationSeconds = nonNegativeInteger(data.duration); + const now = Math.floor(Date.now() / 1000); + for (const userId of ids) { + try { + await db.insert(Ban).values({ + userId, + ip: "", + machineId: "", + userStaffId: context.capability.actor.id, + timestamp: now, + banExpire: durationSeconds > 0 ? now + durationSeconds : 0, + banReason: reason, + type: "account", + }); + completed += 1; + } catch { + failures.push({ userId, reason: "Database error" }); + } + } + } else if (operation === "users.bulk-currency") { + const amount = positiveInteger(data.amount); + const type = normalizedText(data.type, 16) as + | "credits" + | "pixels" + | "points"; + for (const userId of ids) { + try { + if (type === "credits") { + await db + .update(User) + .set({ credits: sql`${User.credits} + ${amount}` }) + .where(eq(User.id, userId)); + await rcon.giveCredits(userId, amount); + } else { + const currencyType = type === "pixels" ? 0 : 101; + await db + .insert(UsersCurrency) + .values({ userId, type: currencyType, amount }) + .onDuplicateKeyUpdate({ + set: { amount: sql`${UsersCurrency.amount} + ${amount}` }, + }); + if (type === "pixels") await rcon.giveDuckets(userId, amount); + else await rcon.givePointsGotw(userId, amount); + } + completed += 1; + } catch { + failures.push({ userId, reason: "Database error" }); + } + } + } else { + const badgeCode = normalizedText(data.badgeCode, 20); + for (const userId of ids) { + try { + const [existing] = await db + .select({ id: UsersBadges.id }) + .from(UsersBadges) + .where( + and( + eq(UsersBadges.userId, userId), + eq(UsersBadges.badgeCode, badgeCode), + ), + ) + .limit(1); + if (!existing) { + const [aggregate] = await db + .select({ maxSlot: max(UsersBadges.slotId) }) + .from(UsersBadges) + .where(eq(UsersBadges.userId, userId)); + await db.insert(UsersBadges).values({ + userId, + slotId: (aggregate?.maxSlot ?? 0) + 1, + badgeCode, + }); + await rcon.giveBadge(userId, badgeCode); + } + completed += 1; + } catch { + failures.push({ userId, reason: "Database error" }); + } + } + } + + const snapshot = { + before: { userIds: ids }, + after: { + completed, + total: ids.length, + failedIds: failures, + ...(mutationReason ? { reason: mutationReason } : {}), + }, + }; + await auditMutation(context, operation, "User", undefined, snapshot); + return snapshot; +} + +async function executeGuildDisband( + input: unknown, + context: PeopleMutationContext, +): Promise { + const guildId = positiveInteger(record(input).guildId); + const [guild] = await db + .select({ id: Guilds.id, name: Guilds.name, userId: Guilds.userId }) + .from(Guilds) + .where(eq(Guilds.id, guildId)) + .limit(1); + if (!guild) { + throw new PeopleMutationFailure( + "NOT_FOUND", + "errors.housekeeping.notFound", + ); + } + await db.transaction(async (tx) => { + const threads = await tx + .select({ id: GuildsForumsThreads.id }) + .from(GuildsForumsThreads) + .where(eq(GuildsForumsThreads.guildId, guildId)); + const threadIds = threads.map((thread) => thread.id); + if (threadIds.length > 0) { + await tx + .delete(GuildsForumsComments) + .where(inArray(GuildsForumsComments.threadId, threadIds)); + await tx + .delete(GuildsForumsThreads) + .where(eq(GuildsForumsThreads.guildId, guildId)); + } + await tx + .delete(GuildForumViews) + .where(eq(GuildForumViews.guildId, guildId)); + await tx.delete(GuildsMembers).where(eq(GuildsMembers.guildId, guildId)); + await tx + .update(Rooms) + .set({ guildId: 0 }) + .where(eq(Rooms.guildId, guildId)); + await tx + .update(Items) + .set({ guildId: 0 }) + .where(eq(Items.guildId, guildId)); + await tx.delete(Guilds).where(eq(Guilds.id, guildId)); + }); + const snapshot = { + before: { id: guild.id, name: guild.name, userId: guild.userId }, + after: null, + }; + await auditMutation(context, "guild.disband", "Guild", guildId, snapshot); + return snapshot; +} + +async function executeApplicationDecision( + input: unknown, + context: PeopleMutationContext, +): Promise { + const data = record(input); + const applicationId = positiveInteger(data.applicationId); + if (data.decision !== "dismiss") { + throw new PeopleMutationFailure( + "VALIDATION", + "errors.housekeeping.validation", + ); + } + const [application] = await db + .select({ + id: WebsiteStaffApplications.id, + userId: WebsiteStaffApplications.userId, + rankId: WebsiteStaffApplications.rankId, + content: WebsiteStaffApplications.content, + }) + .from(WebsiteStaffApplications) + .where(eq(WebsiteStaffApplications.id, BigInt(applicationId))) + .limit(1); + if (!application) { + throw new PeopleMutationFailure( + "NOT_FOUND", + "errors.housekeeping.notFound", + ); + } + await db + .delete(WebsiteStaffApplications) + .where(eq(WebsiteStaffApplications.id, BigInt(applicationId))); + const snapshot = { + before: { + id: Number(application.id), + userId: application.userId, + rankId: application.rankId, + content: application.content, + }, + after: null, + }; + await auditMutation( + context, + "application.decide", + "StaffApplication", + applicationId, + snapshot, + ); + return snapshot; +} + +async function executeTeamChange( + input: unknown, + context: PeopleMutationContext, +): Promise { + const data = record(input); + if (data.action === "delete") { + const teamId = positiveInteger(data.teamId); + const [team] = await db + .select({ + id: WebsiteTeams.id, + rankName: WebsiteTeams.rankName, + badge: WebsiteTeams.badge, + jobDescription: WebsiteTeams.jobDescription, + hiddenRank: WebsiteTeams.hiddenRank, + }) + .from(WebsiteTeams) + .where(eq(WebsiteTeams.id, BigInt(teamId))) + .limit(1); + if (!team) { + throw new PeopleMutationFailure( + "NOT_FOUND", + "errors.housekeeping.notFound", + ); + } + await db.delete(WebsiteTeams).where(eq(WebsiteTeams.id, BigInt(teamId))); + const snapshot = { + before: { ...team, id: Number(team.id) }, + after: null, + }; + await auditMutation(context, "team.change", "Team", teamId, snapshot); + return snapshot; + } + + const rankName = normalizedText(data.rankName, 255); + const badge = normalizedText(data.badge, 255, false); + const jobDescription = normalizedText(data.jobDescription, 255, false); + const staffColor = normalizedText(data.staffColor || "#327fa8", 255); + const hiddenRank = Boolean(data.hiddenRank); + const now = new Date(); + const [result] = await db.insert(WebsiteTeams).values({ + rankName, + badge: badge || null, + jobDescription: jobDescription || null, + staffColor, + hiddenRank, + createdAt: now, + updatedAt: now, + }); + const teamId = Number(result.insertId); + const snapshot = { + before: null, + after: { + teamId, + rankName, + badge: badge || null, + jobDescription: jobDescription || null, + staffColor, + hiddenRank, + }, + }; + await auditMutation(context, "team.change", "Team", teamId, snapshot); + return snapshot; +} + +async function executeIpAction( + input: unknown, + context: PeopleMutationContext, +): Promise { + const data = record(input); + const action = normalizedText(data.action, 32); + const blacklist = action.endsWith("blacklist"); + const adding = action.startsWith("add-"); + const table = blacklist ? WebsiteIpBlacklist : WebsiteIpWhitelist; + if (adding) { + const ipAddress = normalizedText(data.ipAddress, 255); + const asn = normalizedText(data.asn, 255, false) || null; + const [result] = blacklist + ? await db + .insert(WebsiteIpBlacklist) + .values({ ipAddress, asn, blacklistAsn: asn !== null }) + : await db + .insert(WebsiteIpWhitelist) + .values({ ipAddress, asn, whitelistAsn: asn !== null }); + const id = Number(result.insertId); + const snapshot = { before: null, after: { id, action, ipAddress, asn } }; + await auditMutation(context, "ip.action", "IpRule", id, snapshot); + return snapshot; + } + const id = positiveInteger(data.id); + await db.delete(table).where(eq(table.id, BigInt(id))); + const snapshot = { before: { id, action }, after: null }; + await auditMutation(context, "ip.action", "IpRule", id, snapshot); + return snapshot; +} + +async function writeVpnSetting( + key: string, + value: string, + comment: string, +): Promise { + await db + .insert(WebsiteSetting) + .values({ key, value, comment }) + .onDuplicateKeyUpdate({ set: { value } }); +} + +async function executeVpnConfiguration( + input: unknown, + context: PeopleMutationContext, +): Promise { + const data = record(input); + const enabled = Boolean(data.enabled); + const provider = normalizedText(data.provider, 32); + const apiKey = normalizedText(data.apiKey, 255, false); + const blockMessage = normalizedText(data.blockMessage, 255, false); + const [storedEnabled, storedProvider, storedApiKey, storedBlockMessage] = + await Promise.all([ + siteSettings.get("vpn_block_enabled"), + siteSettings.get("vpn_provider"), + siteSettings.get("vpn_api_key"), + siteSettings.get("vpn_block_message"), + ]); + const before = { + enabled: storedEnabled === "1", + provider: storedProvider ?? "none", + apiKeyConfigured: Boolean(storedApiKey), + blockMessage: storedBlockMessage ?? "", + }; + await writeVpnSetting( + "vpn_block_enabled", + enabled ? "1" : "0", + "Block registrations from detected VPN/proxy IPs (0=no, 1=yes)", + ); + await writeVpnSetting( + "vpn_provider", + provider, + "VPN/proxy detection provider (none/proxycheck/ipqualityscore)", + ); + await writeVpnSetting( + "vpn_api_key", + apiKey, + "API key for the VPN/proxy detection provider", + ); + await writeVpnSetting( + "vpn_block_message", + blockMessage, + "Message shown to users blocked for using a VPN/proxy", + ); + await siteSettings.reload(); + const snapshot = { + before, + after: { + enabled, + provider, + apiKeyConfigured: apiKey.length > 0, + blockMessage, + }, + }; + await auditMutation( + context, + "vpn.configure", + "VpnConfiguration", + undefined, + snapshot, + ); + return snapshot; +} + +async function executeWordFilterUpdate( + input: unknown, + context: PeopleMutationContext, +): Promise { + const data = record(input); + if (data.action === "add") { + const word = normalizedText(data.word, 255); + const [result] = (await db + .insert(WebsiteWordfilter) + .values({ word })) as unknown as [ResultSetHeader]; + const id = Number(result.insertId); + reloadWordFilter(); + await rcon.updateWordFilter(); + const snapshot = { before: null, after: { id, word } }; + await auditMutation( + context, + "word-filter.update", + "WordFilter", + id, + snapshot, + ); + return snapshot; + } + const id = positiveInteger(data.id); + const [existing] = await db + .select({ id: WebsiteWordfilter.id, word: WebsiteWordfilter.word }) + .from(WebsiteWordfilter) + .where(eq(WebsiteWordfilter.id, BigInt(id))) + .limit(1); + if (!existing) { + throw new PeopleMutationFailure( + "NOT_FOUND", + "errors.housekeeping.notFound", + ); + } + await db + .delete(WebsiteWordfilter) + .where(eq(WebsiteWordfilter.id, BigInt(id))); + reloadWordFilter(); + await rcon.updateWordFilter(); + const snapshot = { + before: { id: Number(existing.id), word: existing.word }, + after: null, + }; + await auditMutation( + context, + "word-filter.update", + "WordFilter", + id, + snapshot, + ); + return snapshot; +} + +const productionPeopleMutationAdapter: PeopleMutationAdapter = { + async execute(operation, input, context) { + if (operation.startsWith("user.")) { + return executeUserMutation( + operation as Extract, + input, + context, + ); + } + if (operation.startsWith("users.")) { + return executeBulkMutation( + operation as Extract, + input, + context, + ); + } + if (operation === "guild.disband") + return executeGuildDisband(input, context); + if (operation === "application.decide") { + return executeApplicationDecision(input, context); + } + if (operation === "team.change") return executeTeamChange(input, context); + if (operation === "ip.action") return executeIpAction(input, context); + if (operation === "vpn.configure") { + return executeVpnConfiguration(input, context); + } + return executeWordFilterUpdate(input, context); + }, +}; + +export const peopleMutationService = createPeopleMutationService( + productionPeopleMutationAdapter, +); diff --git a/src/features/housekeeping/foundation/commands/bootstrap.test.ts b/src/features/housekeeping/foundation/commands/bootstrap.test.ts index a061fa35..63987da5 100644 --- a/src/features/housekeeping/foundation/commands/bootstrap.test.ts +++ b/src/features/housekeeping/foundation/commands/bootstrap.test.ts @@ -1,5 +1,11 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; + +vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() })); +vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() })); + +import { COMMUNITY_COMMAND_IDS } from "../../domains/people/commands/community-commands"; +import { USER_COMMAND_IDS } from "../../domains/people/commands/user-commands"; import { SYSTEM_COMMAND_IDS } from "../../domains/system/commands/system-commands"; import { anyCapability, ok } from "../contracts"; import { @@ -60,6 +66,12 @@ describe("housekeeping command bootstrap", () => { expect( SYSTEM_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id), ).toEqual(SYSTEM_COMMAND_IDS); + expect( + USER_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id), + ).toEqual(USER_COMMAND_IDS); + expect( + COMMUNITY_COMMAND_IDS.map((id) => getHousekeepingCommand(id)?.id), + ).toEqual(COMMUNITY_COMMAND_IDS); expect(() => registerHousekeepingCommand({ id: "system.bootstrap.too-late", diff --git a/src/features/housekeeping/foundation/commands/bootstrap.ts b/src/features/housekeeping/foundation/commands/bootstrap.ts index 5a014a3a..271a877e 100644 --- a/src/features/housekeeping/foundation/commands/bootstrap.ts +++ b/src/features/housekeeping/foundation/commands/bootstrap.ts @@ -1,5 +1,7 @@ import "server-only"; +import { COMMUNITY_COMMANDS } from "../../domains/people/commands/community-commands"; +import { USER_COMMANDS } from "../../domains/people/commands/user-commands"; import { SYSTEM_COMMANDS } from "../../domains/system/commands/system-commands"; import type { HousekeepingCommand } from "./registry"; import { @@ -36,6 +38,8 @@ export function registerHousekeepingCommands< } const currentHousekeepingCommands = defineHousekeepingCommands( + ...USER_COMMANDS, + ...COMMUNITY_COMMANDS, ...SYSTEM_COMMANDS, ); diff --git a/src/features/housekeeping/foundation/foundation-source-contract.test.ts b/src/features/housekeeping/foundation/foundation-source-contract.test.ts index b5ad1d37..cfe70f8c 100644 --- a/src/features/housekeeping/foundation/foundation-source-contract.test.ts +++ b/src/features/housekeeping/foundation/foundation-source-contract.test.ts @@ -4,6 +4,7 @@ import { join, posix } from "node:path"; import { createElement, type ReactElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; +import { PEOPLE_PRIMARY_ROUTE_IDS } from "../domains/people/route-handlers"; import { SYSTEM_ROUTE_IDS } from "../domains/system/routes"; import { HOUSEKEEPING_MANIFESTS } from "../manifests"; import { discoverLegacyPages } from "../migration/discover-legacy-pages"; @@ -18,7 +19,82 @@ 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>([ +const approvedRuntimeImports = new Map>([ + [ + "src/features/housekeeping/domains/people/commands/community-commands.ts", + new Set(["src/features/housekeeping/domains/people/services/mutations"]), + ], + [ + "src/features/housekeeping/domains/people/commands/user-commands.ts", + new Set(["src/features/housekeeping/domains/people/services/mutations"]), + ], + [ + "src/features/housekeeping/domains/people/pages/community.tsx", + new Set([ + "src/features/housekeeping/domains/people/pages/page-state", + "src/features/housekeeping/domains/people/queries/community", + ]), + ], + [ + "src/features/housekeeping/domains/people/pages/multi-accounts.tsx", + new Set([ + "src/features/housekeeping/domains/people/pages/page-state", + "src/features/housekeeping/domains/people/queries/users", + ]), + ], + [ + "src/features/housekeeping/domains/people/pages/staff.tsx", + new Set([ + "src/features/housekeeping/domains/people/pages/page-state", + "src/features/housekeeping/domains/people/queries/staff", + ]), + ], + [ + "src/features/housekeeping/domains/people/pages/user-detail.tsx", + new Set([ + "src/features/housekeeping/domains/people/pages/page-state", + "src/features/housekeeping/domains/people/queries/users", + ]), + ], + [ + "src/features/housekeeping/domains/people/pages/user-edit.tsx", + new Set([ + "src/features/housekeeping/domains/people/pages/page-state", + "src/features/housekeeping/domains/people/queries/users", + ]), + ], + [ + "src/features/housekeeping/domains/people/pages/users.tsx", + new Set([ + "src/features/housekeeping/domains/people/pages/page-state", + "src/features/housekeeping/domains/people/queries/users", + ]), + ], + [ + "src/features/housekeeping/domains/people/services/mutations.ts", + new Set([ + "src/lib/auth", + "src/lib/auth/password", + "src/lib/db", + "drizzle-orm", + "mysql2", + ]), + ], + [ + "src/features/housekeeping/domains/people/manifest.ts", + new Set(["src/features/housekeeping/domains/people/routes"]), + ], + [ + "src/features/housekeeping/domains/people/route-handlers.ts", + new Set([ + "src/features/housekeeping/domains/people/pages/community", + "src/features/housekeeping/domains/people/pages/multi-accounts", + "src/features/housekeeping/domains/people/pages/staff", + "src/features/housekeeping/domains/people/pages/user-detail", + "src/features/housekeeping/domains/people/pages/user-edit", + "src/features/housekeeping/domains/people/pages/users", + ]), + ], [ "src/features/housekeeping/domains/system/commands/system-commands.ts", new Set(["src/features/housekeeping/domains/system/services/mutations"]), @@ -127,12 +203,17 @@ const approvedSystemRuntimeImports = new Map>([ [ "src/features/housekeeping/foundation/commands/bootstrap.ts", new Set([ + "src/features/housekeeping/domains/people/commands/community-commands", + "src/features/housekeeping/domains/people/commands/user-commands", "src/features/housekeeping/domains/system/commands/system-commands", ]), ], [ "src/features/housekeeping/route-handlers.ts", - new Set(["src/features/housekeeping/domains/system/route-handlers"]), + new Set([ + "src/features/housekeeping/domains/people/route-handlers", + "src/features/housekeeping/domains/system/route-handlers", + ]), ], ]); const forbiddenModuleRoots = [ @@ -476,11 +557,11 @@ function isAllowedPermissionSetTypeImport( ); } -function isApprovedSystemRuntimeImport( +function isApprovedRuntimeImport( canonical: CanonicalModuleSpecifier, sourceFile: string, ): boolean { - const allowed = approvedSystemRuntimeImports.get(sourceFile); + const allowed = approvedRuntimeImports.get(sourceFile); return ( allowed !== undefined && canonical.candidates.some((candidate) => allowed.has(candidate)) @@ -499,7 +580,7 @@ function findHousekeepingImportBoundaryViolations( if (canonical.violation) violations.push(canonical.violation); if (isAllowedPermissionSetTypeImport(access, canonical, sourceFile)) continue; - if (isApprovedSystemRuntimeImport(canonical, sourceFile)) continue; + if (isApprovedRuntimeImport(canonical, sourceFile)) continue; const forbiddenPath = canonical.candidates.find((candidate) => isForbiddenModulePath(candidate, sourceFile), ); @@ -528,7 +609,13 @@ describe("housekeeping runtime import boundary", () => { } }); - it("allows only the approved System vertical runtime edges", () => { + it("allows only approved domain vertical runtime edges", () => { + expect( + findHousekeepingImportBoundaryViolations( + 'import { peopleMutationService } from "../services/mutations";', + "src/features/housekeeping/domains/people/commands/user-commands.ts", + ), + ).toEqual([]); expect( findHousekeepingImportBoundaryViolations( 'import { systemMutationService } from "../services/mutations";', @@ -718,7 +805,7 @@ describe("housekeeping foundation completion contracts", () => { } }); - it("creates the real six-domain registry with only the System routes enabled", () => { + it("creates the real six-domain registry with People primary and System routes enabled", () => { const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS); expect(registry.domains.map((domain) => domain.id)).toEqual([ @@ -731,9 +818,14 @@ describe("housekeeping foundation completion contracts", () => { ]); expect( registry.domains - .filter((domain) => domain.id !== "system") + .filter((domain) => !["people", "system"].includes(domain.id)) .every((domain) => domain.routes.length === 0), ).toBe(true); + expect( + registry.domains + .find((domain) => domain.id === "people") + ?.routes.map((route) => route.id), + ).toEqual(PEOPLE_PRIMARY_ROUTE_IDS); expect( registry.domains .find((domain) => domain.id === "system") diff --git a/src/features/housekeeping/foundation/registry.test.ts b/src/features/housekeeping/foundation/registry.test.ts index 250c5400..555215c4 100644 --- a/src/features/housekeeping/foundation/registry.test.ts +++ b/src/features/housekeeping/foundation/registry.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { PERMS } from "@/lib/permission-slugs"; +import { PEOPLE_PRIMARY_ROUTES } from "../domains/people/routes"; import { SYSTEM_ROUTES } from "../domains/system/routes"; import { HOUSEKEEPING_MANIFESTS } from "../manifests"; import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix"; @@ -355,7 +356,11 @@ describe("housekeeping registry", () => { descriptionKey: expected.descriptionKey, }); expect(actual.routes).toEqual( - expected.id === "system" ? SYSTEM_ROUTES : [], + expected.id === "people" + ? PEOPLE_PRIMARY_ROUTES + : expected.id === "system" + ? SYSTEM_ROUTES + : [], ); expect(actual.searchProviders).toEqual([]); expect(actual.inboxSources).toEqual([]); diff --git a/src/features/housekeeping/foundation/server-capability-context.test.ts b/src/features/housekeeping/foundation/server-capability-context.test.ts index 82bf391c..deb1e320 100644 --- a/src/features/housekeeping/foundation/server-capability-context.test.ts +++ b/src/features/housekeeping/foundation/server-capability-context.test.ts @@ -74,12 +74,12 @@ function adminContext( }; } -async function invokeRequestConsumers() { +async function invokeRequestConsumers(expectedPageError = "NEXT_NOT_FOUND") { const shell = await AdminNextDomainLayout({ children: null, params: Promise.resolve({ domain: "operations" }), }); - await expect(AdminNextPage()).rejects.toThrow("NEXT_NOT_FOUND"); + await expect(AdminNextPage()).rejects.toThrow(expectedPageError); const commandContext = await requireHousekeepingCapability( anyCapability("admin.dashboard"), ); @@ -102,7 +102,9 @@ describe("getHousekeepingCapabilityContext", () => { }); it("isolates real shell, page, and command preflight consumers between logical requests", async () => { - const first = await invokeRequestConsumers(); + const first = await invokeRequestConsumers( + "NEXT_REDIRECT:/ase-next/people/users", + ); expect(first.shell).toBeDefined(); expect(first.commandContext.actor).toEqual({ diff --git a/src/features/housekeeping/route-handlers.test.ts b/src/features/housekeeping/route-handlers.test.ts index 3a0183d6..b5f72e64 100644 --- a/src/features/housekeeping/route-handlers.test.ts +++ b/src/features/housekeeping/route-handlers.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { PEOPLE_PRIMARY_ROUTE_IDS } from "./domains/people/route-handlers"; import { SYSTEM_ROUTE_IDS } from "./domains/system/routes"; import { createHousekeepingRegistry } from "./foundation/registry"; import { HOUSEKEEPING_MANIFESTS } from "./manifests"; @@ -16,6 +17,9 @@ describe("housekeeping route handlers", () => { expect(new Set(handlerIds).size).toBe(handlerIds.length); expect([...handlerIds].sort()).toEqual([...routeIds].sort()); - expect(handlerIds).toEqual(SYSTEM_ROUTE_IDS); + expect(handlerIds).toEqual([ + ...PEOPLE_PRIMARY_ROUTE_IDS, + ...SYSTEM_ROUTE_IDS, + ]); }); }); diff --git a/src/features/housekeeping/route-handlers.ts b/src/features/housekeeping/route-handlers.ts index 10cfe2da..4c0cb490 100644 --- a/src/features/housekeeping/route-handlers.ts +++ b/src/features/housekeeping/route-handlers.ts @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { PEOPLE_PRIMARY_ROUTE_HANDLERS } from "./domains/people/route-handlers"; import { SYSTEM_ROUTE_HANDLERS } from "./domains/system/route-handlers"; import type { HousekeepingCapabilityContext } from "./foundation/contracts"; import type { HousekeepingRouteMatch } from "./foundation/routing/match-route"; @@ -14,4 +15,4 @@ export interface HousekeepingRouteHandler { } export const HOUSEKEEPING_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] = - Object.freeze([...SYSTEM_ROUTE_HANDLERS]); + Object.freeze([...PEOPLE_PRIMARY_ROUTE_HANDLERS, ...SYSTEM_ROUTE_HANDLERS]); diff --git a/src/messages/en.json b/src/messages/en.json index 3866eeef..6fe454b4 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -3295,6 +3295,23 @@ } }, "routes": { + "people": { + "users": { + "list": "Users", + "edit": "Edit user", + "multi-accounts": "Multi-account clusters", + "detail": "User details" + }, + "community": { + "online": "Online users", + "guilds": "Guilds", + "guild-detail": "Guild details" + }, + "staff": { + "applications": "Staff applications", + "teams": "Staff teams" + } + }, "system": { "access": { "permissions": "Permissions", diff --git a/src/messages/it.json b/src/messages/it.json index 301d1b8d..1d79ac44 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -3300,6 +3300,23 @@ } }, "routes": { + "people": { + "users": { + "list": "Utenti", + "edit": "Modifica utente", + "multi-accounts": "Gruppi multi-account", + "detail": "Dettagli utente" + }, + "community": { + "online": "Utenti online", + "guilds": "Gruppi", + "guild-detail": "Dettagli gruppo" + }, + "staff": { + "applications": "Candidature staff", + "teams": "Team staff" + } + }, "system": { "access": { "permissions": "Permessi",