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.
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 {
createLegacyPeopleMutationContext,
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
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;
await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.USERS_EDIT,
createCorrelationId(),
),
createPeopleMutationInvocation(staff, createCorrelationId()),
"application.decide",
{ applicationId, decision: "dismiss" },
);
+5 -7
View File
@@ -5,13 +5,11 @@ import { disbandGuild } from "./admin-guilds";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn(
(staff, permission, correlationId) => ({
staff,
permission,
correlationId,
}),
),
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermissionRateLimited: vi.fn() }));
+2 -6
View File
@@ -2,7 +2,7 @@
import { revalidatePath } from "next/cache";
import {
createLegacyPeopleMutationContext,
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
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;
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.USERS_EDIT,
createCorrelationId(),
),
createPeopleMutationInvocation(staff, createCorrelationId()),
"guild.disband",
{ guildId },
);
+5 -7
View File
@@ -10,13 +10,11 @@ import {
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn(
(staff, permission, correlationId) => ({
staff,
permission,
correlationId,
}),
),
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
+2 -6
View File
@@ -2,7 +2,7 @@
import { revalidatePath } from "next/cache";
import {
createLegacyPeopleMutationContext,
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
@@ -37,11 +37,7 @@ async function run(
const id = "id" in input ? input.id : undefined;
if (!adding && !(Number.isSafeInteger(id) && Number(id) > 0)) return;
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.SETTINGS_EDIT,
createCorrelationId(),
),
createPeopleMutationInvocation(staff, createCorrelationId()),
"ip.action",
input,
);
+5 -7
View File
@@ -5,13 +5,11 @@ import { createTeam, deleteTeam } from "./admin-teams";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn(
(staff, permission, correlationId) => ({
staff,
permission,
correlationId,
}),
),
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
+3 -11
View File
@@ -2,7 +2,7 @@
import { revalidatePath } from "next/cache";
import {
createLegacyPeopleMutationContext,
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
@@ -20,11 +20,7 @@ export async function createTeam(formData: FormData): Promise<void> {
const rankName = text(formData, "rankName");
if (!rankName) return;
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.USERS_EDIT,
createCorrelationId(),
),
createPeopleMutationInvocation(staff, createCorrelationId()),
"team.change",
{
action: "create",
@@ -44,11 +40,7 @@ export async function deleteTeam(formData: FormData): Promise<void> {
const teamId = Number(formData.get("id"));
if (!Number.isSafeInteger(teamId) || teamId <= 0) return;
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.USERS_EDIT,
createCorrelationId(),
),
createPeopleMutationInvocation(staff, createCorrelationId()),
"team.change",
{ action: "delete", teamId },
);
+5 -7
View File
@@ -6,13 +6,11 @@ import { saveVpn } from "./admin-vpn";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn(
(staff, permission, correlationId) => ({
staff,
permission,
correlationId,
}),
),
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
+2 -6
View File
@@ -3,7 +3,7 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import {
createLegacyPeopleMutationContext,
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
@@ -20,11 +20,7 @@ export async function saveVpn(formData: FormData): Promise<void> {
.toLowerCase();
const provider = ALLOWED_PROVIDERS.has(rawProvider) ? rawProvider : "none";
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.SETTINGS_EDIT,
createCorrelationId(),
),
createPeopleMutationInvocation(staff, createCorrelationId()),
"vpn.configure",
{
enabled: String(formData.get("vpn_block_enabled") ?? "").trim() !== "",
+3 -11
View File
@@ -2,7 +2,7 @@
import { revalidatePath } from "next/cache";
import {
createLegacyPeopleMutationContext,
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
@@ -24,11 +24,7 @@ export async function addWord(input: {
.slice(0, 255);
if (!word) return actionError("Word is required");
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.WORDFILTER_EDIT,
createCorrelationId(),
),
createPeopleMutationInvocation(staff, createCorrelationId()),
"word-filter.update",
{ action: "add", word },
);
@@ -44,11 +40,7 @@ export async function deleteWord(input: { id: string }): Promise<ActionResult> {
if (!Number.isSafeInteger(id) || id <= 0)
return actionError("Missing word id");
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.WORDFILTER_EDIT,
createCorrelationId(),
),
createPeopleMutationInvocation(staff, createCorrelationId()),
"word-filter.update",
{ action: "delete", id },
);
+5 -7
View File
@@ -3,13 +3,11 @@ import { requirePermission } from "@/lib/admin/guard";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn(
(staff, permission, correlationId) => ({
staff,
permission,
correlationId,
}),
),
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
+6 -8
View File
@@ -10,13 +10,11 @@ import {
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn(
(staff, permission, correlationId) => ({
staff,
permission,
correlationId,
}),
),
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
@@ -88,7 +86,7 @@ describe("legacy bulk user wrappers", () => {
data: { userId: 9, untilUnix: 1234 },
});
expect(execute).toHaveBeenLastCalledWith(
expect.objectContaining({ permission: "admin.users.edit" }),
expect.objectContaining({ expectedActorId: 1 }),
"user.trade-lock",
{ userId: 9, untilUnix: 1234 },
);
+11 -7
View File
@@ -2,7 +2,7 @@
import { and, eq } from "drizzle-orm";
import {
createLegacyPeopleMutationContext,
createPeopleMutationInvocation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
@@ -27,11 +27,7 @@ async function executeLegacy(
input: unknown,
) {
return peopleMutationService.execute(
createLegacyPeopleMutationContext(
staff,
PERMS.USERS_EDIT,
createCorrelationId(),
),
createPeopleMutationInvocation(staff, createCorrelationId()),
operation,
input,
);
@@ -259,6 +255,14 @@ export async function setTradeLock({
userId,
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 } };
}
+13 -14
View File
@@ -8,13 +8,11 @@ const { execute, staff } = vi.hoisted(() => ({
}));
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
createLegacyPeopleMutationContext: vi.fn(
(actor, permission, correlationId) => ({
actor,
permission,
correlationId,
}),
),
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
@@ -98,12 +96,13 @@ describe("legacy user safe-action wrappers", () => {
}>
)({ userId: 7 });
expect(
execute.mock.calls.map((call) => [call[0].permission, call[1]]),
).toEqual([
["admin.users.edit", "user.update"],
["admin.users.ban", "user.ban"],
["admin.users.reset_password", "user.reset-password"],
expect(execute.mock.calls.map((call) => call[1])).toEqual([
"user.update",
"user.ban",
"user.reset-password",
]);
expect(execute.mock.calls.map((call) => call[0].expectedActorId)).toEqual([
1, 1, 1,
]);
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 () => {
await dismissApplication(form({ id: "9" }));
expect(execute).toHaveBeenCalledWith(
expect.objectContaining({ permission: "admin.users.edit" }),
expect.objectContaining({ expectedActorId: 1 }),
"application.decide",
{ 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", () => ({
createLegacyPeopleMutationContext: vi.fn(
(actor, permission, correlationId) => ({
actor,
permission,
correlationId,
}),
),
createPeopleMutationInvocation: vi.fn((staff, correlationId) => ({
expectedActorId: staff.id,
correlationId,
legacy: true,
})),
peopleMutationService: { execute },
}));
vi.mock("@/lib/admin/guard", () => ({
+11 -21
View File
@@ -3,7 +3,7 @@
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import {
createLegacyPeopleMutationContext,
createPeopleMutationInvocation,
type PeopleMutationOperation,
peopleMutationService,
} from "@/features/housekeeping/domains/people/services/mutations";
@@ -116,16 +116,11 @@ const legacyMessages: Partial<Record<PeopleMutationOperation, string>> = {
async function executeLegacy(
ctx: { session: { user: { id: number; username: string; rank: number } } },
permission: string,
operation: PeopleMutationOperation,
input: unknown,
) {
const result = await peopleMutationService.execute(
createLegacyPeopleMutationContext(
ctx.session.user,
permission,
createCorrelationId(),
),
createPeopleMutationInvocation(ctx.session.user, createCorrelationId()),
operation,
input,
);
@@ -148,7 +143,7 @@ export const updateUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: updateUserInput },
async (ctx) => {
const { id: userId, ...fields } = ctx.data;
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.update", {
await executeLegacy(ctx, "user.update", {
userId,
fields,
});
@@ -159,7 +154,7 @@ export const updateUser = adminAction(
export const banUser = adminAction(
{ permission: PERMS.USERS_BAN, schema: banUserSchema },
async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_BAN, "user.ban", ctx.data);
await executeLegacy(ctx, "user.ban", ctx.data);
return actionOk();
},
);
@@ -169,7 +164,7 @@ const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
export const unbanUser = adminAction(
{ permission: PERMS.USERS_BAN, schema: userIdSchema },
async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_BAN, "user.unban", ctx.data);
await executeLegacy(ctx, "user.unban", ctx.data);
return actionOk();
},
);
@@ -177,12 +172,7 @@ export const unbanUser = adminAction(
export const resetPassword = adminAction(
{ permission: PERMS.USERS_RESET_PASSWORD, schema: userIdSchema },
async (ctx) => {
const snapshot = await executeLegacy(
ctx,
PERMS.USERS_RESET_PASSWORD,
"user.reset-password",
ctx.data,
);
const snapshot = await executeLegacy(ctx, "user.reset-password", ctx.data);
return actionOk({
newPassword: String(snapshot.output?.newPassword ?? ""),
});
@@ -192,7 +182,7 @@ export const resetPassword = adminAction(
export const disconnectUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: userIdSchema },
async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.disconnect", ctx.data);
await executeLegacy(ctx, "user.disconnect", ctx.data);
return actionOk();
},
);
@@ -203,7 +193,7 @@ const alertUserSchema = userIdSchema.extend({
export const alertUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema },
async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.alert", ctx.data);
await executeLegacy(ctx, "user.alert", ctx.data);
return actionOk();
},
);
@@ -214,7 +204,7 @@ const muteSchema = userIdSchema.extend({
export const muteUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: muteSchema },
async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.mute", ctx.data);
await executeLegacy(ctx, "user.mute", ctx.data);
return actionOk();
},
);
@@ -222,7 +212,7 @@ export const muteUser = adminAction(
export const unmuteUser = adminAction(
{ permission: PERMS.USERS_EDIT, schema: userIdSchema },
async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.unmute", ctx.data);
await executeLegacy(ctx, "user.unmute", ctx.data);
return actionOk();
},
);
@@ -233,7 +223,7 @@ const sendCreditsSchema = userIdSchema.extend({
export const sendCredits = adminAction(
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
async (ctx) => {
await executeLegacy(ctx, PERMS.USERS_EDIT, "user.send-currency", ctx.data);
await executeLegacy(ctx, "user.send-currency", ctx.data);
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({
params,
searchParams,
}: {
params: Promise<{ domain: string; segments?: readonly string[] }>;
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
const { domain, segments = [] } = await params;
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
@@ -39,5 +41,9 @@ export default async function HousekeepingPreviewRoutePage({
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) =>
service.execute(
{
capability: context.capability,
correlationId: context.correlationId,
expectedActorId: context.capability.actor.id,
},
options.operation,
input,
@@ -165,12 +165,11 @@ describe("People user commands", () => {
describe("People mutation service boundary", () => {
it("fails closed before the adapter when the exact capability is absent", async () => {
const execute = vi.fn(async () => ({ before: null, after: null }));
const service = createPeopleMutationService({ execute });
const service = createPeopleMutationService({ execute }, async () =>
capabilityContext([PERMS.USERS_EDIT]),
);
const result = await service.execute(
{
capability: capabilityContext([PERMS.USERS_EDIT]),
correlationId: "denied-people",
},
{ expectedActorId: 42, correlationId: "denied-people" },
"user.ban",
{ userId: 7, reason: "abuse", duration: 0, type: "account" },
);
@@ -190,11 +189,11 @@ describe("People mutation service boundary", () => {
after: { rank: 3 },
}),
};
const success = await createPeopleMutationService(snapshotAdapter).execute(
{
capability: capabilityContext([PERMS.USERS_EDIT]),
correlationId: "snapshot-people",
},
const success = await createPeopleMutationService(
snapshotAdapter,
async () => capabilityContext([PERMS.USERS_EDIT]),
).execute(
{ expectedActorId: 42, correlationId: "snapshot-people" },
"user.update",
{ userId: 7, fields: { rank: 3 } },
);
@@ -208,11 +207,11 @@ describe("People mutation service boundary", () => {
throw new Error("database password=secret");
},
};
const failure = await createPeopleMutationService(failureAdapter).execute(
{
capability: capabilityContext([PERMS.USERS_EDIT]),
correlationId: "failed-people",
},
const failure = await createPeopleMutationService(
failureAdapter,
async () => capabilityContext([PERMS.USERS_EDIT]),
).execute(
{ expectedActorId: 42, correlationId: "failed-people" },
"user.update",
{ userId: 7, fields: { motto: "Ready" } },
);
@@ -53,8 +53,8 @@ function userCommand<I>(
execute: (context, input) =>
service.execute(
{
capability: context.capability,
correlationId: context.correlationId,
expectedActorId: context.capability.actor.id,
},
options.operation,
input,
@@ -10,58 +10,71 @@ import {
type PeopleCommunityQueryData,
peopleCommunityQuery,
} from "../queries/community";
import { PeoplePageFrame } from "./page-state";
import { PeoplePageFrame, parsePeopleListInput } from "./page-state";
import { PeopleCommandForm } from "./people-command-form";
interface PeopleCommunityPageProps {
readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<PeopleCommunityQueryData>;
readonly partialDependencies?: readonly string[];
}
function empty(data: PeopleCommunityQueryData) {
return data.kind !== "guild" && data.page.items.length === 0;
}
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({
context,
result,
partialDependencies,
}: PeopleCommunityPageProps) {
return (
<PeoplePageFrame
title="Community"
description="Review online users and guild ownership."
result={result}
partialDependencies={partialDependencies}
isEmpty={empty}
>
{(data) => {
if (data.kind === "online")
return (
<ul className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
{data.page.items.map((user) => (
<li key={user.id}>
<a href={user.href}>{user.username}</a> - {user.motto}
</li>
))}
</ul>
<div className="space-y-3">
<SearchForm />
<ul className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
{data.page.items.map((user) => (
<li key={user.id}>
<a href={user.href}>{user.username}</a> - {user.motto}
</li>
))}
</ul>
</div>
);
if (data.kind === "guilds")
return (
<ul className="space-y-2">
{data.page.items.map((guild) => (
<li
key={guild.id}
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
>
<a href={guild.href}>{guild.name}</a>
<p>{guild.memberCount} members</p>
</li>
))}
</ul>
<div className="space-y-3">
<SearchForm />
<ul className="space-y-2">
{data.page.items.map((guild) => (
<li
key={guild.id}
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
>
<a href={guild.href}>{guild.name}</a>
<p>{guild.memberCount} members</p>
</li>
))}
</ul>
</div>
);
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>
<p>{data.guild.description}</p>
<ul>
@@ -72,9 +85,12 @@ export function PeopleCommunityPage({
))}
</ul>
{context.has(PERMS.USERS_EDIT) ? (
<span data-housekeeping-command="people.guild.disband">
Disband guild
</span>
<PeopleCommandForm
commandId="people.guild.disband"
buttonLabel="Disband guild"
input={{ guildId: data.guild.id }}
requiresReason
/>
) : null}
</section>
);
@@ -101,7 +117,10 @@ export async function renderPeopleCommunityPage(input: HousekeepingPageInput) {
})()
: routeId === "people.community.online" ||
routeId === "people.community.guilds"
? peopleCommunityQuery.run(input.context, { routeId, list: {} })
? peopleCommunityQuery.run(input.context, {
routeId,
list: parsePeopleListInput(input.searchParams ?? {}),
})
: Promise.resolve(
fail(
"NOT_FOUND",
@@ -4,53 +4,60 @@ import type {
} from "../../../foundation/contracts";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users";
import { PeoplePageFrame } from "./page-state";
import { PeoplePageFrame, parsePeopleListInput } from "./page-state";
interface PeopleMultiAccountsPageProps {
readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<PeopleUsersQueryData>;
readonly partialDependencies?: readonly string[];
}
export function PeopleMultiAccountsPage({
context: _context,
result,
partialDependencies,
}: PeopleMultiAccountsPageProps) {
return (
<PeoplePageFrame
title="Multi-account clusters"
description="Review bounded account clusters grouped by current IP."
result={result}
partialDependencies={partialDependencies}
isEmpty={(data) =>
data.kind === "multi-accounts" && data.page.items.length === 0
}
>
{(data) =>
data.kind === "multi-accounts" ? (
<ul className="space-y-3">
{data.page.items.map((cluster) => (
<li
key={cluster.key}
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
>
<h2 className="font-medium text-[var(--admin-text)]">
{cluster.key}
</h2>
<p className="text-sm text-[var(--admin-text-muted)]">
{cluster.accountCount} accounts
</p>
<ul className="mt-2 flex flex-wrap gap-3 text-sm">
{cluster.accounts.map((account) => (
<li key={account.id}>
<a href={account.href}>{account.username}</a>
</li>
))}
</ul>
</li>
))}
</ul>
<div className="space-y-3">
<form method="get" className="flex gap-2">
<input
name="search"
maxLength={100}
aria-label="Search IP clusters"
/>
<button type="submit">Search</button>
</form>
<ul className="space-y-3">
{data.page.items.map((cluster) => (
<li
key={cluster.key}
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
>
<h2 className="font-medium text-[var(--admin-text)]">
{cluster.key}
</h2>
<p className="text-sm text-[var(--admin-text-muted)]">
{cluster.accountCount} accounts
</p>
<ul className="mt-2 flex flex-wrap gap-3 text-sm">
{cluster.accounts.map((account) => (
<li key={account.id}>
<a href={account.href}>{account.username}</a>
</li>
))}
</ul>
</li>
))}
</ul>
</div>
) : null
}
</PeoplePageFrame>
@@ -62,7 +69,7 @@ export async function renderPeopleMultiAccountsPage(
) {
const result = await peopleUsersQuery.run(input.context, {
routeId: "people.users.multi-accounts",
list: {},
list: parsePeopleListInput(input.searchParams ?? {}),
});
return <PeopleMultiAccountsPage context={input.context} result={result} />;
}
@@ -2,12 +2,60 @@ import type { ReactNode } from "react";
import type { HousekeepingResult } from "../../../foundation/contracts";
import { HousekeepingPageShell } from "../../../foundation/page/housekeeping-page-shell";
import { HousekeepingPageState } from "../../../foundation/page/housekeeping-page-state";
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> {
readonly title: string;
readonly description: string;
readonly result?: HousekeepingResult<T>;
readonly partialDependencies?: readonly string[];
readonly isEmpty: (data: T) => boolean;
readonly children: (data: T) => ReactNode;
}
@@ -32,7 +80,6 @@ export function PeoplePageFrame<T>({
title,
description,
result,
partialDependencies = [],
isEmpty,
children,
}: PeoplePageFrameProps<T>) {
@@ -76,20 +123,7 @@ export function PeoplePageFrame<T>({
);
} else {
body = (
<div
data-housekeeping-state={
partialDependencies.length > 0 ? "partial" : "ready"
}
className="space-y-4"
>
{partialDependencies.length > 0 ? (
<HousekeepingPageState
state="partial"
partialLabel="Partial data"
title="Some People data is unavailable"
description={`Unavailable: ${partialDependencies.join(", ")}`}
/>
) : null}
<div data-housekeeping-state="ready" className="space-y-4">
{children(result.data)}
</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 { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
vi.mock("@/actions/housekeeping-command", () => ({
executeHousekeepingCommand: vi.fn(),
}));
import {
fail,
type HousekeepingCapabilityContext,
@@ -19,6 +25,7 @@ import {
} from "../route-handlers";
import { PeopleCommunityPage } from "./community";
import { PeopleMultiAccountsPage } from "./multi-accounts";
import { parsePeopleListInput } from "./page-state";
import { PeopleStaffPage } from "./staff";
import { PeopleUserDetailPage } from "./user-detail";
import { PeopleUserEditPage } from "./user-edit";
@@ -77,12 +84,11 @@ type PageCase = {
const cases: readonly PageCase[] = [
{
name: "users",
render: (result, partialDependencies) =>
render: (result) =>
renderToStaticMarkup(
<PeopleUsersPage
context={readContext}
result={result as HousekeepingResult<PeopleUsersQueryData>}
partialDependencies={partialDependencies}
/>,
),
empty: {
@@ -96,12 +102,11 @@ const cases: readonly PageCase[] = [
},
{
name: "user-detail",
render: (result, partialDependencies) =>
render: (result) =>
renderToStaticMarkup(
<PeopleUserDetailPage
context={readContext}
result={result as HousekeepingResult<PeopleUsersQueryData>}
partialDependencies={partialDependencies}
/>,
),
empty: { kind: "user", user: { ...detail, sanctions: [] } },
@@ -124,12 +129,11 @@ const cases: readonly PageCase[] = [
},
{
name: "user-edit",
render: (result, partialDependencies) =>
render: (result) =>
renderToStaticMarkup(
<PeopleUserEditPage
context={capabilityContext([PERMS.USERS_EDIT, PERMS.USERS_VIEW])}
result={result as HousekeepingResult<PeopleUsersQueryData>}
partialDependencies={partialDependencies}
/>,
),
empty: fail("NOT_FOUND", "errors.housekeeping.notFound", correlationId),
@@ -137,12 +141,11 @@ const cases: readonly PageCase[] = [
},
{
name: "multi-accounts",
render: (result, partialDependencies) =>
render: (result) =>
renderToStaticMarkup(
<PeopleMultiAccountsPage
context={readContext}
result={result as HousekeepingResult<PeopleUsersQueryData>}
partialDependencies={partialDependencies}
/>,
),
empty: {
@@ -161,12 +164,11 @@ const cases: readonly PageCase[] = [
},
{
name: "community",
render: (result, partialDependencies) =>
render: (result) =>
renderToStaticMarkup(
<PeopleCommunityPage
context={readContext}
result={result as HousekeepingResult<PeopleCommunityQueryData>}
partialDependencies={partialDependencies}
/>,
),
empty: {
@@ -197,12 +199,11 @@ const cases: readonly PageCase[] = [
},
{
name: "staff",
render: (result, partialDependencies) =>
render: (result) =>
renderToStaticMarkup(
<PeopleStaffPage
context={readContext}
result={result as HousekeepingResult<PeopleStaffQueryData>}
partialDependencies={partialDependencies}
/>,
),
empty: {
@@ -231,7 +232,7 @@ const cases: readonly PageCase[] = [
];
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(fail("FORBIDDEN", "errors.housekeeping.forbidden", correlationId)),
@@ -250,9 +251,6 @@ describe.each(cases)("People $name page", ({ render, empty, ready }) => {
? (empty as HousekeepingResult<unknown>)
: ok(empty, correlationId);
expect(render(emptyResult)).toContain('data-housekeeping-state="empty"');
expect(render(ok(ready, correlationId), ["secondary"])).toContain(
'data-housekeeping-state="partial"',
);
expect(render(ok(ready, correlationId))).toContain(
'data-housekeeping-state="ready"',
);
@@ -310,7 +308,59 @@ describe("People primary route registration", () => {
/>,
);
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");
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";
import type { HousekeepingPageInput } from "../../../route-handlers";
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 {
readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<PeopleStaffQueryData>;
readonly partialDependencies?: readonly string[];
}
export function PeopleStaffPage({
context,
result,
partialDependencies,
}: PeopleStaffPageProps) {
export function PeopleStaffPage({ context, result }: PeopleStaffPageProps) {
return (
<PeoplePageFrame
title="Staff"
description="Review staff applications and team definitions."
result={result}
partialDependencies={partialDependencies}
isEmpty={(data) => data.page.items.length === 0}
>
{(data) =>
data.kind === "applications" ? (
<ul className="space-y-2">
{data.page.items.map((application) => (
<li
key={application.id}
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
>
<h2>{application.username ?? `User #${application.userId}`}</h2>
<p>{application.content}</p>
{context.has(PERMS.USERS_EDIT) ? (
<span data-housekeeping-command="people.application.decide">
Dismiss application
</span>
) : null}
</li>
))}
</ul>
) : data.kind === "teams" ? (
<ul className="space-y-2">
{data.page.items.map((team) => (
<li
key={team.id}
className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
>
<h2>{team.name}</h2>
<p>{team.jobDescription ?? "No job description"}</p>
{context.has(PERMS.USERS_EDIT) ? (
<span data-housekeeping-command="people.team.change">
Team controls
</span>
) : null}
</li>
))}
</ul>
) : null
}
{(data) => (
<div className="space-y-3">
<form method="get" className="flex gap-2">
<input
name="search"
maxLength={100}
aria-label="Search staff workflow"
/>
<button type="submit">Search</button>
</form>
{data.kind === "applications" ? (
<ul className="space-y-2">
{data.page.items.map((application) => (
<li
key={application.id}
className="space-y-3 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
>
<h2>
{application.username ?? `User #${application.userId}`}
</h2>
<p>{application.content}</p>
{context.has(PERMS.USERS_EDIT) ? (
<PeopleCommandForm
commandId="people.application.decide"
buttonLabel="Dismiss application"
input={{
applicationId: application.id,
decision: "dismiss",
}}
requiresReason
/>
) : null}
</li>
))}
</ul>
) : 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>
);
}
@@ -73,7 +132,10 @@ export async function renderPeopleStaffPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId;
const result =
routeId === "people.staff.applications" || routeId === "people.staff.teams"
? await peopleStaffQuery.run(input.context, { routeId, list: {} })
? await peopleStaffQuery.run(input.context, {
routeId,
list: parsePeopleListInput(input.searchParams ?? {}),
})
: fail(
"NOT_FOUND",
"errors.housekeeping.notFound",
@@ -8,24 +8,22 @@ import {
import type { HousekeepingPageInput } from "../../../route-handlers";
import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users";
import { PeoplePageFrame } from "./page-state";
import { PeopleCommandForm } from "./people-command-form";
interface PeopleUserDetailPageProps {
readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<PeopleUsersQueryData>;
readonly partialDependencies?: readonly string[];
}
export function PeopleUserDetailPage({
context,
result,
partialDependencies,
}: PeopleUserDetailPageProps) {
return (
<PeoplePageFrame
title="User detail"
description="Review account state, permissions, and sanctions."
result={result}
partialDependencies={partialDependencies}
isEmpty={() => false}
>
{(data) =>
@@ -37,12 +35,7 @@ export function PeopleUserDetailPage({
{data.user.username}
</h2>
{context.has(PERMS.USERS_EDIT) ? (
<a
href={`${data.user.href}/edit`}
data-housekeeping-command="people.user.update"
>
Edit
</a>
<a href={`${data.user.href}/edit`}>Edit</a>
) : null}
</div>
<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}
</dl>
<div className="mt-4 flex flex-wrap gap-2 text-xs">
{context.has(PERMS.USERS_BAN) ? (
<span data-housekeeping-command="people.user.ban">Ban</span>
) : null}
{context.has(PERMS.USERS_EDIT) ? (
<span data-housekeeping-command="people.user.disconnect">
Disconnect
</span>
) : null}
{context.has(PERMS.USERS_RESET_PASSWORD) ? (
<span data-housekeeping-command="people.user.reset-password">
Reset password
</span>
) : null}
</div>
</section>
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
<h2 className="font-medium text-[var(--admin-text)]">
@@ -102,6 +80,126 @@ export function PeopleUserDetailPage({
</ul>
)}
</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>
) : null
}
@@ -7,80 +7,74 @@ import {
import type { HousekeepingPageInput } from "../../../route-handlers";
import { type PeopleUsersQueryData, peopleUsersQuery } from "../queries/users";
import { PeoplePageFrame } from "./page-state";
import {
type PeopleCommandField,
PeopleCommandForm,
} from "./people-command-form";
interface PeopleUserEditPageProps {
readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<PeopleUsersQueryData>;
readonly partialDependencies?: readonly string[];
}
export function PeopleUserEditPage({
context: _context,
result,
partialDependencies,
}: PeopleUserEditPageProps) {
export function PeopleUserEditPage({ result }: PeopleUserEditPageProps) {
return (
<PeoplePageFrame
title="Edit user"
description="Update bounded account fields through an audited command."
result={result}
partialDependencies={partialDependencies}
isEmpty={() => false}
>
{(data) =>
data.kind === "user" ? (
<form
data-housekeeping-command="people.user.update"
className="space-y-4 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
>
<input type="hidden" name="userId" value={data.user.id} />
<label className="block text-sm">
Username
<input
name="username"
defaultValue={data.user.username}
maxLength={20}
className="mt-1 block w-full"
/>
</label>
{data.user.mail !== null ? (
<label className="block text-sm">
Email
<input
name="mail"
type="email"
defaultValue={data.user.mail}
className="mt-1 block w-full"
/>
</label>
) : null}
<label className="block text-sm">
Motto
<input
name="motto"
defaultValue={data.user.motto}
maxLength={127}
className="mt-1 block w-full"
/>
</label>
<label className="block text-sm">
Rank
<select
name="rank"
defaultValue={data.user.rank}
className="mt-1 block w-full"
>
{data.user.permission.ranks.map((rank) => (
<option key={rank.id} value={rank.id}>
{rank.name}
</option>
))}
</select>
</label>
<button type="submit">Save user</button>
</form>
) : null
}
{(data) => {
if (data.kind !== "user") return null;
const fields: PeopleCommandField[] = [
{
name: "username",
label: "Username",
type: "text",
defaultValue: data.user.username,
maxLength: 20,
required: true,
},
...(data.user.mail !== null
? [
{
name: "mail",
label: "Email",
type: "text",
defaultValue: data.user.mail,
maxLength: 255,
} as const,
]
: []),
{
name: "motto",
label: "Motto",
type: "text",
defaultValue: data.user.motto,
maxLength: 127,
},
{
name: "rank",
label: "Rank",
type: "select",
defaultValue: data.user.rank,
options: data.user.permission.ranks.map((rank) => ({
value: rank.id,
label: rank.name,
})),
},
];
return (
<PeopleCommandForm
commandId="people.user.update"
buttonLabel="Save user"
input={{ userId: data.user.id }}
fields={fields}
nestFields
/>
);
}}
</PeoplePageFrame>
);
}
@@ -5,72 +5,143 @@ import type {
} from "../../../foundation/contracts";
import type { HousekeepingPageInput } from "../../../route-handlers";
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 {
readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<PeopleUsersQueryData>;
readonly partialDependencies?: readonly string[];
}
export function PeopleUsersPage({
context,
result,
partialDependencies,
}: PeopleUsersPageProps) {
const userIdsField = {
name: "userIds",
label: "User IDs (comma or space separated)",
type: "number-list" as const,
required: true,
maxLength: 1200,
};
export function PeopleUsersPage({ context, result }: PeopleUsersPageProps) {
return (
<PeoplePageFrame
title="Users"
description="Search accounts and open the canonical People workflow."
result={result}
partialDependencies={partialDependencies}
isEmpty={(data) => data.kind === "users" && data.page.items.length === 0}
>
{(data) =>
data.kind === "users" ? (
<section className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4">
<p className="text-sm text-[var(--admin-text-muted)]">
{data.page.total} matching users
</p>
<ul className="mt-3 divide-y divide-[var(--admin-border)]">
{data.page.items.map((user) => (
<li
key={user.id}
className="flex flex-wrap items-center gap-3 py-3 text-sm"
>
<a
className="font-medium text-[var(--admin-text)]"
href={user.href}
<div className="space-y-4">
<form method="get" className="flex flex-wrap gap-2">
<label>
Search users
<input name="search" maxLength={100} className="ml-2" />
</label>
<input type="hidden" name="pageSize" value={data.page.pageSize} />
<button type="submit">Search</button>
</form>
{context.has(PERMS.USERS_EDIT) ? (
<section className="grid gap-3 lg:grid-cols-2">
<PeopleCommandForm
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
href={`${user.href}/edit`}
data-housekeeping-command="people.user.update"
className="font-medium text-[var(--admin-text)]"
href={user.href}
>
Edit
{user.username}
</a>
) : null}
{context.has(PERMS.USERS_BAN) ? (
<span data-housekeeping-command="people.user.ban">
Ban controls available
</span>
) : null}
</li>
))}
</ul>
</section>
<span>Rank {user.rank}</span>
<span>{user.online ? "Online" : "Offline"}</span>
{user.mail !== null ? <span>{user.mail}</span> : null}
{user.ipCurrent !== null ? (
<span>{user.ipCurrent}</span>
) : null}
{context.has(PERMS.USERS_EDIT) ? (
<a href={`${user.href}/edit`}>Edit</a>
) : null}
</li>
))}
</ul>
</section>
</div>
) : null
}
</PeoplePageFrame>
@@ -80,7 +151,7 @@ export function PeopleUsersPage({
export async function renderPeopleUsersPage(input: HousekeepingPageInput) {
const result = await peopleUsersQuery.run(input.context, {
routeId: "people.users.list",
list: {},
list: parsePeopleListInput(input.searchParams ?? {}),
});
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 type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
const { audit, alertUser, selectLimit } = vi.hoisted(() => ({
audit: vi.fn(),
alertUser: vi.fn(),
selectLimit: vi.fn(),
}));
const { audit, alertUser, resolveServerContext, selectLimit } = vi.hoisted(
() => ({
audit: 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/password", () => ({ hashPassword: vi.fn() }));
vi.mock("@/lib/db", async (importOriginal) => {
@@ -62,6 +68,7 @@ const target = {
beforeEach(() => {
vi.clearAllMocks();
resolveServerContext.mockResolvedValue(capabilityContext([]));
selectLimit.mockResolvedValue([target]);
alertUser.mockResolvedValue(true);
audit.mockResolvedValue(undefined);
@@ -70,7 +77,7 @@ beforeEach(() => {
describe("People production mutation boundary", () => {
it("rechecks authorization before any production adapter work", async () => {
const result = await peopleMutationService.execute(
{ capability: capabilityContext([]), correlationId: "production-denied" },
{ expectedActorId: 42, correlationId: "production-denied" },
"user.alert",
{ 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 () => {
resolveServerContext.mockResolvedValue(
capabilityContext([PERMS.USERS_EDIT]),
);
const result = await peopleMutationService.execute(
{
capability: capabilityContext([PERMS.USERS_EDIT]),
correlationId: "production-alert",
},
{ expectedActorId: 42, correlationId: "production-alert" },
"user.alert",
{ 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"));
const result = await peopleMutationService.execute(
{
capability: capabilityContext([PERMS.USERS_EDIT]),
correlationId: "production-audit-failure",
},
{ expectedActorId: 42, correlationId: "production-audit-failure" },
"user.alert",
{ userId: 7, message: "Hello" },
);
expect(alertUser).toHaveBeenCalled();
expect(alertUser).not.toHaveBeenCalled();
expect(result).toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
@@ -2,17 +2,39 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
const { audit, disconnectUser, insertValues, selectLimit } = vi.hoisted(() => ({
const {
audit,
disconnectUser,
insertValues,
resolveServerContext,
selectLimit,
transaction,
txSelectLimit,
} = vi.hoisted(() => ({
audit: vi.fn(),
disconnectUser: vi.fn(),
insertValues: vi.fn(),
resolveServerContext: 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/password", () => ({ hashPassword: vi.fn() }));
vi.mock("@/lib/db", async (importOriginal) => {
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 {
...actual,
db: {
@@ -22,7 +44,9 @@ vi.mock("@/lib/db", async (importOriginal) => {
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")>()),
rcon: { disconnectUser },
}));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
vi.mock("@/lib/services/webhook", () => ({ notify: vi.fn() }));
import { peopleMutationService } from "./mutations";
function capabilityContext(
granted: readonly string[],
): HousekeepingCapabilityContext {
const permissions = new Set(granted);
function capabilityContext(): HousekeepingCapabilityContext {
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)),
has: (slug) => slug === PERMS.USERS_BAN,
hasAny: (...slugs) => slugs.includes(PERMS.USERS_BAN),
hasAll: (...slugs) => slugs.every((slug) => slug === PERMS.USERS_BAN),
};
}
beforeEach(() => {
vi.clearAllMocks();
resolveServerContext.mockResolvedValue(capabilityContext());
selectLimit.mockResolvedValue([
{
id: 7,
username: "Alice",
mail: "[email protected]",
rank: 2,
motto: "Ready",
credits: 100,
@@ -65,18 +87,16 @@ beforeEach(() => {
online: "1",
},
]);
txSelectLimit.mockResolvedValue([]);
insertValues.mockResolvedValue([{}]);
disconnectUser.mockResolvedValue(true);
audit.mockResolvedValue(undefined);
});
describe("People production reason audit", () => {
it("persists the nonblank sanction reason with before and after evidence", async () => {
it("persists the nonblank sanction reason with observed before and after evidence", async () => {
const result = await peopleMutationService.execute(
{
capability: capabilityContext([PERMS.USERS_BAN]),
correlationId: "production-ban",
},
{ expectedActorId: 42, correlationId: "production-ban" },
"user.ban",
{
userId: 7,
@@ -87,9 +107,11 @@ describe("People production reason audit", () => {
);
expect(result).toMatchObject({ ok: true });
expect(transaction).toHaveBeenCalledTimes(1);
expect(audit).toHaveBeenCalledWith(
expect.objectContaining({
action: "people.user.ban",
outcome: "intent",
before: expect.objectContaining({ id: 7, banned: false }),
after: expect.objectContaining({
id: 7,
@@ -97,6 +119,7 @@ describe("People production reason audit", () => {
reason: "Repeated harassment",
}),
}),
expect.anything(),
);
expect(audit.mock.calls[0]?.[0]?.before).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",
new Set([
"src/features/housekeeping/domains/people/pages/people-command-form",
"src/features/housekeeping/domains/people/pages/page-state",
"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",
new Set([
"src/features/housekeeping/domains/people/pages/people-command-form",
"src/features/housekeeping/domains/people/pages/page-state",
"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",
new Set([
"src/features/housekeeping/domains/people/pages/people-command-form",
"src/features/housekeeping/domains/people/pages/page-state",
"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",
new Set([
"src/features/housekeeping/domains/people/pages/people-command-form",
"src/features/housekeeping/domains/people/pages/page-state",
"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",
new Set([
"src/features/housekeeping/domains/people/pages/people-command-form",
"src/features/housekeeping/domains/people/pages/page-state",
"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",
new Set([
"src/features/housekeeping/domains/people/pages/people-command-form",
"src/features/housekeeping/domains/people/pages/page-state",
"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",
new Set([
@@ -7,6 +7,9 @@ import type { HousekeepingRouteMatch } from "./foundation/routing/match-route";
export interface HousekeepingPageInput {
readonly context: HousekeepingCapabilityContext;
readonly match: HousekeepingRouteMatch;
readonly searchParams?: Readonly<
Record<string, string | readonly string[] | undefined>
>;
}
export interface HousekeepingRouteHandler {
+11 -7
View File
@@ -136,13 +136,17 @@ describe("staff smoke contract", () => {
}
});
it("exports bulk adjust currency and trade lock", () => {
const src = readFileSync("src/actions/bulk-users.ts", "utf8");
expect(src).toContain("bulkAdjustCurrency");
expect(src).toContain("setTradeLock");
expect(src).toContain("tradeLockedUntil");
expect(src).toContain("UsersSettings");
expect(src).toContain("rcon.setTradeLock");
it("exports bulk adjust currency and keeps trade-lock DB/RCON behavior", () => {
const wrapper = readFileSync("src/actions/bulk-users.ts", "utf8");
const service = readFileSync(
"src/features/housekeeping/domains/people/services/mutations.ts",
"utf8",
);
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", () => {