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,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]);