diff --git a/.superpowers/sdd/2026-08-26-housekeeping-completion/task-12-report.md b/.superpowers/sdd/2026-08-26-housekeeping-completion/task-12-report.md index 365e93161b..fb047c7e56 100644 --- a/.superpowers/sdd/2026-08-26-housekeeping-completion/task-12-report.md +++ b/.superpowers/sdd/2026-08-26-housekeeping-completion/task-12-report.md @@ -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. \ No newline at end of file diff --git a/src/actions/admin-applications.ts b/src/actions/admin-applications.ts index 7d58ec284f..382c396260 100644 --- a/src/actions/admin-applications.ts +++ b/src/actions/admin-applications.ts @@ -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 { if (!Number.isSafeInteger(applicationId) || applicationId <= 0) return; await peopleMutationService.execute( - createLegacyPeopleMutationContext( - staff, - PERMS.USERS_EDIT, - createCorrelationId(), - ), + createPeopleMutationInvocation(staff, createCorrelationId()), "application.decide", { applicationId, decision: "dismiss" }, ); diff --git a/src/actions/admin-guilds.test.ts b/src/actions/admin-guilds.test.ts index 8a9634758d..a8f0ce8e3c 100644 --- a/src/actions/admin-guilds.test.ts +++ b/src/actions/admin-guilds.test.ts @@ -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() })); diff --git a/src/actions/admin-guilds.ts b/src/actions/admin-guilds.ts index 4c043f8b52..9ed33159e8 100644 --- a/src/actions/admin-guilds.ts +++ b/src/actions/admin-guilds.ts @@ -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 { if (!Number.isSafeInteger(guildId) || guildId <= 0) return; const result = await peopleMutationService.execute( - createLegacyPeopleMutationContext( - staff, - PERMS.USERS_EDIT, - createCorrelationId(), - ), + createPeopleMutationInvocation(staff, createCorrelationId()), "guild.disband", { guildId }, ); diff --git a/src/actions/admin-ip.test.ts b/src/actions/admin-ip.test.ts index 0376e9fb41..e74d130892 100644 --- a/src/actions/admin-ip.test.ts +++ b/src/actions/admin-ip.test.ts @@ -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() })); diff --git a/src/actions/admin-ip.ts b/src/actions/admin-ip.ts index 7c80a908e0..2e625a47d3 100644 --- a/src/actions/admin-ip.ts +++ b/src/actions/admin-ip.ts @@ -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, ); diff --git a/src/actions/admin-teams.test.ts b/src/actions/admin-teams.test.ts index 1e73765167..3eb5b0b8ce 100644 --- a/src/actions/admin-teams.test.ts +++ b/src/actions/admin-teams.test.ts @@ -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() })); diff --git a/src/actions/admin-teams.ts b/src/actions/admin-teams.ts index e380c14044..ae214e066e 100644 --- a/src/actions/admin-teams.ts +++ b/src/actions/admin-teams.ts @@ -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 { 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 { 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 }, ); diff --git a/src/actions/admin-vpn.test.ts b/src/actions/admin-vpn.test.ts index f0d187d818..6f94ea5068 100644 --- a/src/actions/admin-vpn.test.ts +++ b/src/actions/admin-vpn.test.ts @@ -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() })); diff --git a/src/actions/admin-vpn.ts b/src/actions/admin-vpn.ts index c1b93425cf..1f0c99a591 100644 --- a/src/actions/admin-vpn.ts +++ b/src/actions/admin-vpn.ts @@ -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 { .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() !== "", diff --git a/src/actions/admin-wordfilter.ts b/src/actions/admin-wordfilter.ts index a95bfb0b4d..0af1ad0990 100644 --- a/src/actions/admin-wordfilter.ts +++ b/src/actions/admin-wordfilter.ts @@ -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 { 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 }, ); diff --git a/src/actions/bulk-adjust-wrapper.test.ts b/src/actions/bulk-adjust-wrapper.test.ts index 05c48dee39..e06238aaa5 100644 --- a/src/actions/bulk-adjust-wrapper.test.ts +++ b/src/actions/bulk-adjust-wrapper.test.ts @@ -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() })); diff --git a/src/actions/bulk-users.test.ts b/src/actions/bulk-users.test.ts index 009e1fa72b..92e712e68f 100644 --- a/src/actions/bulk-users.test.ts +++ b/src/actions/bulk-users.test.ts @@ -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 }, ); diff --git a/src/actions/bulk-users.ts b/src/actions/bulk-users.ts index 771025338d..400115dfea 100644 --- a/src/actions/bulk-users.ts +++ b/src/actions/bulk-users.ts @@ -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 } }; } diff --git a/src/actions/people-shared-wrappers.test.ts b/src/actions/people-shared-wrappers.test.ts index de1ecc9d50..62fd2a9d42 100644 --- a/src/actions/people-shared-wrappers.test.ts +++ b/src/actions/people-shared-wrappers.test.ts @@ -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" }, ); diff --git a/src/actions/people-wrapper-errors.test.ts b/src/actions/people-wrapper-errors.test.ts index 301e55b31f..f4d3103b77 100644 --- a/src/actions/people-wrapper-errors.test.ts +++ b/src/actions/people-wrapper-errors.test.ts @@ -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", () => ({ diff --git a/src/actions/users.ts b/src/actions/users.ts index 913475311a..f6d927246c 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -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> = { 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(); }, ); diff --git a/src/app/ase-next/[domain]/[[...segments]]/loading.tsx b/src/app/ase-next/[domain]/[[...segments]]/loading.tsx new file mode 100644 index 0000000000..0c98abbf33 --- /dev/null +++ b/src/app/ase-next/[domain]/[[...segments]]/loading.tsx @@ -0,0 +1,13 @@ +import { HousekeepingPageState } from "@/features/housekeeping/foundation/page/housekeeping-page-state"; + +export default function HousekeepingPreviewLoading() { + return ( +
+ +
+ ); +} diff --git a/src/app/ase-next/[domain]/[[...segments]]/page.tsx b/src/app/ase-next/[domain]/[[...segments]]/page.tsx index e6e33ecf36..937b06221c 100644 --- a/src/app/ase-next/[domain]/[[...segments]]/page.tsx +++ b/src/app/ase-next/[domain]/[[...segments]]/page.tsx @@ -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>; }) { 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 } : {}), + }); } diff --git a/src/features/housekeeping/domains/people/commands/community-commands.ts b/src/features/housekeeping/domains/people/commands/community-commands.ts index b082d0451b..076968eff6 100644 --- a/src/features/housekeeping/domains/people/commands/community-commands.ts +++ b/src/features/housekeeping/domains/people/commands/community-commands.ts @@ -43,8 +43,8 @@ function communityCommand( execute: (context, input) => service.execute( { - capability: context.capability, correlationId: context.correlationId, + expectedActorId: context.capability.actor.id, }, options.operation, input, diff --git a/src/features/housekeeping/domains/people/commands/user-commands.test.ts b/src/features/housekeeping/domains/people/commands/user-commands.test.ts index 67059d0b1f..a104b472ed 100644 --- a/src/features/housekeeping/domains/people/commands/user-commands.test.ts +++ b/src/features/housekeeping/domains/people/commands/user-commands.test.ts @@ -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" } }, ); diff --git a/src/features/housekeeping/domains/people/commands/user-commands.ts b/src/features/housekeeping/domains/people/commands/user-commands.ts index 88e95d60c4..5c6989e63a 100644 --- a/src/features/housekeeping/domains/people/commands/user-commands.ts +++ b/src/features/housekeeping/domains/people/commands/user-commands.ts @@ -53,8 +53,8 @@ function userCommand( execute: (context, input) => service.execute( { - capability: context.capability, correlationId: context.correlationId, + expectedActorId: context.capability.actor.id, }, options.operation, input, diff --git a/src/features/housekeeping/domains/people/pages/community.tsx b/src/features/housekeeping/domains/people/pages/community.tsx index fcb1eee9bd..c512ac564d 100644 --- a/src/features/housekeeping/domains/people/pages/community.tsx +++ b/src/features/housekeeping/domains/people/pages/community.tsx @@ -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; - readonly partialDependencies?: readonly string[]; } function empty(data: PeopleCommunityQueryData) { return data.kind !== "guild" && data.page.items.length === 0; } +function SearchForm() { + return ( +
+ + +
+ ); +} + export function PeopleCommunityPage({ context, result, - partialDependencies, }: PeopleCommunityPageProps) { return ( {(data) => { if (data.kind === "online") return ( -
    - {data.page.items.map((user) => ( -
  • - {user.username} - {user.motto} -
  • - ))} -
+
+ +
    + {data.page.items.map((user) => ( +
  • + {user.username} - {user.motto} +
  • + ))} +
+
); if (data.kind === "guilds") return ( -
    - {data.page.items.map((guild) => ( -
  • - {guild.name} -

    {guild.memberCount} members

    -
  • - ))} -
+
+ +
    + {data.page.items.map((guild) => ( +
  • + {guild.name} +

    {guild.memberCount} members

    +
  • + ))} +
+
); return ( -
+

{data.guild.name}

{data.guild.description}

    @@ -72,9 +85,12 @@ export function PeopleCommunityPage({ ))}
{context.has(PERMS.USERS_EDIT) ? ( - - Disband guild - + ) : null}
); @@ -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", diff --git a/src/features/housekeeping/domains/people/pages/multi-accounts.tsx b/src/features/housekeeping/domains/people/pages/multi-accounts.tsx index 4f78380709..9085af3aac 100644 --- a/src/features/housekeeping/domains/people/pages/multi-accounts.tsx +++ b/src/features/housekeeping/domains/people/pages/multi-accounts.tsx @@ -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; - readonly partialDependencies?: readonly string[]; } export function PeopleMultiAccountsPage({ context: _context, result, - partialDependencies, }: PeopleMultiAccountsPageProps) { return ( data.kind === "multi-accounts" && data.page.items.length === 0 } > {(data) => data.kind === "multi-accounts" ? ( -
    - {data.page.items.map((cluster) => ( -
  • -

    - {cluster.key} -

    -

    - {cluster.accountCount} accounts -

    - -
  • - ))} -
+
+
+ + +
+
    + {data.page.items.map((cluster) => ( +
  • +

    + {cluster.key} +

    +

    + {cluster.accountCount} accounts +

    + +
  • + ))} +
+
) : null }
@@ -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 ; } diff --git a/src/features/housekeeping/domains/people/pages/page-state.tsx b/src/features/housekeeping/domains/people/pages/page-state.tsx index a33575b562..44c5d10d71 100644 --- a/src/features/housekeeping/domains/people/pages/page-state.tsx +++ b/src/features/housekeeping/domains/people/pages/page-state.tsx @@ -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 +>; + +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 { readonly title: string; readonly description: string; readonly result?: HousekeepingResult; - readonly partialDependencies?: readonly string[]; readonly isEmpty: (data: T) => boolean; readonly children: (data: T) => ReactNode; } @@ -32,7 +80,6 @@ export function PeoplePageFrame({ title, description, result, - partialDependencies = [], isEmpty, children, }: PeoplePageFrameProps) { @@ -76,20 +123,7 @@ export function PeoplePageFrame({ ); } else { body = ( -
0 ? "partial" : "ready" - } - className="space-y-4" - > - {partialDependencies.length > 0 ? ( - - ) : null} +
{children(result.data)}
); diff --git a/src/features/housekeeping/domains/people/pages/people-command-form.tsx b/src/features/housekeeping/domains/people/pages/people-command-form.tsx new file mode 100644 index 0000000000..9001026526 --- /dev/null +++ b/src/features/housekeeping/domains/people/pages/people-command-form.tsx @@ -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>; + 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 | null = null; + +export function PeopleCommandForm({ + commandId, + buttonLabel, + input, + fields = [], + requiresReason = false, + includeReasonInInput = false, + nestFields = false, +}: PeopleCommandFormProps) { + const [result, submit, pending] = useActionState( + async ( + _previous: HousekeepingResult | 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 ( +
+ {fields.map((field) => ( + + ))} + {requiresReason ? ( +