feat(housekeeping): deliver people account workflows

This commit is contained in:
Simo committed 2026-08-29 11:45:50 +02:00
1 parent 7920d4f46c
commit e1b31ff773
45 files changed
+4290 -1243

No files matched your search

@@ -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 <exact Task 12 src files>
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.
+20 -13
View File
@@ -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<void> {
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");
}
+30 -71
View File
@@ -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<string, string>) => ({
get: (key: string) => data[key] ?? null,
});
const form = (data: Record<string, string>) =>
({ 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<void>) => {
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();
});
});
+20 -53
View File
@@ -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<void> {
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");
}
+41 -63
View File
@@ -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<string, string>) => ({
get: (key: string) => data[key] ?? null,
});
const form = (data: Record<string, string>) =>
({ 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();
});
+42 -51
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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");
}
+47 -43
View File
@@ -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<string, string | null>) => ({
get: (key: string) => (key in data ? data[key] : null),
});
const form = (data: Record<string, string>) =>
({ 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();
});
+45 -36
View File
@@ -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<void> {
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<void> {
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");
}
+54 -42
View File
@@ -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<string, string | null>) => ({
get: (key: string) => (key in data ? data[key] : null),
});
const form = (data: Record<string, string>) =>
({ 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");
});
+28 -72
View File
@@ -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<void> {
await db
.insert(WebsiteSetting)
.values({ key, value, comment })
.onDuplicateKeyUpdate({ set: { value } });
}
export async function saveVpn(formData: FormData): Promise<void> {
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");
}
+34 -30
View File
@@ -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<ActionResult<{ id: string }>> {
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<ActionResult> {
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();
}
+58
View File
@@ -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" },
);
});
+66 -150
View File
@@ -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 },
);
});
});
+114 -225
View File
@@ -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<ActionResult<{ unbanned: number; total: number }>> {
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<ActionResult<{ banned: number }>> {
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<ActionResult<{ userId: number; untilUnix: number }>> {
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 } };
}
+138
View File
@@ -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<string, string>) =>
({ 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<unknown>)({
id: 7,
motto: "Ready",
});
await (banUser as never as (input: unknown) => Promise<unknown>)({
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");
});
});
+87
View File
@@ -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<string, string>) =>
({ 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");
});
});
-25
View File
@@ -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")');
});
});
+145 -353
View File
@@ -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<Record<PeopleMutationOperation, string>> = {
"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();
},
);
@@ -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<unknown, unknown>[];
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 },
);
});
});
@@ -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<I> {
readonly id: CommunityCommandId;
readonly operation: PeopleMutationOperation;
readonly capability: string;
readonly input: z.ZodType<I>;
}
function communityCommand<I>(
service: Pick<PeopleMutationService, "execute">,
options: CommandOptions<I>,
): HousekeepingCommand<I, unknown> {
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<PeopleMutationService, "execute">,
) {
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,
);
@@ -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<unknown, unknown>[];
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");
});
});
@@ -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<I> {
readonly id: UserCommandId;
readonly operation: PeopleMutationOperation;
readonly capability: string;
readonly input: z.ZodType<I>;
readonly requiresReason: boolean;
readonly attempts?: number;
}
function userCommand<I>(
service: Pick<PeopleMutationService, "execute">,
options: UserCommandOptions<I>,
): HousekeepingCommand<I, unknown> {
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<PeopleMutationService, "execute">,
) {
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);
@@ -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: [],
@@ -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<PeopleCommunityQueryData>;
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 (
<PeoplePageFrame
title="Community"
description="Review online users and guild ownership."
result={result}
partialDependencies={partialDependencies}
isEmpty={empty}
>
{(data) => {
if (data.kind === "online")
return (
<ul className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
{data.page.items.map((user) => (
<li key={user.id}>
<a href={user.href}>{user.username}</a> - {user.motto}
</li>
))}
</ul>
);
if (data.kind === "guilds")
return (
<ul className="space-y-2">
{data.page.items.map((guild) => (
<li
key={guild.id}
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
>
<a href={guild.href}>{guild.name}</a>
<p>{guild.memberCount} members</p>
</li>
))}
</ul>
);
return (
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
<h2>{data.guild.name}</h2>
<p>{data.guild.description}</p>
<ul>
{data.guild.members.map((member) => (
<li key={member.id}>
<a href={member.href}>{member.username}</a>
</li>
))}
</ul>
{context.has(PERMS.USERS_EDIT) ? (
<span data-housekeeping-command="people.guild.disband">
Disband guild
</span>
) : null}
</section>
);
}}
</PeoplePageFrame>
);
}
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 <PeopleCommunityPage context={input.context} result={await result} />;
}
@@ -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<PeopleUsersQueryData>;
readonly partialDependencies?: readonly string[];
}
export function PeopleMultiAccountsPage({
context: _context,
result,
partialDependencies,
}: PeopleMultiAccountsPageProps) {
return (
<PeoplePageFrame
title="Multi-account clusters"
description="Review bounded account clusters grouped by current IP."
result={result}
partialDependencies={partialDependencies}
isEmpty={(data) =>
data.kind === "multi-accounts" && data.page.items.length === 0
}
>
{(data) =>
data.kind === "multi-accounts" ? (
<ul className="space-y-3">
{data.page.items.map((cluster) => (
<li
key={cluster.key}
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
>
<h2 className="font-medium text-[var(--admin-text)]">
{cluster.key}
</h2>
<p className="text-sm text-[var(--admin-text-muted)]">
{cluster.accountCount} accounts
</p>
<ul className="mt-2 flex flex-wrap gap-3 text-sm">
{cluster.accounts.map((account) => (
<li key={account.id}>
<a href={account.href}>{account.username}</a>
</li>
))}
</ul>
</li>
))}
</ul>
) : null
}
</PeoplePageFrame>
);
}
export async function renderPeopleMultiAccountsPage(
input: HousekeepingPageInput,
) {
const result = await peopleUsersQuery.run(input.context, {
routeId: "people.users.multi-accounts",
list: {},
});
return <PeopleMultiAccountsPage context={input.context} result={result} />;
}
@@ -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<T> {
readonly title: string;
readonly description: string;
readonly result?: HousekeepingResult<T>;
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 (
<div data-housekeeping-state={kind}>
<HousekeepingPageState
state={kind}
title={title}
description={description}
/>
</div>
);
}
export function PeoplePageFrame<T>({
title,
description,
result,
partialDependencies = [],
isEmpty,
children,
}: PeoplePageFrameProps<T>) {
let body: ReactNode;
if (!result) {
body = state("loading", `Loading ${title.toLowerCase()}`, description);
} else if (!result.ok) {
if (result.error.code === "FORBIDDEN") {
body = (
<section
role="alert"
data-housekeeping-state="forbidden"
className="rounded-lg border border-[var(--admin-error)] bg-[var(--admin-surface)] p-4"
>
<h2 className="font-medium text-[var(--admin-text)]">
Access denied
</h2>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
Your account cannot use this People workflow.
</p>
</section>
);
} 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 = (
<div
data-housekeeping-state={
partialDependencies.length > 0 ? "partial" : "ready"
}
className="space-y-4"
>
{partialDependencies.length > 0 ? (
<HousekeepingPageState
state="partial"
partialLabel="Partial data"
title="Some People data is unavailable"
description={`Unavailable: ${partialDependencies.join(", ")}`}
/>
) : null}
{children(result.data)}
</div>
);
}
return (
<HousekeepingPageShell title={title} description={description}>
{body}
</HousekeepingPageShell>
);
}
@@ -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: "[email protected]",
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<unknown>,
partialDependencies?: readonly string[],
) => string;
readonly empty: unknown;
readonly ready: unknown;
};
const cases: readonly PageCase[] = [
{
name: "users",
render: (result, partialDependencies) =>
renderToStaticMarkup(
<PeopleUsersPage
context={readContext}
result={result as HousekeepingResult<PeopleUsersQueryData>}
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(
<PeopleUserDetailPage
context={readContext}
result={result as HousekeepingResult<PeopleUsersQueryData>}
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(
<PeopleUserEditPage
context={capabilityContext([PERMS.USERS_EDIT, PERMS.USERS_VIEW])}
result={result as HousekeepingResult<PeopleUsersQueryData>}
partialDependencies={partialDependencies}
/>,
),
empty: fail("NOT_FOUND", "errors.housekeeping.notFound", correlationId),
ready: { kind: "user", user: { ...detail, watched: true } },
},
{
name: "multi-accounts",
render: (result, partialDependencies) =>
renderToStaticMarkup(
<PeopleMultiAccountsPage
context={readContext}
result={result as HousekeepingResult<PeopleUsersQueryData>}
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(
<PeopleCommunityPage
context={readContext}
result={result as HousekeepingResult<PeopleCommunityQueryData>}
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(
<PeopleStaffPage
context={readContext}
result={result as HousekeepingResult<PeopleStaffQueryData>}
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<unknown>)
: 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(
<PeopleUserDetailPage
context={modReadContext}
result={ok({ kind: "user", user: redacted }, correlationId)}
/>,
);
expect(modHtml).not.toContain("[email protected]");
expect(modHtml).not.toContain("198.51.100.7");
expect(modHtml).not.toContain("people.user.update");
const editorHtml = renderToStaticMarkup(
<PeopleUserDetailPage
context={capabilityContext([PERMS.USERS_VIEW, PERMS.USERS_EDIT])}
result={ok({ kind: "user", user: detail }, correlationId)}
/>,
);
expect(editorHtml).toContain('href="/ase/people/users/7/edit"');
expect(editorHtml).toContain("people.user.update");
expect(editorHtml).not.toContain("/admin");
});
});
@@ -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<PeopleStaffQueryData>;
readonly partialDependencies?: readonly string[];
}
export function PeopleStaffPage({
context,
result,
partialDependencies,
}: PeopleStaffPageProps) {
return (
<PeoplePageFrame
title="Staff"
description="Review staff applications and team definitions."
result={result}
partialDependencies={partialDependencies}
isEmpty={(data) => data.page.items.length === 0}
>
{(data) =>
data.kind === "applications" ? (
<ul className="space-y-2">
{data.page.items.map((application) => (
<li
key={application.id}
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
>
<h2>{application.username ?? `User #${application.userId}`}</h2>
<p>{application.content}</p>
{context.has(PERMS.USERS_EDIT) ? (
<span data-housekeeping-command="people.application.decide">
Dismiss application
</span>
) : null}
</li>
))}
</ul>
) : data.kind === "teams" ? (
<ul className="space-y-2">
{data.page.items.map((team) => (
<li
key={team.id}
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
>
<h2>{team.name}</h2>
<p>{team.jobDescription ?? "No job description"}</p>
{context.has(PERMS.USERS_EDIT) ? (
<span data-housekeeping-command="people.team.change">
Team controls
</span>
) : null}
</li>
))}
</ul>
) : null
}
</PeoplePageFrame>
);
}
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 <PeopleStaffPage context={input.context} result={result} />;
}
@@ -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<PeopleUsersQueryData>;
readonly partialDependencies?: readonly string[];
}
export function PeopleUserDetailPage({
context,
result,
partialDependencies,
}: PeopleUserDetailPageProps) {
return (
<PeoplePageFrame
title="User detail"
description="Review account state, permissions, and sanctions."
result={result}
partialDependencies={partialDependencies}
isEmpty={() => false}
>
{(data) =>
data.kind === "user" ? (
<div className="grid gap-4 lg:grid-cols-2">
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
<div className="flex items-center justify-between gap-3">
<h2 className="font-medium text-[var(--admin-text)]">
{data.user.username}
</h2>
{context.has(PERMS.USERS_EDIT) ? (
<a
href={`${data.user.href}/edit`}
data-housekeeping-command="people.user.update"
>
Edit
</a>
) : null}
</div>
<dl className="mt-3 grid grid-cols-2 gap-2 text-sm text-[var(--admin-text-muted)]">
<dt>Rank</dt>
<dd>{data.user.permission.rankName}</dd>
<dt>Status</dt>
<dd>{data.user.online ? "Online" : "Offline"}</dd>
<dt>Motto</dt>
<dd>{data.user.motto || "None"}</dd>
{data.user.mail !== null ? (
<>
<dt>Email</dt>
<dd>{data.user.mail}</dd>
</>
) : null}
{data.user.ipCurrent !== null ? (
<>
<dt>Current IP</dt>
<dd>{data.user.ipCurrent}</dd>
</>
) : null}
</dl>
<div className="mt-4 flex flex-wrap gap-2 text-xs">
{context.has(PERMS.USERS_BAN) ? (
<span data-housekeeping-command="people.user.ban">Ban</span>
) : null}
{context.has(PERMS.USERS_EDIT) ? (
<span data-housekeeping-command="people.user.disconnect">
Disconnect
</span>
) : null}
{context.has(PERMS.USERS_RESET_PASSWORD) ? (
<span data-housekeeping-command="people.user.reset-password">
Reset password
</span>
) : null}
</div>
</section>
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
<h2 className="font-medium text-[var(--admin-text)]">
Sanctions
</h2>
{data.user.sanctions.length === 0 ? (
<p
data-housekeeping-state="empty"
className="mt-3 text-sm text-[var(--admin-text-muted)]"
>
No sanctions recorded.
</p>
) : (
<ul className="mt-3 space-y-2 text-sm">
{data.user.sanctions.map((sanction) => (
<li key={sanction.id}>
{sanction.kind}: {sanction.reason}
</li>
))}
</ul>
)}
</section>
</div>
) : null
}
</PeoplePageFrame>
);
}
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 <PeopleUserDetailPage context={input.context} result={result} />;
}
@@ -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<PeopleUsersQueryData>;
readonly partialDependencies?: readonly string[];
}
export function PeopleUserEditPage({
context: _context,
result,
partialDependencies,
}: PeopleUserEditPageProps) {
return (
<PeoplePageFrame
title="Edit user"
description="Update bounded account fields through an audited command."
result={result}
partialDependencies={partialDependencies}
isEmpty={() => false}
>
{(data) =>
data.kind === "user" ? (
<form
data-housekeeping-command="people.user.update"
className="space-y-4 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
>
<input type="hidden" name="userId" value={data.user.id} />
<label className="block text-sm">
Username
<input
name="username"
defaultValue={data.user.username}
maxLength={20}
className="mt-1 block w-full"
/>
</label>
{data.user.mail !== null ? (
<label className="block text-sm">
Email
<input
name="mail"
type="email"
defaultValue={data.user.mail}
className="mt-1 block w-full"
/>
</label>
) : null}
<label className="block text-sm">
Motto
<input
name="motto"
defaultValue={data.user.motto}
maxLength={127}
className="mt-1 block w-full"
/>
</label>
<label className="block text-sm">
Rank
<select
name="rank"
defaultValue={data.user.rank}
className="mt-1 block w-full"
>
{data.user.permission.ranks.map((rank) => (
<option key={rank.id} value={rank.id}>
{rank.name}
</option>
))}
</select>
</label>
<button type="submit">Save user</button>
</form>
) : null
}
</PeoplePageFrame>
);
}
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 <PeopleUserEditPage context={input.context} result={result} />;
}
@@ -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<PeopleUsersQueryData>;
readonly partialDependencies?: readonly string[];
}
export function PeopleUsersPage({
context,
result,
partialDependencies,
}: PeopleUsersPageProps) {
return (
<PeoplePageFrame
title="Users"
description="Search accounts and open the canonical People workflow."
result={result}
partialDependencies={partialDependencies}
isEmpty={(data) => data.kind === "users" && data.page.items.length === 0}
>
{(data) =>
data.kind === "users" ? (
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
<p className="text-sm text-[var(--admin-text-muted)]">
{data.page.total} matching users
</p>
<ul className="mt-3 divide-y divide-[var(--admin-border)]">
{data.page.items.map((user) => (
<li
key={user.id}
className="flex flex-wrap items-center gap-3 py-3 text-sm"
>
<a
className="font-medium text-[var(--admin-text)]"
href={user.href}
>
{user.username}
</a>
<span className="text-[var(--admin-text-muted)]">
Rank {user.rank}
</span>
<span className="text-[var(--admin-text-muted)]">
{user.online ? "Online" : "Offline"}
</span>
{user.mail !== null ? <span>{user.mail}</span> : null}
{user.ipCurrent !== null ? (
<span>{user.ipCurrent}</span>
) : null}
{context.has(PERMS.USERS_EDIT) ? (
<a
href={`${user.href}/edit`}
data-housekeeping-command="people.user.update"
>
Edit
</a>
) : null}
{context.has(PERMS.USERS_BAN) ? (
<span data-housekeeping-command="people.user.ban">
Ban controls available
</span>
) : null}
</li>
))}
</ul>
</section>
) : null
}
</PeoplePageFrame>
);
}
export async function renderPeopleUsersPage(input: HousekeepingPageInput) {
const result = await peopleUsersQuery.run(input.context, {
routeId: "people.users.list",
list: {},
});
return <PeopleUsersPage context={input.context} result={result} />;
}
@@ -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) }),
),
);
@@ -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));
@@ -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<typeof import("@/lib/db")>();
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<typeof import("@/lib/services/audit")>()),
logAudit: audit,
}));
vi.mock("@/lib/services/rcon", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/services/rcon")>()),
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: "[email protected]",
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",
});
});
});
@@ -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<typeof import("@/lib/db")>();
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<typeof import("@/lib/services/audit")>()),
logAudit: audit,
}));
vi.mock("@/lib/services/rcon", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/services/rcon")>()),
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: "[email protected]",
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");
});
});
@@ -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<Record<string, unknown>> | null;
readonly after: Readonly<Record<string, unknown>> | null;
readonly output?: Readonly<Record<string, unknown>>;
}
export interface PeopleMutationContext {
readonly capability: HousekeepingCapabilityContext;
readonly correlationId: string;
}
export interface PeopleMutationAdapter {
execute(
operation: PeopleMutationOperation,
input: unknown,
context: PeopleMutationContext,
): Promise<PeopleMutationSnapshot>;
}
export interface PeopleMutationService {
execute(
context: PeopleMutationContext,
operation: PeopleMutationOperation,
input: unknown,
): Promise<HousekeepingResult<PeopleMutationSnapshot>>;
}
class PeopleMutationFailure extends Error {
constructor(
readonly code: HousekeepingErrorCode,
readonly messageKey: string,
readonly fieldErrors?: Readonly<Record<string, readonly string[]>>,
) {
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<string, unknown> {
if (typeof input !== "object" || input === null || Array.isArray(input)) {
throw new PeopleMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
return input as Record<string, unknown>;
}
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<void> {
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<TargetUser> {
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<void> {
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<PeopleMutationOperation, `user.${string}`>,
input: unknown,
context: PeopleMutationContext,
): Promise<PeopleMutationSnapshot> {
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<PeopleMutationOperation, `users.${string}`>,
input: unknown,
context: PeopleMutationContext,
): Promise<PeopleMutationSnapshot> {
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<PeopleMutationSnapshot> {
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<PeopleMutationSnapshot> {
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<PeopleMutationSnapshot> {
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<PeopleMutationSnapshot> {
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<void> {
await db
.insert(WebsiteSetting)
.values({ key, value, comment })
.onDuplicateKeyUpdate({ set: { value } });
}
async function executeVpnConfiguration(
input: unknown,
context: PeopleMutationContext,
): Promise<PeopleMutationSnapshot> {
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<PeopleMutationSnapshot> {
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<PeopleMutationOperation, `user.${string}`>,
input,
context,
);
}
if (operation.startsWith("users.")) {
return executeBulkMutation(
operation as Extract<PeopleMutationOperation, `users.${string}`>,
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,
);
@@ -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",
@@ -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,
);
@@ -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<string, ReadonlySet<string>>([
const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
[
"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<string, ReadonlySet<string>>([
[
"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")
@@ -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([]);
@@ -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({
@@ -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,
]);
});
});
+2 -1
View File
@@ -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]);
+17
View File
@@ -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",
+17
View File
@@ -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",