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
File diff suppressed because it is too large. Load diff
@@ -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,
File diff suppressed because it is too large. Load diff
@@ -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>
);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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" },
File diff suppressed because it is too large. Load diff
@@ -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();
});
});
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", () => {