fix(housekeeping): harden people account workflows

This commit is contained in:
Simo committed 2026-08-29 13:13:04 +02:00
1 parent e1b31ff773
commit 25b76437ff
41 files changed
+2657 -838

No files matched your search

@@ -153,3 +153,56 @@ 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. 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. No database operation, deployment, push, or pull-request update was performed.
## Official review fix round 1
The official review reported 0 Critical and 5 Important findings. This round addresses the five findings without widening the Task 12 route catalog or changing legacy redirects, safe-action response shapes, or cutover behavior.
### RED evidence
```text
Production server authority: auth resolver was called 0 times at the public service boundary (1 failing regression).
Canonical external audit: 3 failing regressions for missing durable intent/outcome behavior.
Transactional audit: expected one transaction and observed zero (1 failing regression).
Legacy/production workflows: new production matrix initially exposed bulk truncation/deduplication, trade-lock hierarchy/state, missing StaffActivities, and missing word-filter refresh behavior.
Primary workflows: page suite started at 7 passed / 2 failed (no executable command form and no bounded URL parser); preview contract started at 61 passed / 3 failed (searchParams/loading propagation).
Import boundary after real forms: test:housekeeping reached 481 passed / 1 failed, then the focused boundary exposed one exact page-state -> People models edge (22 passed / 1 failed).
```
### GREEN implementation
- Production People services now rehydrate `getHousekeepingCapabilityContext()` on every public mutation. Invocation data can carry correlation and an expected actor only; it cannot synthesize permissions. The production adapter stays private, while test factories inject an authority resolver.
- Pure database mutations write their canonical before/after audit evidence in the same transaction. Mixed database/RCON/cache work writes sanitized intent first and a correlated success, failure, or partial outcome afterward. Audit-outcome persistence errors retain truthful completed/partial state, and legacy wrappers preserve their observable behavior.
- Ban/unban use observed active-ban state; trade-lock uses observed sanction/settings state. Passwords, hashes, API keys, and secrets are excluded from canonical evidence.
- Shared legacy bulk paths preserve original order, duplicates, totals, and iteration with no service-side 100-item cap. The <=100 bound remains in command schemas. Trade lock has no invented hierarchy gate and its missing-user wrapper message remains exactly `User not found`.
- Original `StaffActivities` side effects are retained for bulk ban/unban/currency/badge, guild disband, VPN, and trade lock. Missing word-filter deletion still reloads local cache, sends RCON refresh, and returns legacy success.
- The nine registered pages now expose capability-gated, accessible command forms backed by `executeHousekeepingCommand`; no inert command spans remain. Edit submits a mutation, list inputs come from bounded URL search parameters, and the dynamic preview route passes them through. Atomic Task 11 queries keep their fail-closed contracts; the impossible synthetic partial state was removed. A real Next loading route was added.
- The foundation contract allows only the exact same-domain edges required here: each People page to the shared People command form, the form to the single housekeeping command action, and page-state to the People `ListInput` model. No wildcard or prefix relaxation was introduced.
### Final verification after review fixes
```text
Focused wrapper/command/page/service/route/auth/audit/staff-smoke matrix
Test Files 24 passed (24)
Tests 187 passed (187)
pnpm test:housekeeping
Test Files 58 passed (58)
Tests 482 passed (482)
pnpm test
Test Files 206 passed | 3 skipped (209)
Tests 1321 passed | 5 skipped (1326)
pnpm typecheck
tsc --noEmit
Exit 0
pnpm exec biome check --formatter-enabled=false <40 exact changed Task 12 source files>
Checked 40 files. No fixes applied.
git diff --check e1b31ff7738eb5cc59e7765c8ed7290d62130972 --
Exit 0
```
The approved Node engine warning remains: the repository requests Node `>=26.8.1 <27`, while the host runs Node `v26.7.0` with pnpm `11.24.0`. No database operation, deployment, push, or pull-request update was performed.
+2 -6
View File
@@ -2,7 +2,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { import {
createLegacyPeopleMutationContext, createPeopleMutationInvocation,
peopleMutationService, peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations"; } from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
@@ -18,11 +18,7 @@ export async function dismissApplication(formData: FormData): Promise<void> {
if (!Number.isSafeInteger(applicationId) || applicationId <= 0) return; if (!Number.isSafeInteger(applicationId) || applicationId <= 0) return;
await peopleMutationService.execute( await peopleMutationService.execute(
createLegacyPeopleMutationContext( createPeopleMutationInvocation(staff, createCorrelationId()),
staff,
PERMS.USERS_EDIT,
createCorrelationId(),
),
"application.decide", "application.decide",
{ applicationId, decision: "dismiss" }, { applicationId, decision: "dismiss" },
); );
+5 -7
View File
@@ -5,13 +5,11 @@ import { disbandGuild } from "./admin-guilds";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() })); const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({ vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn( createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
(staff, permission, correlationId) => ({ expectedActorId: staff.id,
staff, correlationId,
permission, legacy: true,
correlationId, })),
}),
),
peopleMutationService: { execute }, peopleMutationService: { execute },
})); }));
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() })); vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
+2 -6
View File
@@ -2,7 +2,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { import {
createLegacyPeopleMutationContext, createPeopleMutationInvocation,
peopleMutationService, peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations"; } from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
@@ -16,11 +16,7 @@ export async function disbandGuild(formData: FormData): Promise<void> {
if (!Number.isSafeInteger(guildId) || guildId <= 0) return; if (!Number.isSafeInteger(guildId) || guildId <= 0) return;
const result = await peopleMutationService.execute( const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext( createPeopleMutationInvocation(staff, createCorrelationId()),
staff,
PERMS.USERS_EDIT,
createCorrelationId(),
),
"guild.disband", "guild.disband",
{ guildId }, { guildId },
); );
+5 -7
View File
@@ -10,13 +10,11 @@ import {
const { execute } = vi.hoisted(() => ({ execute: vi.fn() })); const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({ vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn( createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
(staff, permission, correlationId) => ({ expectedActorId: staff.id,
staff, correlationId,
permission, legacy: true,
correlationId, })),
}),
),
peopleMutationService: { execute }, peopleMutationService: { execute },
})); }));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
+2 -6
View File
@@ -2,7 +2,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { import {
createLegacyPeopleMutationContext, createPeopleMutationInvocation,
peopleMutationService, peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations"; } from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
@@ -37,11 +37,7 @@ async function run(
const id = "id" in input ? input.id : undefined; const id = "id" in input ? input.id : undefined;
if (!adding && !(Number.isSafeInteger(id) && Number(id) > 0)) return; if (!adding && !(Number.isSafeInteger(id) && Number(id) > 0)) return;
const result = await peopleMutationService.execute( const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext( createPeopleMutationInvocation(staff, createCorrelationId()),
staff,
PERMS.SETTINGS_EDIT,
createCorrelationId(),
),
"ip.action", "ip.action",
input, input,
); );
+5 -7
View File
@@ -5,13 +5,11 @@ import { createTeam, deleteTeam } from "./admin-teams";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() })); const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({ vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn( createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
(staff, permission, correlationId) => ({ expectedActorId: staff.id,
staff, correlationId,
permission, legacy: true,
correlationId, })),
}),
),
peopleMutationService: { execute }, peopleMutationService: { execute },
})); }));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
+3 -11
View File
@@ -2,7 +2,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { import {
createLegacyPeopleMutationContext, createPeopleMutationInvocation,
peopleMutationService, peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations"; } from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
@@ -20,11 +20,7 @@ export async function createTeam(formData: FormData): Promise<void> {
const rankName = text(formData, "rankName"); const rankName = text(formData, "rankName");
if (!rankName) return; if (!rankName) return;
const result = await peopleMutationService.execute( const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext( createPeopleMutationInvocation(staff, createCorrelationId()),
staff,
PERMS.USERS_EDIT,
createCorrelationId(),
),
"team.change", "team.change",
{ {
action: "create", action: "create",
@@ -44,11 +40,7 @@ export async function deleteTeam(formData: FormData): Promise<void> {
const teamId = Number(formData.get("id")); const teamId = Number(formData.get("id"));
if (!Number.isSafeInteger(teamId) || teamId <= 0) return; if (!Number.isSafeInteger(teamId) || teamId <= 0) return;
const result = await peopleMutationService.execute( const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext( createPeopleMutationInvocation(staff, createCorrelationId()),
staff,
PERMS.USERS_EDIT,
createCorrelationId(),
),
"team.change", "team.change",
{ action: "delete", teamId }, { action: "delete", teamId },
); );
+5 -7
View File
@@ -6,13 +6,11 @@ import { saveVpn } from "./admin-vpn";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() })); const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({ vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn( createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
(staff, permission, correlationId) => ({ expectedActorId: staff.id,
staff, correlationId,
permission, legacy: true,
correlationId, })),
}),
),
peopleMutationService: { execute }, peopleMutationService: { execute },
})); }));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
+2 -6
View File
@@ -3,7 +3,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { import {
createLegacyPeopleMutationContext, createPeopleMutationInvocation,
peopleMutationService, peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations"; } from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
@@ -20,11 +20,7 @@ export async function saveVpn(formData: FormData): Promise<void> {
.toLowerCase(); .toLowerCase();
const provider = ALLOWED_PROVIDERS.has(rawProvider) ? rawProvider : "none"; const provider = ALLOWED_PROVIDERS.has(rawProvider) ? rawProvider : "none";
const result = await peopleMutationService.execute( const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext( createPeopleMutationInvocation(staff, createCorrelationId()),
staff,
PERMS.SETTINGS_EDIT,
createCorrelationId(),
),
"vpn.configure", "vpn.configure",
{ {
enabled: String(formData.get("vpn_block_enabled") ?? "").trim() !== "", enabled: String(formData.get("vpn_block_enabled") ?? "").trim() !== "",
+3 -11
View File
@@ -2,7 +2,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { import {
createLegacyPeopleMutationContext, createPeopleMutationInvocation,
peopleMutationService, peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations"; } from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
@@ -24,11 +24,7 @@ export async function addWord(input: {
.slice(0, 255); .slice(0, 255);
if (!word) return actionError("Word is required"); if (!word) return actionError("Word is required");
const result = await peopleMutationService.execute( const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext( createPeopleMutationInvocation(staff, createCorrelationId()),
staff,
PERMS.WORDFILTER_EDIT,
createCorrelationId(),
),
"word-filter.update", "word-filter.update",
{ action: "add", word }, { action: "add", word },
); );
@@ -44,11 +40,7 @@ export async function deleteWord(input: { id: string }): Promise<ActionResult> {
if (!Number.isSafeInteger(id) || id <= 0) if (!Number.isSafeInteger(id) || id <= 0)
return actionError("Missing word id"); return actionError("Missing word id");
const result = await peopleMutationService.execute( const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext( createPeopleMutationInvocation(staff, createCorrelationId()),
staff,
PERMS.WORDFILTER_EDIT,
createCorrelationId(),
),
"word-filter.update", "word-filter.update",
{ action: "delete", id }, { action: "delete", id },
); );
+5 -7
View File
@@ -3,13 +3,11 @@ import { requirePermission } from "@/lib/admin/guard";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() })); const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({ vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn( createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
(staff, permission, correlationId) => ({ expectedActorId: staff.id,
staff, correlationId,
permission, legacy: true,
correlationId, })),
}),
),
peopleMutationService: { execute }, peopleMutationService: { execute },
})); }));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
+6 -8
View File
@@ -10,13 +10,11 @@ import {
const { execute } = vi.hoisted(() => ({ execute: vi.fn() })); const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({ vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn( createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
(staff, permission, correlationId) => ({ expectedActorId: staff.id,
staff, correlationId,
permission, legacy: true,
correlationId, })),
}),
),
peopleMutationService: { execute }, peopleMutationService: { execute },
})); }));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
@@ -88,7 +86,7 @@ describe("legacy bulk user wrappers", () => {
data: { userId: 9, untilUnix: 1234 }, data: { userId: 9, untilUnix: 1234 },
}); });
expect(execute).toHaveBeenLastCalledWith( expect(execute).toHaveBeenLastCalledWith(
expect.objectContaining({ permission: "admin.users.edit" }), expect.objectContaining({ expectedActorId: 1 }),
"user.trade-lock", "user.trade-lock",
{ userId: 9, untilUnix: 1234 }, { userId: 9, untilUnix: 1234 },
); );
+11 -7
View File
@@ -2,7 +2,7 @@
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { import {
createLegacyPeopleMutationContext, createPeopleMutationInvocation,
peopleMutationService, peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations"; } from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
@@ -27,11 +27,7 @@ async function executeLegacy(
input: unknown, input: unknown,
) { ) {
return peopleMutationService.execute( return peopleMutationService.execute(
createLegacyPeopleMutationContext( createPeopleMutationInvocation(staff, createCorrelationId()),
staff,
PERMS.USERS_EDIT,
createCorrelationId(),
),
operation, operation,
input, input,
); );
@@ -259,6 +255,14 @@ export async function setTradeLock({
userId, userId,
untilUnix: until, untilUnix: until,
}); });
if (!result.ok) return { ok: false, error: "Trade lock update failed" }; if (!result.ok) {
return {
ok: false,
error:
result.error.code === "NOT_FOUND"
? "User not found"
: "Trade lock update failed",
};
}
return { ok: true, data: { userId, untilUnix: until } }; return { ok: true, data: { userId, untilUnix: until } };
} }
+13 -14
View File
@@ -8,13 +8,11 @@ const { execute, staff } = vi.hoisted(() => ({
})); }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({ vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn( createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
(actor, permission, correlationId) => ({ expectedActorId: staff.id,
actor, correlationId,
permission, legacy: true,
correlationId, })),
}),
),
peopleMutationService: { execute }, peopleMutationService: { execute },
})); }));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
@@ -98,12 +96,13 @@ describe("legacy user safe-action wrappers", () => {
}> }>
)({ userId: 7 }); )({ userId: 7 });
expect( expect(execute.mock.calls.map((call) => call[1])).toEqual([
execute.mock.calls.map((call) => [call[0].permission, call[1]]), "user.update",
).toEqual([ "user.ban",
["admin.users.edit", "user.update"], "user.reset-password",
["admin.users.ban", "user.ban"], ]);
["admin.users.reset_password", "user.reset-password"], expect(execute.mock.calls.map((call) => call[0].expectedActorId)).toEqual([
1, 1, 1,
]); ]);
expect(reset.data.newPassword).toBe("temporary-password"); expect(reset.data.newPassword).toBe("temporary-password");
}); });
@@ -113,7 +112,7 @@ describe("legacy application and word-filter wrappers", () => {
it("keeps tolerant application dismissal and /admin revalidation", async () => { it("keeps tolerant application dismissal and /admin revalidation", async () => {
await dismissApplication(form({ id: "9" })); await dismissApplication(form({ id: "9" }));
expect(execute).toHaveBeenCalledWith( expect(execute).toHaveBeenCalledWith(
expect.objectContaining({ permission: "admin.users.edit" }), expect.objectContaining({ expectedActorId: 1 }),
"application.decide", "application.decide",
{ applicationId: 9, decision: "dismiss" }, { applicationId: 9, decision: "dismiss" },
); );
+5 -7
View File
@@ -7,13 +7,11 @@ const { execute, staff } = vi.hoisted(() => ({
})); }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({ vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn( createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
(actor, permission, correlationId) => ({ expectedActorId: staff.id,
actor, correlationId,
permission, legacy: true,
correlationId, })),
}),
),
peopleMutationService: { execute }, peopleMutationService: { execute },
})); }));
vi.mock("@/lib/admin/guard", () => ({ vi.mock("@/lib/admin/guard", () => ({
+11 -21
View File
@@ -3,7 +3,7 @@
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { import {
createLegacyPeopleMutationContext, createPeopleMutationInvocation,
type PeopleMutationOperation, type PeopleMutationOperation,
peopleMutationService, peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations"; } from "@/features/housekeeping/domains/people/services/mutations";
@@ -116,16 +116,11 @@ const legacyMessages: Partial<Record<PeopleMutationOperation, string>> = {
async function executeLegacy( async function executeLegacy(
ctx: { session: { user: { id: number; username: string; rank: number } } }, ctx: { session: { user: { id: number; username: string; rank: number } } },
permission: string,
operation: PeopleMutationOperation, operation: PeopleMutationOperation,
input: unknown, input: unknown,
) { ) {
const result = await peopleMutationService.execute( const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext( createPeopleMutationInvocation(ctx.session.user, createCorrelationId()),
ctx.session.user,
permission,
createCorrelationId(),
),
operation, operation,
input, input,
); );
@@ -148,7 +143,7 @@ export const updateUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: updateUserInput }, { permission: PERMS.USERS_EDIT, schema: updateUserInput },
async (ctx) => { async (ctx) => {
const { id: userId, ...fields } = ctx.data; const { id: userId, ...fields } = ctx.data;
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.update", { await executeLegacy(ctx, "user.update", {
userId, userId,
fields, fields,
}); });
@@ -159,7 +154,7 @@ export const updateUser = adminAction(
export const banUser = adminAction( export const banUser = adminAction(
{ permission: PERMS.USERS_BAN, schema: banUserSchema }, { permission: PERMS.USERS_BAN, schema: banUserSchema },
async (ctx) => { async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_BAN, "user.ban", ctx.data); await executeLegacy(ctx, "user.ban", ctx.data);
return actionOk(); return actionOk();
}, },
); );
@@ -169,7 +164,7 @@ const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
export const unbanUser = adminAction( export const unbanUser = adminAction(
{ permission: PERMS.USERS_BAN, schema: userIdSchema }, { permission: PERMS.USERS_BAN, schema: userIdSchema },
async (ctx) => { async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_BAN, "user.unban", ctx.data); await executeLegacy(ctx, "user.unban", ctx.data);
return actionOk(); return actionOk();
}, },
); );
@@ -177,12 +172,7 @@ export const unbanUser = adminAction(
export const resetPassword = adminAction( export const resetPassword = adminAction(
{ permission: PERMS.USERS_RESET_PASSWORD, schema: userIdSchema }, { permission: PERMS.USERS_RESET_PASSWORD, schema: userIdSchema },
async (ctx) => { async (ctx) => {
const snapshot = await executeLegacy( const snapshot = await executeLegacy(ctx, "user.reset-password", ctx.data);
ctx,
PERMS.USERS_RESET_PASSWORD,
"user.reset-password",
ctx.data,
);
return actionOk({ return actionOk({
newPassword: String(snapshot.output?.newPassword ?? ""), newPassword: String(snapshot.output?.newPassword ?? ""),
}); });
@@ -192,7 +182,7 @@ export const resetPassword = adminAction(
export const disconnectUser = adminAction( export const disconnectUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: userIdSchema }, { permission: PERMS.USERS_EDIT, schema: userIdSchema },
async (ctx) => { async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.disconnect", ctx.data); await executeLegacy(ctx, "user.disconnect", ctx.data);
return actionOk(); return actionOk();
}, },
); );
@@ -203,7 +193,7 @@ const alertUserSchema = userIdSchema.extend({
export const alertUser = adminAction( export const alertUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema }, { permission: PERMS.USERS_EDIT, schema: alertUserSchema },
async (ctx) => { async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.alert", ctx.data); await executeLegacy(ctx, "user.alert", ctx.data);
return actionOk(); return actionOk();
}, },
); );
@@ -214,7 +204,7 @@ const muteSchema = userIdSchema.extend({
export const muteUser = adminAction( export const muteUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: muteSchema }, { permission: PERMS.USERS_EDIT, schema: muteSchema },
async (ctx) => { async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.mute", ctx.data); await executeLegacy(ctx, "user.mute", ctx.data);
return actionOk(); return actionOk();
}, },
); );
@@ -222,7 +212,7 @@ export const muteUser = adminAction(
export const unmuteUser = adminAction( export const unmuteUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: userIdSchema }, { permission: PERMS.USERS_EDIT, schema: userIdSchema },
async (ctx) => { async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.unmute", ctx.data); await executeLegacy(ctx, "user.unmute", ctx.data);
return actionOk(); return actionOk();
}, },
); );
@@ -233,7 +223,7 @@ const sendCreditsSchema = userIdSchema.extend({
export const sendCredits = adminAction( export const sendCredits = adminAction(
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema }, { permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
async (ctx) => { async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.send-currency", ctx.data); await executeLegacy(ctx, "user.send-currency", ctx.data);
return actionOk(); return actionOk();
}, },
); );
@@ -0,0 +1,13 @@
import { HousekeepingPageState } from "@/features/housekeeping/foundation/page/housekeeping-page-state";
export default function HousekeepingPreviewLoading() {
return (
<div data-housekeeping-state="loading">
<HousekeepingPageState
state="loading"
title="Loading housekeeping workflow"
description="Fetching the latest authorized data."
/>
</div>
);
}
@@ -8,8 +8,10 @@ import { HOUSEKEEPING_ROUTE_HANDLERS } from "@/features/housekeeping/route-handl
export default async function HousekeepingPreviewRoutePage({ export default async function HousekeepingPreviewRoutePage({
params, params,
searchParams,
}: { }: {
params: Promise<{ domain: string; segments?: readonly string[] }>; params: Promise<{ domain: string; segments?: readonly string[] }>;
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) { }) {
const { domain, segments = [] } = await params; const { domain, segments = [] } = await params;
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS); const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
@@ -39,5 +41,9 @@ export default async function HousekeepingPreviewRoutePage({
notFound(); notFound();
} }
return handler.render({ context, match }); return handler.render({
context,
match,
...(searchParams ? { searchParams: await searchParams } : {}),
});
} }
@@ -43,8 +43,8 @@ function communityCommand<I>(
execute: (context, input) => execute: (context, input) =>
service.execute( service.execute(
{ {
capability: context.capability,
correlationId: context.correlationId, correlationId: context.correlationId,
expectedActorId: context.capability.actor.id,
}, },
options.operation, options.operation,
input, input,
@@ -165,12 +165,11 @@ describe("People user commands", () => {
describe("People mutation service boundary", () => { describe("People mutation service boundary", () => {
it("fails closed before the adapter when the exact capability is absent", async () => { it("fails closed before the adapter when the exact capability is absent", async () => {
const execute = vi.fn(async () => ({ before: null, after: null })); const execute = vi.fn(async () => ({ before: null, after: null }));
const service = createPeopleMutationService({ execute }); const service = createPeopleMutationService({ execute }, async () =>
capabilityContext([PERMS.USERS_EDIT]),
);
const result = await service.execute( const result = await service.execute(
{ { expectedActorId: 42, correlationId: "denied-people" },
capability: capabilityContext([PERMS.USERS_EDIT]),
correlationId: "denied-people",
},
"user.ban", "user.ban",
{ userId: 7, reason: "abuse", duration: 0, type: "account" }, { userId: 7, reason: "abuse", duration: 0, type: "account" },
); );
@@ -190,11 +189,11 @@ describe("People mutation service boundary", () => {
after: { rank: 3 }, after: { rank: 3 },
}), }),
}; };
const success = await createPeopleMutationService(snapshotAdapter).execute( const success = await createPeopleMutationService(
{ snapshotAdapter,
capability: capabilityContext([PERMS.USERS_EDIT]), async () => capabilityContext([PERMS.USERS_EDIT]),
correlationId: "snapshot-people", ).execute(
}, { expectedActorId: 42, correlationId: "snapshot-people" },
"user.update", "user.update",
{ userId: 7, fields: { rank: 3 } }, { userId: 7, fields: { rank: 3 } },
); );
@@ -208,11 +207,11 @@ describe("People mutation service boundary", () => {
throw new Error("database password=secret"); throw new Error("database password=secret");
}, },
}; };
const failure = await createPeopleMutationService(failureAdapter).execute( const failure = await createPeopleMutationService(
{ failureAdapter,
capability: capabilityContext([PERMS.USERS_EDIT]), async () => capabilityContext([PERMS.USERS_EDIT]),
correlationId: "failed-people", ).execute(
}, { expectedActorId: 42, correlationId: "failed-people" },
"user.update", "user.update",
{ userId: 7, fields: { motto: "Ready" } }, { userId: 7, fields: { motto: "Ready" } },
); );
@@ -53,8 +53,8 @@ function userCommand<I>(
execute: (context, input) => execute: (context, input) =>
service.execute( service.execute(
{ {
capability: context.capability,
correlationId: context.correlationId, correlationId: context.correlationId,
expectedActorId: context.capability.actor.id,
}, },
options.operation, options.operation,
input, input,
@@ -10,58 +10,71 @@ import {
type PeopleCommunityQueryData, type PeopleCommunityQueryData,
peopleCommunityQuery, peopleCommunityQuery,
} from "../queries/community"; } from "../queries/community";
import { PeoplePageFrame } from "./page-state"; import { PeoplePageFrame, parsePeopleListInput } from "./page-state";
import { PeopleCommandForm } from "./people-command-form";
interface PeopleCommunityPageProps { interface PeopleCommunityPageProps {
readonly context: HousekeepingCapabilityContext; readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<PeopleCommunityQueryData>; readonly result?: HousekeepingResult<PeopleCommunityQueryData>;
readonly partialDependencies?: readonly string[];
} }
function empty(data: PeopleCommunityQueryData) { function empty(data: PeopleCommunityQueryData) {
return data.kind !== "guild" && data.page.items.length === 0; return data.kind !== "guild" && data.page.items.length === 0;
} }
function SearchForm() {
return (
<form method="get" className="flex gap-2">
<input name="search" maxLength={100} aria-label="Search community" />
<button type="submit">Search</button>
</form>
);
}
export function PeopleCommunityPage({ export function PeopleCommunityPage({
context, context,
result, result,
partialDependencies,
}: PeopleCommunityPageProps) { }: PeopleCommunityPageProps) {
return ( return (
<PeoplePageFrame <PeoplePageFrame
title="Community" title="Community"
description="Review online users and guild ownership." description="Review online users and guild ownership."
result={result} result={result}
partialDependencies={partialDependencies}
isEmpty={empty} isEmpty={empty}
> >
{(data) => { {(data) => {
if (data.kind === "online") if (data.kind === "online")
return ( return (
<ul className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"> <div className="space-y-3">
{data.page.items.map((user) => ( <SearchForm />
<li key={user.id}> <ul className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
<a href={user.href}>{user.username}</a> - {user.motto} {data.page.items.map((user) => (
</li> <li key={user.id}>
))} <a href={user.href}>{user.username}</a> - {user.motto}
</ul> </li>
))}
</ul>
</div>
); );
if (data.kind === "guilds") if (data.kind === "guilds")
return ( return (
<ul className="space-y-2"> <div className="space-y-3">
{data.page.items.map((guild) => ( <SearchForm />
<li <ul className="space-y-2">
key={guild.id} {data.page.items.map((guild) => (
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4" <li
> key={guild.id}
<a href={guild.href}>{guild.name}</a> className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
<p>{guild.memberCount} members</p> >
</li> <a href={guild.href}>{guild.name}</a>
))} <p>{guild.memberCount} members</p>
</ul> </li>
))}
</ul>
</div>
); );
return ( return (
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"> <section className="space-y-3 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
<h2>{data.guild.name}</h2> <h2>{data.guild.name}</h2>
<p>{data.guild.description}</p> <p>{data.guild.description}</p>
<ul> <ul>
@@ -72,9 +85,12 @@ export function PeopleCommunityPage({
))} ))}
</ul> </ul>
{context.has(PERMS.USERS_EDIT) ? ( {context.has(PERMS.USERS_EDIT) ? (
<span data-housekeeping-command="people.guild.disband"> <PeopleCommandForm
Disband guild commandId="people.guild.disband"
</span> buttonLabel="Disband guild"
input={{ guildId: data.guild.id }}
requiresReason
/>
) : null} ) : null}
</section> </section>
); );
@@ -101,7 +117,10 @@ export async function renderPeopleCommunityPage(input: HousekeepingPageInput) {
})() })()
: routeId === "people.community.online" || : routeId === "people.community.online" ||
routeId === "people.community.guilds" routeId === "people.community.guilds"
? peopleCommunityQuery.run(input.context, { routeId, list: {} }) ? peopleCommunityQuery.run(input.context, {
routeId,
list: parsePeopleListInput(input.searchParams ?? {}),
})
: Promise.resolve( : Promise.resolve(
fail( fail(
"NOT_FOUND", "NOT_FOUND",
@@ -4,53 +4,60 @@ import type {
} from "../../../foundation/contracts"; } from "../../../foundation/contracts";
import type { HousekeepingPageInput } from "../../../route-handlers"; import type { HousekeepingPageInput } from "../../../route-handlers";
import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users"; import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users";
import { PeoplePageFrame } from "./page-state"; import { PeoplePageFrame, parsePeopleListInput } from "./page-state";
interface PeopleMultiAccountsPageProps { interface PeopleMultiAccountsPageProps {
readonly context: HousekeepingCapabilityContext; readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<PeopleUsersQueryData>; readonly result?: HousekeepingResult<PeopleUsersQueryData>;
readonly partialDependencies?: readonly string[];
} }
export function PeopleMultiAccountsPage({ export function PeopleMultiAccountsPage({
context: _context, context: _context,
result, result,
partialDependencies,
}: PeopleMultiAccountsPageProps) { }: PeopleMultiAccountsPageProps) {
return ( return (
<PeoplePageFrame <PeoplePageFrame
title="Multi-account clusters" title="Multi-account clusters"
description="Review bounded account clusters grouped by current IP." description="Review bounded account clusters grouped by current IP."
result={result} result={result}
partialDependencies={partialDependencies}
isEmpty={(data) => isEmpty={(data) =>
data.kind === "multi-accounts" && data.page.items.length === 0 data.kind === "multi-accounts" && data.page.items.length === 0
} }
> >
{(data) => {(data) =>
data.kind === "multi-accounts" ? ( data.kind === "multi-accounts" ? (
<ul className="space-y-3"> <div className="space-y-3">
{data.page.items.map((cluster) => ( <form method="get" className="flex gap-2">
<li <input
key={cluster.key} name="search"
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4" maxLength={100}
> aria-label="Search IP clusters"
<h2 className="font-medium text-[var(--admin-text)]"> />
{cluster.key} <button type="submit">Search</button>
</h2> </form>
<p className="text-sm text-[var(--admin-text-muted)]"> <ul className="space-y-3">
{cluster.accountCount} accounts {data.page.items.map((cluster) => (
</p> <li
<ul className="mt-2 flex flex-wrap gap-3 text-sm"> key={cluster.key}
{cluster.accounts.map((account) => ( className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
<li key={account.id}> >
<a href={account.href}>{account.username}</a> <h2 className="font-medium text-[var(--admin-text)]">
</li> {cluster.key}
))} </h2>
</ul> <p className="text-sm text-[var(--admin-text-muted)]">
</li> {cluster.accountCount} accounts
))} </p>
</ul> <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>
</div>
) : null ) : null
} }
</PeoplePageFrame> </PeoplePageFrame>
@@ -62,7 +69,7 @@ export async function renderPeopleMultiAccountsPage(
) { ) {
const result = await peopleUsersQuery.run(input.context, { const result = await peopleUsersQuery.run(input.context, {
routeId: "people.users.multi-accounts", routeId: "people.users.multi-accounts",
list: {}, list: parsePeopleListInput(input.searchParams ?? {}),
}); });
return <PeopleMultiAccountsPage context={input.context} result={result} />; return <PeopleMultiAccountsPage context={input.context} result={result} />;
} }
@@ -2,12 +2,60 @@ import type { ReactNode } from "react";
import type { HousekeepingResult } from "../../../foundation/contracts"; import type { HousekeepingResult } from "../../../foundation/contracts";
import { HousekeepingPageShell } from "../../../foundation/page/housekeeping-page-shell"; import { HousekeepingPageShell } from "../../../foundation/page/housekeeping-page-shell";
import { HousekeepingPageState } from "../../../foundation/page/housekeeping-page-state"; import { HousekeepingPageState } from "../../../foundation/page/housekeeping-page-state";
import type { ListInput } from "../models";
export type PeopleSearchParams = Readonly<
Record<string, string | readonly string[] | undefined>
>;
function firstParam(
params: PeopleSearchParams,
key: string,
): string | undefined {
const value = params[key];
return typeof value === "string" ? value : value?.[0];
}
function boundedInteger(
value: string | undefined,
fallback: number,
minimum: number,
maximum: number,
): number {
const parsed = Number(value);
return Number.isSafeInteger(parsed)
? Math.min(maximum, Math.max(minimum, parsed))
: fallback;
}
export function parsePeopleListInput(params: PeopleSearchParams): ListInput {
const pageSize = boundedInteger(firstParam(params, "pageSize"), 20, 1, 100);
const page = boundedInteger(firstParam(params, "page"), 1, 1, 1_000_001);
const search = Array.from(firstParam(params, "search") ?? "")
.filter((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint >= 32 && codePoint !== 127;
})
.join("")
.normalize("NFC")
.trim()
.slice(0, 100);
return {
search,
pageSize,
offset: Math.min(100_000, (page - 1) * pageSize),
sort: (firstParam(params, "sort") ?? "")
.normalize("NFC")
.trim()
.slice(0, 32),
order: firstParam(params, "direction") === "desc" ? "desc" : "asc",
};
}
interface PeoplePageFrameProps<T> { interface PeoplePageFrameProps<T> {
readonly title: string; readonly title: string;
readonly description: string; readonly description: string;
readonly result?: HousekeepingResult<T>; readonly result?: HousekeepingResult<T>;
readonly partialDependencies?: readonly string[];
readonly isEmpty: (data: T) => boolean; readonly isEmpty: (data: T) => boolean;
readonly children: (data: T) => ReactNode; readonly children: (data: T) => ReactNode;
} }
@@ -32,7 +80,6 @@ export function PeoplePageFrame<T>({
title, title,
description, description,
result, result,
partialDependencies = [],
isEmpty, isEmpty,
children, children,
}: PeoplePageFrameProps<T>) { }: PeoplePageFrameProps<T>) {
@@ -76,20 +123,7 @@ export function PeoplePageFrame<T>({
); );
} else { } else {
body = ( body = (
<div <div data-housekeeping-state="ready" className="space-y-4">
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)} {children(result.data)}
</div> </div>
); );
@@ -0,0 +1,180 @@
"use client";
import { useActionState } from "react";
import type { HousekeepingResult } from "../../../foundation/contracts";
export interface PeopleCommandField {
readonly name: string;
readonly label: string;
readonly type: "text" | "number" | "number-list" | "checkbox" | "select";
readonly required?: boolean;
readonly min?: number;
readonly max?: number;
readonly maxLength?: number;
readonly defaultValue?: string | number;
readonly options?: readonly Readonly<{
value: string | number;
label: string;
}>[];
}
interface PeopleCommandFormProps {
readonly commandId: string;
readonly buttonLabel: string;
readonly input: Readonly<Record<string, unknown>>;
readonly fields?: readonly PeopleCommandField[];
readonly requiresReason?: boolean;
readonly includeReasonInInput?: boolean;
readonly nestFields?: boolean;
}
function parseField(field: PeopleCommandField, formData: FormData): unknown {
if (field.type === "checkbox") return formData.get(field.name) === "on";
const raw = String(formData.get(field.name) ?? "")
.normalize("NFC")
.trim();
if (field.type === "number") {
const value = Number(raw);
if (!Number.isSafeInteger(value)) return 0;
return Math.min(field.max ?? value, Math.max(field.min ?? value, value));
}
if (field.type === "select") {
const selected = field.options?.find(
(option) => String(option.value) === raw,
)?.value;
return typeof selected === "number" ? selected : raw.slice(0, 500);
}
if (field.type === "number-list") {
return raw
.split(/[\s,]+/u)
.filter(Boolean)
.slice(0, 100)
.map(Number)
.filter((value) => Number.isSafeInteger(value) && value > 0);
}
return raw.slice(0, field.maxLength ?? 500);
}
const initialState: HousekeepingResult<unknown> | null = null;
export function PeopleCommandForm({
commandId,
buttonLabel,
input,
fields = [],
requiresReason = false,
includeReasonInInput = false,
nestFields = false,
}: PeopleCommandFormProps) {
const [result, submit, pending] = useActionState(
async (
_previous: HousekeepingResult<unknown> | null,
formData: FormData,
) => {
const dynamic = Object.fromEntries(
fields.map((field) => [field.name, parseField(field, formData)]),
);
const reason = String(formData.get("reason") ?? "")
.normalize("NFC")
.trim()
.slice(0, 1000);
const commandInput = nestFields
? { ...input, fields: dynamic }
: {
...input,
...dynamic,
...(includeReasonInInput ? { reason } : {}),
};
const { executeHousekeepingCommand } = await import(
"@/actions/housekeeping-command"
);
return executeHousekeepingCommand({
commandId,
input: commandInput,
...(requiresReason ? { reason } : {}),
});
},
initialState,
);
return (
<form
action={submit}
data-housekeeping-command={commandId}
className="space-y-3 rounded border border-[var(--admin-border)] p-3"
>
{fields.map((field) => (
<label
key={field.name}
htmlFor={`${commandId}-${field.name}`}
className="block text-sm"
>
{field.type === "checkbox" ? (
<>
<input
id={`${commandId}-${field.name}`}
name={field.name}
type="checkbox"
/>{" "}
{field.label}
</>
) : field.type === "select" ? (
<>
{field.label}
<select
id={`${commandId}-${field.name}`}
name={field.name}
defaultValue={field.defaultValue}
required={field.required}
className="mt-1 block w-full"
>
{field.options?.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</>
) : (
<>
{field.label}
<input
id={`${commandId}-${field.name}`}
name={field.name}
type={field.type === "number" ? "number" : "text"}
defaultValue={field.defaultValue}
required={field.required}
min={field.min}
max={field.max}
maxLength={field.maxLength}
className="mt-1 block w-full"
/>
</>
)}
</label>
))}
{requiresReason ? (
<label htmlFor={`${commandId}-reason`} className="block text-sm">
Reason
<textarea
id={`${commandId}-reason`}
name="reason"
required
maxLength={1000}
className="mt-1 block w-full"
/>
</label>
) : null}
<button type="submit" disabled={pending}>
{pending ? "Working..." : buttonLabel}
</button>
{result ? (
<p role="status" className="text-xs text-[var(--admin-text-muted)]">
{result.ok
? `Completed (${result.correlationId})`
: `Failed: ${result.error.messageKey} (${result.correlationId})`}
</p>
) : null}
</form>
);
}
@@ -1,6 +1,12 @@
import { readFileSync } from "node:fs";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
vi.mock("@/actions/housekeeping-command", () => ({
executeHousekeepingCommand: vi.fn(),
}));
import { import {
fail, fail,
type HousekeepingCapabilityContext, type HousekeepingCapabilityContext,
@@ -19,6 +25,7 @@ import {
} from "../route-handlers"; } from "../route-handlers";
import { PeopleCommunityPage } from "./community"; import { PeopleCommunityPage } from "./community";
import { PeopleMultiAccountsPage } from "./multi-accounts"; import { PeopleMultiAccountsPage } from "./multi-accounts";
import { parsePeopleListInput } from "./page-state";
import { PeopleStaffPage } from "./staff"; import { PeopleStaffPage } from "./staff";
import { PeopleUserDetailPage } from "./user-detail"; import { PeopleUserDetailPage } from "./user-detail";
import { PeopleUserEditPage } from "./user-edit"; import { PeopleUserEditPage } from "./user-edit";
@@ -77,12 +84,11 @@ type PageCase = {
const cases: readonly PageCase[] = [ const cases: readonly PageCase[] = [
{ {
name: "users", name: "users",
render: (result, partialDependencies) => render: (result) =>
renderToStaticMarkup( renderToStaticMarkup(
<PeopleUsersPage <PeopleUsersPage
context={readContext} context={readContext}
result={result as HousekeepingResult<PeopleUsersQueryData>} result={result as HousekeepingResult<PeopleUsersQueryData>}
partialDependencies={partialDependencies}
/>, />,
), ),
empty: { empty: {
@@ -96,12 +102,11 @@ const cases: readonly PageCase[] = [
}, },
{ {
name: "user-detail", name: "user-detail",
render: (result, partialDependencies) => render: (result) =>
renderToStaticMarkup( renderToStaticMarkup(
<PeopleUserDetailPage <PeopleUserDetailPage
context={readContext} context={readContext}
result={result as HousekeepingResult<PeopleUsersQueryData>} result={result as HousekeepingResult<PeopleUsersQueryData>}
partialDependencies={partialDependencies}
/>, />,
), ),
empty: { kind: "user", user: { ...detail, sanctions: [] } }, empty: { kind: "user", user: { ...detail, sanctions: [] } },
@@ -124,12 +129,11 @@ const cases: readonly PageCase[] = [
}, },
{ {
name: "user-edit", name: "user-edit",
render: (result, partialDependencies) => render: (result) =>
renderToStaticMarkup( renderToStaticMarkup(
<PeopleUserEditPage <PeopleUserEditPage
context={capabilityContext([PERMS.USERS_EDIT, PERMS.USERS_VIEW])} context={capabilityContext([PERMS.USERS_EDIT, PERMS.USERS_VIEW])}
result={result as HousekeepingResult<PeopleUsersQueryData>} result={result as HousekeepingResult<PeopleUsersQueryData>}
partialDependencies={partialDependencies}
/>, />,
), ),
empty: fail("NOT_FOUND", "errors.housekeeping.notFound", correlationId), empty: fail("NOT_FOUND", "errors.housekeeping.notFound", correlationId),
@@ -137,12 +141,11 @@ const cases: readonly PageCase[] = [
}, },
{ {
name: "multi-accounts", name: "multi-accounts",
render: (result, partialDependencies) => render: (result) =>
renderToStaticMarkup( renderToStaticMarkup(
<PeopleMultiAccountsPage <PeopleMultiAccountsPage
context={readContext} context={readContext}
result={result as HousekeepingResult<PeopleUsersQueryData>} result={result as HousekeepingResult<PeopleUsersQueryData>}
partialDependencies={partialDependencies}
/>, />,
), ),
empty: { empty: {
@@ -161,12 +164,11 @@ const cases: readonly PageCase[] = [
}, },
{ {
name: "community", name: "community",
render: (result, partialDependencies) => render: (result) =>
renderToStaticMarkup( renderToStaticMarkup(
<PeopleCommunityPage <PeopleCommunityPage
context={readContext} context={readContext}
result={result as HousekeepingResult<PeopleCommunityQueryData>} result={result as HousekeepingResult<PeopleCommunityQueryData>}
partialDependencies={partialDependencies}
/>, />,
), ),
empty: { empty: {
@@ -197,12 +199,11 @@ const cases: readonly PageCase[] = [
}, },
{ {
name: "staff", name: "staff",
render: (result, partialDependencies) => render: (result) =>
renderToStaticMarkup( renderToStaticMarkup(
<PeopleStaffPage <PeopleStaffPage
context={readContext} context={readContext}
result={result as HousekeepingResult<PeopleStaffQueryData>} result={result as HousekeepingResult<PeopleStaffQueryData>}
partialDependencies={partialDependencies}
/>, />,
), ),
empty: { empty: {
@@ -231,7 +232,7 @@ const cases: readonly PageCase[] = [
]; ];
describe.each(cases)("People $name page", ({ render, empty, ready }) => { describe.each(cases)("People $name page", ({ render, empty, ready }) => {
it("renders loading, forbidden, dependency error, empty, partial, and ready states", () => { it("renders loading, forbidden, dependency error, empty, and ready states", () => {
expect(render()).toContain('data-housekeeping-state="loading"'); expect(render()).toContain('data-housekeeping-state="loading"');
expect( expect(
render(fail("FORBIDDEN", "errors.housekeeping.forbidden", correlationId)), render(fail("FORBIDDEN", "errors.housekeeping.forbidden", correlationId)),
@@ -250,9 +251,6 @@ describe.each(cases)("People $name page", ({ render, empty, ready }) => {
? (empty as HousekeepingResult<unknown>) ? (empty as HousekeepingResult<unknown>)
: ok(empty, correlationId); : ok(empty, correlationId);
expect(render(emptyResult)).toContain('data-housekeeping-state="empty"'); 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( expect(render(ok(ready, correlationId))).toContain(
'data-housekeeping-state="ready"', 'data-housekeeping-state="ready"',
); );
@@ -310,7 +308,59 @@ describe("People primary route registration", () => {
/>, />,
); );
expect(editorHtml).toContain('href="/ase/people/users/7/edit"'); expect(editorHtml).toContain('href="/ase/people/users/7/edit"');
expect(editorHtml).toContain("people.user.update"); expect(editorHtml).toContain("<form");
expect(editorHtml).not.toContain("<span data-housekeeping-command");
expect(editorHtml).not.toContain("/admin"); expect(editorHtml).not.toContain("/admin");
const editHtml = renderToStaticMarkup(
<PeopleUserEditPage
context={capabilityContext([PERMS.USERS_VIEW, PERMS.USERS_EDIT])}
result={ok({ kind: "user", user: detail }, correlationId)}
/>,
);
expect(editHtml).toContain("people.user.update");
expect(editHtml).toContain("<form");
});
});
describe("People URL list input", () => {
it("bounds and normalizes search, page, pageSize, sort, and direction", () => {
expect(
parsePeopleListInput({
search: [" Alice ", "ignored"],
page: "999999999999",
pageSize: "5000",
sort: "username",
direction: "desc",
}),
).toEqual({
search: "Alice",
pageSize: 100,
offset: 100000,
sort: "username",
order: "desc",
});
});
});
describe("People actionable form contract", () => {
it("submits bounded command input and reason through the existing server action", () => {
const source = readFileSync(
"src/features/housekeeping/domains/people/pages/people-command-form.tsx",
"utf8",
);
expect(source).toContain("executeHousekeepingCommand({");
expect(source).toContain("commandId");
expect(source).toContain("input: commandInput");
expect(source).toContain("{ reason }");
expect(source).toContain("action={submit}");
expect(source).not.toContain("<span data-housekeeping-command");
});
it("provides a real Next loading boundary", () => {
const source = readFileSync(
"src/app/ase-next/[domain]/[[...segments]]/loading.tsx",
"utf8",
);
expect(source).toContain('data-housekeeping-state="loading"');
expect(source).toContain('state="loading"');
}); });
}); });
@@ -7,64 +7,123 @@ import {
} from "../../../foundation/contracts"; } from "../../../foundation/contracts";
import type { HousekeepingPageInput } from "../../../route-handlers"; import type { HousekeepingPageInput } from "../../../route-handlers";
import { type PeopleStaffQueryData, peopleStaffQuery } from "../queries/staff"; import { type PeopleStaffQueryData, peopleStaffQuery } from "../queries/staff";
import { PeoplePageFrame } from "./page-state"; import { PeoplePageFrame, parsePeopleListInput } from "./page-state";
import { PeopleCommandForm } from "./people-command-form";
interface PeopleStaffPageProps { interface PeopleStaffPageProps {
readonly context: HousekeepingCapabilityContext; readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<PeopleStaffQueryData>; readonly result?: HousekeepingResult<PeopleStaffQueryData>;
readonly partialDependencies?: readonly string[];
} }
export function PeopleStaffPage({ export function PeopleStaffPage({ context, result }: PeopleStaffPageProps) {
context,
result,
partialDependencies,
}: PeopleStaffPageProps) {
return ( return (
<PeoplePageFrame <PeoplePageFrame
title="Staff" title="Staff"
description="Review staff applications and team definitions." description="Review staff applications and team definitions."
result={result} result={result}
partialDependencies={partialDependencies}
isEmpty={(data) => data.page.items.length === 0} isEmpty={(data) => data.page.items.length === 0}
> >
{(data) => {(data) => (
data.kind === "applications" ? ( <div className="space-y-3">
<ul className="space-y-2"> <form method="get" className="flex gap-2">
{data.page.items.map((application) => ( <input
<li name="search"
key={application.id} maxLength={100}
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4" aria-label="Search staff workflow"
> />
<h2>{application.username ?? `User #${application.userId}`}</h2> <button type="submit">Search</button>
<p>{application.content}</p> </form>
{context.has(PERMS.USERS_EDIT) ? ( {data.kind === "applications" ? (
<span data-housekeeping-command="people.application.decide"> <ul className="space-y-2">
Dismiss application {data.page.items.map((application) => (
</span> <li
) : null} key={application.id}
</li> className="space-y-3 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
))} >
</ul> <h2>
) : data.kind === "teams" ? ( {application.username ?? `User #${application.userId}`}
<ul className="space-y-2"> </h2>
{data.page.items.map((team) => ( <p>{application.content}</p>
<li {context.has(PERMS.USERS_EDIT) ? (
key={team.id} <PeopleCommandForm
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4" commandId="people.application.decide"
> buttonLabel="Dismiss application"
<h2>{team.name}</h2> input={{
<p>{team.jobDescription ?? "No job description"}</p> applicationId: application.id,
{context.has(PERMS.USERS_EDIT) ? ( decision: "dismiss",
<span data-housekeeping-command="people.team.change"> }}
Team controls requiresReason
</span> />
) : null} ) : null}
</li> </li>
))} ))}
</ul> </ul>
) : null ) : data.kind === "teams" ? (
} <div className="space-y-3">
{context.has(PERMS.USERS_EDIT) ? (
<PeopleCommandForm
commandId="people.team.change"
buttonLabel="Create team"
input={{ action: "create" }}
fields={[
{
name: "rankName",
label: "Team name",
type: "text",
maxLength: 255,
required: true,
},
{
name: "badge",
label: "Badge",
type: "text",
maxLength: 255,
},
{
name: "jobDescription",
label: "Job description",
type: "text",
maxLength: 255,
},
{
name: "staffColor",
label: "Staff color",
type: "text",
maxLength: 255,
defaultValue: "#327fa8",
},
{
name: "hiddenRank",
label: "Hidden rank",
type: "checkbox",
},
]}
requiresReason
/>
) : null}
<ul className="space-y-2">
{data.page.items.map((team) => (
<li
key={team.id}
className="space-y-3 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) ? (
<PeopleCommandForm
commandId="people.team.change"
buttonLabel="Delete team"
input={{ action: "delete", teamId: team.id }}
requiresReason
/>
) : null}
</li>
))}
</ul>
</div>
) : null}
</div>
)}
</PeoplePageFrame> </PeoplePageFrame>
); );
} }
@@ -73,7 +132,10 @@ export async function renderPeopleStaffPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId; const routeId = input.match.routeId;
const result = const result =
routeId === "people.staff.applications" || routeId === "people.staff.teams" routeId === "people.staff.applications" || routeId === "people.staff.teams"
? await peopleStaffQuery.run(input.context, { routeId, list: {} }) ? await peopleStaffQuery.run(input.context, {
routeId,
list: parsePeopleListInput(input.searchParams ?? {}),
})
: fail( : fail(
"NOT_FOUND", "NOT_FOUND",
"errors.housekeeping.notFound", "errors.housekeeping.notFound",
@@ -8,24 +8,22 @@ import {
import type { HousekeepingPageInput } from "../../../route-handlers"; import type { HousekeepingPageInput } from "../../../route-handlers";
import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users"; import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users";
import { PeoplePageFrame } from "./page-state"; import { PeoplePageFrame } from "./page-state";
import { PeopleCommandForm } from "./people-command-form";
interface PeopleUserDetailPageProps { interface PeopleUserDetailPageProps {
readonly context: HousekeepingCapabilityContext; readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<PeopleUsersQueryData>; readonly result?: HousekeepingResult<PeopleUsersQueryData>;
readonly partialDependencies?: readonly string[];
} }
export function PeopleUserDetailPage({ export function PeopleUserDetailPage({
context, context,
result, result,
partialDependencies,
}: PeopleUserDetailPageProps) { }: PeopleUserDetailPageProps) {
return ( return (
<PeoplePageFrame <PeoplePageFrame
title="User detail" title="User detail"
description="Review account state, permissions, and sanctions." description="Review account state, permissions, and sanctions."
result={result} result={result}
partialDependencies={partialDependencies}
isEmpty={() => false} isEmpty={() => false}
> >
{(data) => {(data) =>
@@ -37,12 +35,7 @@ export function PeopleUserDetailPage({
{data.user.username} {data.user.username}
</h2> </h2>
{context.has(PERMS.USERS_EDIT) ? ( {context.has(PERMS.USERS_EDIT) ? (
<a <a href={`${data.user.href}/edit`}>Edit</a>
href={`${data.user.href}/edit`}
data-housekeeping-command="people.user.update"
>
Edit
</a>
) : null} ) : null}
</div> </div>
<dl className="mt-3 grid grid-cols-2 gap-2 text-sm text-[var(--admin-text-muted)]"> <dl className="mt-3 grid grid-cols-2 gap-2 text-sm text-[var(--admin-text-muted)]">
@@ -65,21 +58,6 @@ export function PeopleUserDetailPage({
</> </>
) : null} ) : null}
</dl> </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>
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"> <section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
<h2 className="font-medium text-[var(--admin-text)]"> <h2 className="font-medium text-[var(--admin-text)]">
@@ -102,6 +80,126 @@ export function PeopleUserDetailPage({
</ul> </ul>
)} )}
</section> </section>
<section className="space-y-3 lg:col-span-2">
<h2 className="font-medium text-[var(--admin-text)]">
Account actions
</h2>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{context.has(PERMS.USERS_BAN) ? (
<>
<PeopleCommandForm
commandId="people.user.ban"
buttonLabel="Ban user"
input={{ userId: data.user.id, type: "account" }}
fields={[
{
name: "duration",
label: "Duration hours",
type: "number",
min: 0,
max: 87_600,
defaultValue: 0,
},
]}
requiresReason
includeReasonInInput
/>
<PeopleCommandForm
commandId="people.user.unban"
buttonLabel="Unban user"
input={{ userId: data.user.id }}
requiresReason
/>
</>
) : null}
{context.has(PERMS.USERS_EDIT) ? (
<>
<PeopleCommandForm
commandId="people.user.alert"
buttonLabel="Send alert"
input={{ userId: data.user.id }}
fields={[
{
name: "message",
label: "Message",
type: "text",
required: true,
maxLength: 500,
},
]}
/>
<PeopleCommandForm
commandId="people.user.disconnect"
buttonLabel="Disconnect"
input={{ userId: data.user.id }}
requiresReason
/>
<PeopleCommandForm
commandId="people.user.mute"
buttonLabel="Mute"
input={{ userId: data.user.id }}
fields={[
{
name: "duration",
label: "Duration seconds",
type: "number",
min: 0,
max: 87_600,
defaultValue: 0,
},
]}
requiresReason
/>
<PeopleCommandForm
commandId="people.user.unmute"
buttonLabel="Unmute"
input={{ userId: data.user.id }}
requiresReason
/>
<PeopleCommandForm
commandId="people.user.send-currency"
buttonLabel="Send credits"
input={{ userId: data.user.id }}
fields={[
{
name: "amount",
label: "Credits",
type: "number",
min: 1,
max: 1_000_000,
required: true,
},
]}
requiresReason
/>
<PeopleCommandForm
commandId="people.user.trade-lock"
buttonLabel="Set trade lock"
input={{ userId: data.user.id }}
fields={[
{
name: "untilUnix",
label: "Locked until (Unix seconds, 0 clears)",
type: "number",
min: 0,
max: 2_147_483_647,
required: true,
},
]}
requiresReason
/>
</>
) : null}
{context.has(PERMS.USERS_RESET_PASSWORD) ? (
<PeopleCommandForm
commandId="people.user.reset-password"
buttonLabel="Reset password"
input={{ userId: data.user.id }}
requiresReason
/>
) : null}
</div>
</section>
</div> </div>
) : null ) : null
} }
@@ -7,80 +7,74 @@ import {
import type { HousekeepingPageInput } from "../../../route-handlers"; import type { HousekeepingPageInput } from "../../../route-handlers";
import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users"; import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users";
import { PeoplePageFrame } from "./page-state"; import { PeoplePageFrame } from "./page-state";
import {
type PeopleCommandField,
PeopleCommandForm,
} from "./people-command-form";
interface PeopleUserEditPageProps { interface PeopleUserEditPageProps {
readonly context: HousekeepingCapabilityContext; readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<PeopleUsersQueryData>; readonly result?: HousekeepingResult<PeopleUsersQueryData>;
readonly partialDependencies?: readonly string[];
} }
export function PeopleUserEditPage({ export function PeopleUserEditPage({ result }: PeopleUserEditPageProps) {
context: _context,
result,
partialDependencies,
}: PeopleUserEditPageProps) {
return ( return (
<PeoplePageFrame <PeoplePageFrame
title="Edit user" title="Edit user"
description="Update bounded account fields through an audited command." description="Update bounded account fields through an audited command."
result={result} result={result}
partialDependencies={partialDependencies}
isEmpty={() => false} isEmpty={() => false}
> >
{(data) => {(data) => {
data.kind === "user" ? ( if (data.kind !== "user") return null;
<form const fields: PeopleCommandField[] = [
data-housekeeping-command="people.user.update" {
className="space-y-4 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4" name: "username",
> label: "Username",
<input type="hidden" name="userId" value={data.user.id} /> type: "text",
<label className="block text-sm"> defaultValue: data.user.username,
Username maxLength: 20,
<input required: true,
name="username" },
defaultValue={data.user.username} ...(data.user.mail !== null
maxLength={20} ? [
className="mt-1 block w-full" {
/> name: "mail",
</label> label: "Email",
{data.user.mail !== null ? ( type: "text",
<label className="block text-sm"> defaultValue: data.user.mail,
Email maxLength: 255,
<input } as const,
name="mail" ]
type="email" : []),
defaultValue={data.user.mail} {
className="mt-1 block w-full" name: "motto",
/> label: "Motto",
</label> type: "text",
) : null} defaultValue: data.user.motto,
<label className="block text-sm"> maxLength: 127,
Motto },
<input {
name="motto" name: "rank",
defaultValue={data.user.motto} label: "Rank",
maxLength={127} type: "select",
className="mt-1 block w-full" defaultValue: data.user.rank,
/> options: data.user.permission.ranks.map((rank) => ({
</label> value: rank.id,
<label className="block text-sm"> label: rank.name,
Rank })),
<select },
name="rank" ];
defaultValue={data.user.rank} return (
className="mt-1 block w-full" <PeopleCommandForm
> commandId="people.user.update"
{data.user.permission.ranks.map((rank) => ( buttonLabel="Save user"
<option key={rank.id} value={rank.id}> input={{ userId: data.user.id }}
{rank.name} fields={fields}
</option> nestFields
))} />
</select> );
</label> }}
<button type="submit">Save user</button>
</form>
) : null
}
</PeoplePageFrame> </PeoplePageFrame>
); );
} }
@@ -5,72 +5,143 @@ import type {
} from "../../../foundation/contracts"; } from "../../../foundation/contracts";
import type { HousekeepingPageInput } from "../../../route-handlers"; import type { HousekeepingPageInput } from "../../../route-handlers";
import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users"; import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users";
import { PeoplePageFrame } from "./page-state"; import { PeoplePageFrame, parsePeopleListInput } from "./page-state";
import { PeopleCommandForm } from "./people-command-form";
interface PeopleUsersPageProps { interface PeopleUsersPageProps {
readonly context: HousekeepingCapabilityContext; readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<PeopleUsersQueryData>; readonly result?: HousekeepingResult<PeopleUsersQueryData>;
readonly partialDependencies?: readonly string[];
} }
export function PeopleUsersPage({ const userIdsField = {
context, name: "userIds",
result, label: "User IDs (comma or space separated)",
partialDependencies, type: "number-list" as const,
}: PeopleUsersPageProps) { required: true,
maxLength: 1200,
};
export function PeopleUsersPage({ context, result }: PeopleUsersPageProps) {
return ( return (
<PeoplePageFrame <PeoplePageFrame
title="Users" title="Users"
description="Search accounts and open the canonical People workflow." description="Search accounts and open the canonical People workflow."
result={result} result={result}
partialDependencies={partialDependencies}
isEmpty={(data) => data.kind === "users" && data.page.items.length === 0} isEmpty={(data) => data.kind === "users" && data.page.items.length === 0}
> >
{(data) => {(data) =>
data.kind === "users" ? ( data.kind === "users" ? (
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"> <div className="space-y-4">
<p className="text-sm text-[var(--admin-text-muted)]"> <form method="get" className="flex flex-wrap gap-2">
{data.page.total} matching users <label>
</p> Search users
<ul className="mt-3 divide-y divide-[var(--admin-border)]"> <input name="search" maxLength={100} className="ml-2" />
{data.page.items.map((user) => ( </label>
<li <input type="hidden" name="pageSize" value={data.page.pageSize} />
key={user.id} <button type="submit">Search</button>
className="flex flex-wrap items-center gap-3 py-3 text-sm" </form>
> {context.has(PERMS.USERS_EDIT) ? (
<a <section className="grid gap-3 lg:grid-cols-2">
className="font-medium text-[var(--admin-text)]" <PeopleCommandForm
href={user.href} commandId="people.users.bulk-ban"
buttonLabel="Ban users"
input={{}}
fields={[
userIdsField,
{
name: "duration",
label: "Duration seconds",
type: "number",
min: 0,
max: 315_360_000,
defaultValue: 0,
},
]}
requiresReason
includeReasonInInput
/>
<PeopleCommandForm
commandId="people.users.bulk-unban"
buttonLabel="Unban users"
input={{}}
fields={[userIdsField]}
requiresReason
/>
<PeopleCommandForm
commandId="people.users.bulk-currency"
buttonLabel="Send currency"
input={{}}
fields={[
userIdsField,
{
name: "amount",
label: "Amount",
type: "number",
min: 1,
max: 1_000_000,
required: true,
},
{
name: "type",
label: "Currency",
type: "select",
options: [
{ value: "credits", label: "Credits" },
{ value: "pixels", label: "Duckets" },
{ value: "points", label: "Points" },
],
},
]}
requiresReason
/>
<PeopleCommandForm
commandId="people.users.bulk-badge"
buttonLabel="Give badge"
input={{}}
fields={[
userIdsField,
{
name: "badgeCode",
label: "Badge code",
type: "text",
maxLength: 20,
required: true,
},
]}
requiresReason
/>
</section>
) : null}
<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"
> >
{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 <a
href={`${user.href}/edit`} className="font-medium text-[var(--admin-text)]"
data-housekeeping-command="people.user.update" href={user.href}
> >
Edit {user.username}
</a> </a>
) : null} <span>Rank {user.rank}</span>
{context.has(PERMS.USERS_BAN) ? ( <span>{user.online ? "Online" : "Offline"}</span>
<span data-housekeeping-command="people.user.ban"> {user.mail !== null ? <span>{user.mail}</span> : null}
Ban controls available {user.ipCurrent !== null ? (
</span> <span>{user.ipCurrent}</span>
) : null} ) : null}
</li> {context.has(PERMS.USERS_EDIT) ? (
))} <a href={`${user.href}/edit`}>Edit</a>
</ul> ) : null}
</section> </li>
))}
</ul>
</section>
</div>
) : null ) : null
} }
</PeoplePageFrame> </PeoplePageFrame>
@@ -80,7 +151,7 @@ export function PeopleUsersPage({
export async function renderPeopleUsersPage(input: HousekeepingPageInput) { export async function renderPeopleUsersPage(input: HousekeepingPageInput) {
const result = await peopleUsersQuery.run(input.context, { const result = await peopleUsersQuery.run(input.context, {
routeId: "people.users.list", routeId: "people.users.list",
list: {}, list: parsePeopleListInput(input.searchParams ?? {}),
}); });
return <PeopleUsersPage context={input.context} result={result} />; return <PeopleUsersPage context={input.context} result={result} />;
} }
@@ -0,0 +1,143 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { AuditEntry } from "@/lib/services/audit";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
const { alertUser, audit, resolveServerContext, selectLimit } = vi.hoisted(
() => ({
alertUser: vi.fn(),
audit: vi.fn(),
resolveServerContext: vi.fn(),
selectLimit: vi.fn(),
}),
);
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: resolveServerContext,
}));
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 context(): HousekeepingCapabilityContext {
return {
actor: { id: 42, username: "operator", rank: 6 },
isSuperAdmin: false,
has: (slug) => slug === PERMS.USERS_EDIT,
hasAny: (...slugs) => slugs.includes(PERMS.USERS_EDIT),
hasAll: (...slugs) => slugs.every((slug) => slug === PERMS.USERS_EDIT),
};
}
const invocation = {
correlationId: "audit-contract",
expectedActorId: 42,
};
beforeEach(() => {
vi.clearAllMocks();
resolveServerContext.mockResolvedValue(context());
selectLimit.mockResolvedValue([
{
id: 7,
username: "Alice",
rank: 2,
motto: "Ready",
credits: 100,
pixels: 50,
online: "1",
},
]);
alertUser.mockResolvedValue(true);
audit.mockResolvedValue(undefined);
});
describe("People external audit contract", () => {
it("blocks the external operation when intent persistence fails", async () => {
audit.mockRejectedValueOnce(new Error("intent unavailable"));
const result = await peopleMutationService.execute(
invocation,
"user.alert",
{ userId: 7, message: "Hello" },
);
expect(result).toMatchObject({ ok: false });
expect(alertUser).not.toHaveBeenCalled();
expect(audit).toHaveBeenCalledWith(
expect.objectContaining({ outcome: "intent" }),
);
});
it("persists a correlated failure outcome when the external operation fails", async () => {
alertUser.mockResolvedValue(false);
const result = await peopleMutationService.execute(
invocation,
"user.alert",
{ userId: 7, message: "Hello" },
);
expect(result).toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
});
expect(
audit.mock.calls.map((call) => {
const entry = call[0] as AuditEntry;
return {
outcome: entry.outcome,
correlationId: entry.correlationId,
};
}),
).toEqual([
{ outcome: "intent", correlationId: "audit-contract" },
{ outcome: "failure", correlationId: "audit-contract" },
]);
});
it("throws typed completed-operation evidence when final outcome persistence fails", async () => {
audit.mockImplementation(async (entry: AuditEntry) => {
if (entry.outcome === "success") throw new Error("outcome unavailable");
});
await expect(
peopleMutationService.execute(invocation, "user.alert", {
userId: 7,
message: "Hello",
}),
).rejects.toMatchObject({
name: "AuditOutcomePersistenceError",
operationCompleted: true,
operationResult: {
before: expect.objectContaining({ id: 7 }),
after: expect.objectContaining({ alertDelivered: true }),
},
});
expect(alertUser).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,286 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AuditEntry } from "@/lib/services/audit";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
const mocks = vi.hoisted(() => ({
audit: vi.fn(),
deleteWhere: vi.fn(),
insertValues: vi.fn(),
logStaffActivity: vi.fn(),
rcon: {
alertUser: vi.fn(),
disconnectUser: vi.fn(),
giveBadge: vi.fn(),
giveCredits: vi.fn(),
giveDuckets: vi.fn(),
givePointsGotw: vi.fn(),
setTradeLock: vi.fn(),
updateWordFilter: vi.fn(),
},
reloadSettings: vi.fn(),
reloadWordFilter: vi.fn(),
resolveServerContext: vi.fn(),
selectQueue: [] as unknown[][],
transaction: vi.fn(),
updateWhere: vi.fn(),
}));
function mutationPromise(
result: unknown = [{ insertId: 12, affectedRows: 2 }],
) {
return Object.assign(Promise.resolve(result), {
onDuplicateKeyUpdate: vi.fn(async () => result),
});
}
function selectResult() {
const value = mocks.selectQueue.shift() ?? [];
return Object.assign(Promise.resolve(value), {
limit: vi.fn(async () => value),
orderBy: vi.fn(() =>
Object.assign(Promise.resolve(value), {
limit: vi.fn(async () => value),
}),
),
});
}
function databaseFacade() {
const facade = {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(selectResult),
})),
})),
insert: vi.fn(() => ({
values: mocks.insertValues.mockImplementation(() => mutationPromise()),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: mocks.updateWhere.mockResolvedValue([{ affectedRows: 1 }]),
})),
})),
delete: vi.fn(() => ({
where: mocks.deleteWhere.mockResolvedValue([{ affectedRows: 2 }]),
})),
};
return facade;
}
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: mocks.resolveServerContext,
}));
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")>();
const tx = databaseFacade();
const root = databaseFacade();
return {
...actual,
db: {
...actual.db,
...root,
transaction: mocks.transaction.mockImplementation(async (callback) =>
callback(tx),
),
},
};
});
vi.mock("@/lib/services/audit", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/services/audit")>()),
logAudit: mocks.audit,
}));
vi.mock("@/lib/services/moderation", () => ({
reloadWordFilter: mocks.reloadWordFilter,
}));
vi.mock("@/lib/services/rcon", () => ({ rcon: mocks.rcon }));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: {
get: vi.fn(
async (key: string) =>
({
vpn_block_enabled: "0",
vpn_provider: "none",
vpn_api_key: "stored-secret",
vpn_block_message: "Blocked",
})[key],
),
reload: mocks.reloadSettings,
},
}));
vi.mock("@/lib/services/staff-activity", () => ({
logStaffActivity: mocks.logStaffActivity,
}));
vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() }));
import { peopleMutationService } from "./mutations";
function context(): HousekeepingCapabilityContext {
return {
actor: { id: 42, username: "operator", rank: 6 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
};
}
const invocation = {
correlationId: "production-workflow",
expectedActorId: 42,
legacy: true,
};
beforeEach(() => {
vi.clearAllMocks();
mocks.selectQueue.length = 0;
mocks.resolveServerContext.mockResolvedValue(context());
mocks.audit.mockResolvedValue(undefined);
mocks.reloadSettings.mockResolvedValue(undefined);
for (const call of Object.values(mocks.rcon)) call.mockResolvedValue(true);
});
describe("People production workflow adapter", () => {
it("preserves legacy bulk order, duplicates, totals, and StaffActivities", async () => {
const ids = Array.from({ length: 101 }, (_, index) => (index % 2) + 1);
const result = await peopleMutationService.execute(
invocation,
"users.bulk-unban",
{ userIds: ids },
);
expect(result).toMatchObject({
ok: true,
data: { before: { userIds: ids }, after: { completed: 2, total: 101 } },
});
expect(mocks.transaction).toHaveBeenCalledTimes(1);
expect(mocks.logStaffActivity).toHaveBeenCalledWith(
expect.objectContaining({
action: "bulk_unban",
description: "Unbanned 2 user(s)",
}),
);
});
it("uses observed trade-lock state, no target hierarchy, RCON, and legacy activity", async () => {
mocks.selectQueue.push(
[
{
id: 7,
username: "Alice",
rank: 7,
motto: "",
credits: 0,
pixels: 0,
online: "1",
},
],
[{ id: 3, tradeLockedUntil: 100, reason: "existing" }],
[{ canTrade: "0", tradelockAmount: 4 }],
);
const result = await peopleMutationService.execute(
invocation,
"user.trade-lock",
{ userId: 7, untilUnix: 200 },
);
expect(result).toMatchObject({
ok: true,
data: {
before: { tradeLockedUntil: 100, canTrade: "0", tradelockAmount: 4 },
after: { tradeLockedUntil: 200, canTrade: "0", tradelockAmount: 5 },
},
});
expect(mocks.rcon.setTradeLock).toHaveBeenCalledWith(7, true);
expect(mocks.rcon.disconnectUser).toHaveBeenCalledWith(7, "Alice");
expect(mocks.logStaffActivity).toHaveBeenCalledWith(
expect.objectContaining({ action: "trade_lock", targetId: 7 }),
);
});
it("runs guild, application, team, and IP database workflows transactionally", async () => {
mocks.selectQueue.push([{ id: 9, name: "Builders", userId: 7 }], []);
await expect(
peopleMutationService.execute(invocation, "guild.disband", {
guildId: 9,
}),
).resolves.toMatchObject({ ok: true });
expect(mocks.logStaffActivity).toHaveBeenCalledWith(
expect.objectContaining({ action: "guild_disband", targetId: 9 }),
);
mocks.selectQueue.push([
{ id: 5n, userId: 7, rankId: 4, content: "Ready" },
]);
await expect(
peopleMutationService.execute(invocation, "application.decide", {
applicationId: 5,
decision: "dismiss",
}),
).resolves.toMatchObject({ ok: true });
mocks.selectQueue.push([
{
id: 4n,
rankName: "MOD",
badge: null,
jobDescription: null,
hiddenRank: false,
},
]);
await expect(
peopleMutationService.execute(invocation, "team.change", {
action: "delete",
teamId: 4,
}),
).resolves.toMatchObject({ ok: true });
mocks.selectQueue.push([{ id: 3n, ipAddress: "198.51.100.3", asn: null }]);
await expect(
peopleMutationService.execute(invocation, "ip.action", {
action: "delete-blacklist",
id: 3,
}),
).resolves.toMatchObject({ ok: true });
expect(
mocks.audit.mock.calls.filter((call) => call[1]).length,
).toBeGreaterThanOrEqual(4);
});
it("keeps VPN secrets out of audit while reloading settings and logging legacy activity", async () => {
const result = await peopleMutationService.execute(
invocation,
"vpn.configure",
{
enabled: true,
provider: "proxycheck",
apiKey: "new-secret",
blockMessage: "No VPN",
},
);
expect(result).toMatchObject({ ok: true });
expect(mocks.reloadSettings).toHaveBeenCalledTimes(1);
expect(mocks.logStaffActivity).toHaveBeenCalledWith(
expect.objectContaining({ action: "vpn_update" }),
);
const serialized = JSON.stringify(
mocks.audit.mock.calls.map((call) => call[0] as AuditEntry),
);
expect(serialized).not.toContain("new-secret");
expect(serialized).not.toContain("stored-secret");
});
it("refreshes local and RCON wordfilter state when delete target is already missing", async () => {
mocks.selectQueue.push([]);
const result = await peopleMutationService.execute(
invocation,
"word-filter.update",
{ action: "delete", id: 99 },
);
expect(result).toMatchObject({
ok: true,
data: { before: null, after: null },
});
expect(mocks.reloadWordFilter).toHaveBeenCalledTimes(1);
expect(mocks.rcon.updateWordFilter).toHaveBeenCalledTimes(1);
});
});
@@ -2,12 +2,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts"; import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
const { audit, alertUser, selectLimit } = vi.hoisted(() => ({ const { audit, alertUser, resolveServerContext, selectLimit } = vi.hoisted(
audit: vi.fn(), () => ({
alertUser: vi.fn(), audit: vi.fn(),
selectLimit: vi.fn(), alertUser: vi.fn(),
})); resolveServerContext: vi.fn(),
selectLimit: vi.fn(),
}),
);
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: resolveServerContext,
}));
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() })); vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() })); vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
vi.mock("@/lib/db", async (importOriginal) => { vi.mock("@/lib/db", async (importOriginal) => {
@@ -62,6 +68,7 @@ const target = {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
resolveServerContext.mockResolvedValue(capabilityContext([]));
selectLimit.mockResolvedValue([target]); selectLimit.mockResolvedValue([target]);
alertUser.mockResolvedValue(true); alertUser.mockResolvedValue(true);
audit.mockResolvedValue(undefined); audit.mockResolvedValue(undefined);
@@ -70,7 +77,7 @@ beforeEach(() => {
describe("People production mutation boundary", () => { describe("People production mutation boundary", () => {
it("rechecks authorization before any production adapter work", async () => { it("rechecks authorization before any production adapter work", async () => {
const result = await peopleMutationService.execute( const result = await peopleMutationService.execute(
{ capability: capabilityContext([]), correlationId: "production-denied" }, { expectedActorId: 42, correlationId: "production-denied" },
"user.alert", "user.alert",
{ userId: 7, message: "Hello" }, { userId: 7, message: "Hello" },
); );
@@ -81,11 +88,11 @@ describe("People production mutation boundary", () => {
}); });
it("emits sanitized before and after evidence for a successful mutation", async () => { it("emits sanitized before and after evidence for a successful mutation", async () => {
resolveServerContext.mockResolvedValue(
capabilityContext([PERMS.USERS_EDIT]),
);
const result = await peopleMutationService.execute( const result = await peopleMutationService.execute(
{ { expectedActorId: 42, correlationId: "production-alert" },
capability: capabilityContext([PERMS.USERS_EDIT]),
correlationId: "production-alert",
},
"user.alert", "user.alert",
{ userId: 7, message: "Hello" }, { userId: 7, message: "Hello" },
); );
@@ -107,17 +114,17 @@ describe("People production mutation boundary", () => {
); );
}); });
it("fails closed when required audit evidence cannot be persisted", async () => { it("blocks the external action when required intent evidence cannot be persisted", async () => {
resolveServerContext.mockResolvedValue(
capabilityContext([PERMS.USERS_EDIT]),
);
audit.mockRejectedValue(new Error("audit database unavailable")); audit.mockRejectedValue(new Error("audit database unavailable"));
const result = await peopleMutationService.execute( const result = await peopleMutationService.execute(
{ { expectedActorId: 42, correlationId: "production-audit-failure" },
capability: capabilityContext([PERMS.USERS_EDIT]),
correlationId: "production-audit-failure",
},
"user.alert", "user.alert",
{ userId: 7, message: "Hello" }, { userId: 7, message: "Hello" },
); );
expect(alertUser).toHaveBeenCalled(); expect(alertUser).not.toHaveBeenCalled();
expect(result).toMatchObject({ expect(result).toMatchObject({
ok: false, ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" }, error: { code: "DEPENDENCY_UNAVAILABLE" },
@@ -2,17 +2,39 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts"; import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
const { audit, disconnectUser, insertValues, selectLimit } = vi.hoisted(() => ({ const {
audit,
disconnectUser,
insertValues,
resolveServerContext,
selectLimit,
transaction,
txSelectLimit,
} = vi.hoisted(() => ({
audit: vi.fn(), audit: vi.fn(),
disconnectUser: vi.fn(), disconnectUser: vi.fn(),
insertValues: vi.fn(), insertValues: vi.fn(),
resolveServerContext: vi.fn(),
selectLimit: vi.fn(), selectLimit: vi.fn(),
transaction: vi.fn(),
txSelectLimit: vi.fn(),
})); }));
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: resolveServerContext,
}));
vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() })); vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() }));
vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() })); vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() }));
vi.mock("@/lib/db", async (importOriginal) => { vi.mock("@/lib/db", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/db")>(); const actual = await importOriginal<typeof import("@/lib/db")>();
const tx = {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({ limit: txSelectLimit })),
})),
})),
insert: vi.fn(() => ({ values: insertValues })),
};
return { return {
...actual, ...actual,
db: { db: {
@@ -22,7 +44,9 @@ vi.mock("@/lib/db", async (importOriginal) => {
where: vi.fn(() => ({ limit: selectLimit })), where: vi.fn(() => ({ limit: selectLimit })),
})), })),
})), })),
insert: vi.fn(() => ({ values: insertValues })), transaction: transaction.mockImplementation(async (callback) =>
callback(tx),
),
}, },
}; };
}); });
@@ -34,30 +58,28 @@ vi.mock("@/lib/services/rcon", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/services/rcon")>()), ...(await importOriginal<typeof import("@/lib/services/rcon")>()),
rcon: { disconnectUser }, rcon: { disconnectUser },
})); }));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() })); vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() }));
import { peopleMutationService } from "./mutations"; import { peopleMutationService } from "./mutations";
function capabilityContext( function capabilityContext(): HousekeepingCapabilityContext {
granted: readonly string[],
): HousekeepingCapabilityContext {
const permissions = new Set(granted);
return { return {
actor: { id: 42, username: "operator", rank: 6 }, actor: { id: 42, username: "operator", rank: 6 },
isSuperAdmin: false, isSuperAdmin: false,
has: (slug) => permissions.has(slug), has: (slug) => slug === PERMS.USERS_BAN,
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)), hasAny: (...slugs) => slugs.includes(PERMS.USERS_BAN),
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)), hasAll: (...slugs) => slugs.every((slug) => slug === PERMS.USERS_BAN),
}; };
} }
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
resolveServerContext.mockResolvedValue(capabilityContext());
selectLimit.mockResolvedValue([ selectLimit.mockResolvedValue([
{ {
id: 7, id: 7,
username: "Alice", username: "Alice",
mail: "[email protected]",
rank: 2, rank: 2,
motto: "Ready", motto: "Ready",
credits: 100, credits: 100,
@@ -65,18 +87,16 @@ beforeEach(() => {
online: "1", online: "1",
}, },
]); ]);
txSelectLimit.mockResolvedValue([]);
insertValues.mockResolvedValue([{}]); insertValues.mockResolvedValue([{}]);
disconnectUser.mockResolvedValue(true); disconnectUser.mockResolvedValue(true);
audit.mockResolvedValue(undefined); audit.mockResolvedValue(undefined);
}); });
describe("People production reason audit", () => { describe("People production reason audit", () => {
it("persists the nonblank sanction reason with before and after evidence", async () => { it("persists the nonblank sanction reason with observed before and after evidence", async () => {
const result = await peopleMutationService.execute( const result = await peopleMutationService.execute(
{ { expectedActorId: 42, correlationId: "production-ban" },
capability: capabilityContext([PERMS.USERS_BAN]),
correlationId: "production-ban",
},
"user.ban", "user.ban",
{ {
userId: 7, userId: 7,
@@ -87,9 +107,11 @@ describe("People production reason audit", () => {
); );
expect(result).toMatchObject({ ok: true }); expect(result).toMatchObject({ ok: true });
expect(transaction).toHaveBeenCalledTimes(1);
expect(audit).toHaveBeenCalledWith( expect(audit).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
action: "people.user.ban", action: "people.user.ban",
outcome: "intent",
before: expect.objectContaining({ id: 7, banned: false }), before: expect.objectContaining({ id: 7, banned: false }),
after: expect.objectContaining({ after: expect.objectContaining({
id: 7, id: 7,
@@ -97,6 +119,7 @@ describe("People production reason audit", () => {
reason: "Repeated harassment", reason: "Repeated harassment",
}), }),
}), }),
expect.anything(),
); );
expect(audit.mock.calls[0]?.[0]?.before).not.toHaveProperty("mail"); expect(audit.mock.calls[0]?.[0]?.before).not.toHaveProperty("mail");
expect(audit.mock.calls[0]?.[0]?.after).not.toHaveProperty("mail"); expect(audit.mock.calls[0]?.[0]?.after).not.toHaveProperty("mail");
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
const { alertUser, audit, resolveServerContext, selectLimit } = vi.hoisted(
() => ({
alertUser: vi.fn(),
audit: vi.fn(),
resolveServerContext: vi.fn(),
selectLimit: vi.fn(),
}),
);
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: resolveServerContext,
}));
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 context(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();
resolveServerContext.mockResolvedValue(context([]));
selectLimit.mockResolvedValue([
{
id: 7,
username: "Alice",
rank: 2,
motto: "Ready",
credits: 100,
pixels: 50,
online: "1",
},
]);
alertUser.mockResolvedValue(true);
audit.mockResolvedValue(undefined);
});
describe("People production server authority", () => {
it("rejects a caller-forged capability and resolves the authenticated request actor", async () => {
const result = await peopleMutationService.execute(
{
correlationId: "server-authority",
expectedActorId: 42,
capability: context([PERMS.USERS_EDIT]),
} as never,
"user.alert",
{ userId: 7, message: "Hello" },
);
expect(resolveServerContext).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
ok: false,
error: { code: "FORBIDDEN" },
correlationId: "server-authority",
});
expect(selectLimit).not.toHaveBeenCalled();
expect(alertUser).not.toHaveBeenCalled();
expect(audit).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
const {
audit,
globalDeleteWhere,
resolveServerContext,
transaction,
txDeleteWhere,
txSelectLimit,
} = vi.hoisted(() => ({
audit: vi.fn(),
globalDeleteWhere: vi.fn(),
resolveServerContext: vi.fn(),
transaction: vi.fn(),
txDeleteWhere: vi.fn(),
txSelectLimit: vi.fn(),
}));
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: resolveServerContext,
}));
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: txSelectLimit })),
})),
})),
delete: vi.fn(() => ({ where: globalDeleteWhere })),
transaction,
},
};
});
vi.mock("@/lib/services/audit", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/services/audit")>()),
logAudit: audit,
}));
vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() }));
import { peopleMutationService } from "./mutations";
function context(): HousekeepingCapabilityContext {
return {
actor: { id: 42, username: "operator", rank: 6 },
isSuperAdmin: false,
has: (slug) => slug === PERMS.USERS_EDIT,
hasAny: (...slugs) => slugs.includes(PERMS.USERS_EDIT),
hasAll: (...slugs) => slugs.every((slug) => slug === PERMS.USERS_EDIT),
};
}
beforeEach(() => {
vi.clearAllMocks();
resolveServerContext.mockResolvedValue(context());
txSelectLimit.mockResolvedValue([
{ id: 9n, userId: 7, rankId: 3, content: "Application" },
]);
globalDeleteWhere.mockResolvedValue(undefined);
txDeleteWhere.mockResolvedValue(undefined);
transaction.mockImplementation(async (callback) => {
let staged = false;
const tx = {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({ limit: txSelectLimit })),
})),
})),
delete: vi.fn(() => ({
where: vi.fn(async (...args) => {
staged = true;
return txDeleteWhere(...args);
}),
})),
insert: vi.fn(),
};
try {
const value = await callback(tx);
return { value, committed: staged };
} catch (error) {
throw Object.assign(error as Error, { rolledBack: staged });
}
});
audit.mockRejectedValue(new Error("audit unavailable"));
});
describe("People transactional database audit", () => {
it("rolls back application deletion when canonical audit persistence fails", async () => {
const result = await peopleMutationService.execute(
{ correlationId: "transactional-audit", expectedActorId: 42 },
"application.decide",
{ applicationId: 9, decision: "dismiss" },
);
expect(result).toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
});
expect(transaction).toHaveBeenCalledTimes(1);
expect(globalDeleteWhere).not.toHaveBeenCalled();
expect(txDeleteWhere).toHaveBeenCalledTimes(1);
expect(audit).toHaveBeenCalledWith(
expect.objectContaining({
outcome: "success",
before: expect.objectContaining({ id: 9 }),
after: undefined,
}),
expect.objectContaining({ insert: expect.any(Function) }),
);
});
});
File diff suppressed because it is too large. Load diff
@@ -31,6 +31,7 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
[ [
"src/features/housekeeping/domains/people/pages/community.tsx", "src/features/housekeeping/domains/people/pages/community.tsx",
new Set([ new Set([
"src/features/housekeeping/domains/people/pages/people-command-form",
"src/features/housekeeping/domains/people/pages/page-state", "src/features/housekeeping/domains/people/pages/page-state",
"src/features/housekeeping/domains/people/queries/community", "src/features/housekeeping/domains/people/queries/community",
]), ]),
@@ -38,6 +39,7 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
[ [
"src/features/housekeeping/domains/people/pages/multi-accounts.tsx", "src/features/housekeeping/domains/people/pages/multi-accounts.tsx",
new Set([ new Set([
"src/features/housekeeping/domains/people/pages/people-command-form",
"src/features/housekeeping/domains/people/pages/page-state", "src/features/housekeeping/domains/people/pages/page-state",
"src/features/housekeeping/domains/people/queries/users", "src/features/housekeeping/domains/people/queries/users",
]), ]),
@@ -45,6 +47,7 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
[ [
"src/features/housekeeping/domains/people/pages/staff.tsx", "src/features/housekeeping/domains/people/pages/staff.tsx",
new Set([ new Set([
"src/features/housekeeping/domains/people/pages/people-command-form",
"src/features/housekeeping/domains/people/pages/page-state", "src/features/housekeeping/domains/people/pages/page-state",
"src/features/housekeeping/domains/people/queries/staff", "src/features/housekeeping/domains/people/queries/staff",
]), ]),
@@ -52,6 +55,7 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
[ [
"src/features/housekeeping/domains/people/pages/user-detail.tsx", "src/features/housekeeping/domains/people/pages/user-detail.tsx",
new Set([ new Set([
"src/features/housekeeping/domains/people/pages/people-command-form",
"src/features/housekeeping/domains/people/pages/page-state", "src/features/housekeeping/domains/people/pages/page-state",
"src/features/housekeeping/domains/people/queries/users", "src/features/housekeeping/domains/people/queries/users",
]), ]),
@@ -59,6 +63,7 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
[ [
"src/features/housekeeping/domains/people/pages/user-edit.tsx", "src/features/housekeeping/domains/people/pages/user-edit.tsx",
new Set([ new Set([
"src/features/housekeeping/domains/people/pages/people-command-form",
"src/features/housekeeping/domains/people/pages/page-state", "src/features/housekeeping/domains/people/pages/page-state",
"src/features/housekeeping/domains/people/queries/users", "src/features/housekeeping/domains/people/queries/users",
]), ]),
@@ -66,10 +71,19 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
[ [
"src/features/housekeeping/domains/people/pages/users.tsx", "src/features/housekeeping/domains/people/pages/users.tsx",
new Set([ new Set([
"src/features/housekeeping/domains/people/pages/people-command-form",
"src/features/housekeeping/domains/people/pages/page-state", "src/features/housekeeping/domains/people/pages/page-state",
"src/features/housekeeping/domains/people/queries/users", "src/features/housekeeping/domains/people/queries/users",
]), ]),
], ],
[
"src/features/housekeeping/domains/people/pages/page-state.tsx",
new Set(["src/features/housekeeping/domains/people/models"]),
],
[
"src/features/housekeeping/domains/people/pages/people-command-form.tsx",
new Set(["src/actions/housekeeping-command"]),
],
[ [
"src/features/housekeeping/domains/people/services/mutations.ts", "src/features/housekeeping/domains/people/services/mutations.ts",
new Set([ new Set([
@@ -7,6 +7,9 @@ import type { HousekeepingRouteMatch } from "./foundation/routing/match-route";
export interface HousekeepingPageInput { export interface HousekeepingPageInput {
readonly context: HousekeepingCapabilityContext; readonly context: HousekeepingCapabilityContext;
readonly match: HousekeepingRouteMatch; readonly match: HousekeepingRouteMatch;
readonly searchParams?: Readonly<
Record<string, string | readonly string[] | undefined>
>;
} }
export interface HousekeepingRouteHandler { export interface HousekeepingRouteHandler {
+11 -7
View File
@@ -136,13 +136,17 @@ describe("staff smoke contract", () => {
} }
}); });
it("exports bulk adjust currency and trade lock", () => { it("exports bulk adjust currency and keeps trade-lock DB/RCON behavior", () => {
const src = readFileSync("src/actions/bulk-users.ts", "utf8"); const wrapper = readFileSync("src/actions/bulk-users.ts", "utf8");
expect(src).toContain("bulkAdjustCurrency"); const service = readFileSync(
expect(src).toContain("setTradeLock"); "src/features/housekeeping/domains/people/services/mutations.ts",
expect(src).toContain("tradeLockedUntil"); "utf8",
expect(src).toContain("UsersSettings"); );
expect(src).toContain("rcon.setTradeLock"); expect(wrapper).toContain("bulkAdjustCurrency");
expect(wrapper).toContain("setTradeLock");
expect(service).toContain("tradeLockedUntil");
expect(service).toContain("UsersSettings");
expect(service).toContain("rcon.setTradeLock");
}); });
it("deletes photos via Drizzle with local file purge helper", () => { it("deletes photos via Drizzle with local file purge helper", () => {