fix(housekeeping): address people moderation review

This commit is contained in:
Simo committed 2026-08-29 23:53:38 +02:00
1 parent 29fe22297b
commit 3fa1119f5a
20 files changed
+2000 -777

No files matched your search

+12 -1
View File
@@ -10,10 +10,21 @@ import { createCorrelationId } from "@/features/housekeeping/foundation/contract
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared";
import { canonicalTicketId } from "@/lib/services/ticket-replies";
const ticketIdField = z
.union([z.string(), z.number(), z.bigint()])
.transform((v) => BigInt(String(v)));
.transform((value, context) => {
try {
return canonicalTicketId(value);
} catch {
context.addIssue({
code: "custom",
message: "Invalid ticket identifier",
});
return z.NEVER;
}
});
const replyHelpCenterTicketSchema = z.object({
ticketId: ticketIdField,
+29 -30
View File
@@ -29,6 +29,17 @@ async function execute(
);
}
async function executeLegacyModerationAction(
staff: { readonly id: number },
input: unknown,
) {
const result = await execute(staff, "moderation.action", input);
if (!result.ok) {
throw new Error("Could not execute moderation action");
}
return actionOk();
}
export const assignCfhTicket = adminAction(
{ permission: CFH_PERM, schema: cfhIdSchema },
async (ctx) => {
@@ -85,13 +96,11 @@ const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
export const quickKick = adminAction(
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
async (ctx) => {
await execute(ctx.session.user, "moderation.action", {
(ctx) =>
executeLegacyModerationAction(ctx.session.user, {
action: "kick",
userId: ctx.data.userId,
});
return actionOk();
},
}),
);
const muteSchema = z.object({
@@ -101,24 +110,20 @@ const muteSchema = z.object({
export const quickMute = adminAction(
{ permission: MOD_ACTION_PERM, schema: muteSchema },
async (ctx) => {
await execute(ctx.session.user, "moderation.action", {
(ctx) =>
executeLegacyModerationAction(ctx.session.user, {
action: "mute",
...ctx.data,
});
return actionOk();
},
}),
);
export const quickUnmute = adminAction(
{ permission: MOD_ACTION_PERM, schema: userIdSchema },
async (ctx) => {
await execute(ctx.session.user, "moderation.action", {
(ctx) =>
executeLegacyModerationAction(ctx.session.user, {
action: "unmute",
userId: ctx.data.userId,
});
return actionOk();
},
}),
);
const alertSchema = z.object({
@@ -128,26 +133,22 @@ const alertSchema = z.object({
export const quickAlert = adminAction(
{ permission: MOD_ACTION_PERM, schema: alertSchema },
async (ctx) => {
await execute(ctx.session.user, "moderation.action", {
(ctx) =>
executeLegacyModerationAction(ctx.session.user, {
action: "alert",
...ctx.data,
});
return actionOk();
},
}),
);
const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() });
export const quickRoomKick = adminAction(
{ permission: MOD_ACTION_PERM, schema: roomIdSchema },
async (ctx) => {
await execute(ctx.session.user, "moderation.action", {
(ctx) =>
executeLegacyModerationAction(ctx.session.user, {
action: "room-kick",
roomId: ctx.data.roomId,
});
return actionOk();
},
}),
);
const broadcastSchema = z.object({
@@ -157,11 +158,9 @@ const broadcastSchema = z.object({
export const broadcastAlert = adminAction(
{ permission: MOD_ACTION_PERM, schema: broadcastSchema },
async (ctx) => {
await execute(ctx.session.user, "moderation.action", {
(ctx) =>
executeLegacyModerationAction(ctx.session.user, {
action: "broadcast",
...ctx.data,
});
return actionOk();
},
}),
);
@@ -8,11 +8,34 @@ const { execute, registrations, staff } = vi.hoisted(() => ({
}));
function wrapper(
options: { permission: string | readonly string[] },
options: {
permission: string | readonly string[];
schema?: {
safeParse(value: unknown):
| { success: true; data: unknown }
| { success: false; error: unknown };
};
},
handler: (context: { data: unknown; session: { user: typeof staff } }) => unknown,
) {
registrations.push(options);
return (data: unknown) => handler({ data, session: { user: staff } });
return async (data: unknown) => {
const parsed = options.schema?.safeParse(data);
if (parsed && !parsed.success) {
return { ok: false, error: "Validation failed" };
}
try {
return await handler({
data: parsed?.data ?? data,
session: { user: staff },
});
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : "Internal server error",
};
}
};
}
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
@@ -202,6 +225,64 @@ describe("legacy People support and moderation wrappers", () => {
);
});
it.each([
["kick", quickKick, { userId: 7 }],
["mute", quickMute, { userId: 7, duration: 60 }],
["unmute", quickUnmute, { userId: 7 }],
["alert", quickAlert, { userId: 7, message: "Stop" }],
["room kick", quickRoomKick, { roomId: 12 }],
["broadcast", broadcastAlert, { message: "Notice", type: "staff" }],
] as const)(
"maps a non-ok %s service result to the historical legacy failure boundary",
async (_label, action, input) => {
execute.mockResolvedValueOnce({
ok: false,
error: {
code: "DEPENDENCY_UNAVAILABLE",
messageKey: "errors.housekeeping.dependencyUnavailable",
},
correlationId: "quick-action-failure",
});
await expect(call(action, input)).resolves.toEqual({
ok: false,
error: "Could not execute moderation action",
});
},
);
it("maps a thrown moderation service failure instead of reporting success", async () => {
execute.mockRejectedValueOnce(new Error("RCON unavailable"));
await expect(call(quickKick, { userId: 7 })).resolves.toEqual({
ok: false,
error: "RCON unavailable",
});
});
it.each([
9_007_199_254_740_992,
"01",
"0",
0,
-1,
"18446744073709551616",
] as const)(
"rejects noncanonical help-ticket identifier %s at every legacy action schema",
async (ticketId) => {
for (const [action, input] of [
[replyHelpCenterTicket, { ticketId, content: "Handled" }],
[closeHelpCenterTicket, { ticketId }],
[reopenHelpCenterTicket, { ticketId }],
[liftBanFromHelpTicket, { ticketId }],
] as const) {
await expect(call(action, input)).resolves.toEqual({
ok: false,
error: "Validation failed",
});
}
expect(execute).not.toHaveBeenCalled();
},
);
it("keeps close-CFH missing rows as a successful legacy no-op", async () => {
execute.mockResolvedValueOnce({
ok: false,
@@ -2,6 +2,7 @@ import "server-only";
import { z } from "zod";
import { PERMS } from "@/lib/permission-slugs";
import { CANONICAL_TICKET_ID_PATTERN } from "@/lib/services/ticket-replies";
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
import { anyCapability } from "../../../foundation/contracts";
import {
@@ -24,26 +25,7 @@ export const SUPPORT_COMMAND_IDS = [
type SupportCommandId = (typeof SUPPORT_COMMAND_IDS)[number];
const positiveId = z.number().int().positive();
const text = (max: number) => z.string().min(1).max(max).regex(/\S/u);
const MAX_UNSIGNED_BIGINT = "18446744073709551615";
function boundedDecimalPattern(maximum: string): RegExp {
const alternatives = [`[1-9]\\d{0,${maximum.length - 2}}`];
for (let index = 0; index < maximum.length; index += 1) {
const maximumDigit = Number(maximum[index]);
const minimumDigit = index === 0 ? 1 : 0;
const upperDigit = maximumDigit - 1;
if (upperDigit < minimumDigit) continue;
const digit =
upperDigit === minimumDigit
? String(minimumDigit)
: `[${minimumDigit}-${upperDigit}]`;
alternatives.push(
`${maximum.slice(0, index)}${digit}\\d{${maximum.length - index - 1}}`,
);
}
alternatives.push(maximum);
return new RegExp(`^(?:${alternatives.join("|")})$`, "u");
}
const bigintId = z.string().regex(boundedDecimalPattern(MAX_UNSIGNED_BIGINT));
const bigintId = z.string().regex(CANONICAL_TICKET_ID_PATTERN);
function command<I>(
service: Pick<PeopleMutationService, "execute">,
@@ -12,6 +12,7 @@ import {
type HousekeepingWorkItem,
ok,
} from "../../foundation/contracts";
import { isSafeHousekeepingHref } from "../../foundation/housekeeping-href";
import { peopleModerationQuery } from "./queries/moderation";
import { peopleSupportQuery } from "./queries/support";
@@ -41,17 +42,6 @@ export interface PeopleInboxAdapters {
): Promise<readonly HousekeepingWorkItem[]>;
}
function safeHref(href: string): boolean {
return (
href.startsWith("/ase/") &&
!href.includes("\\") &&
!Array.from(href).some((character) => {
const code = character.codePointAt(0) ?? 0;
return code < 32 || code === 127;
})
);
}
function createSource(
id: (typeof PEOPLE_INBOX_SOURCE_IDS)[number],
capability: CapabilityRequirement,
@@ -72,7 +62,7 @@ function createSource(
items: items
.filter(
(item) =>
safeHref(item.href) &&
isSafeHousekeepingHref(item.href) &&
satisfiesCapability(context, item.capability),
)
.slice(0, 25),
@@ -1,14 +1,23 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { ok } from "../../../foundation/contracts";
import { PeopleCfhDetailPage } from "./cfh-detail";
import { PeopleHelpTicketDetailPage } from "./help-ticket-detail";
import { PeopleModerationPage } from "./moderation";
import { PeopleSupportPage } from "./support";
import { submitPeopleCommandForm } from "./people-command-form";
import {
PeopleSupportPage,
ticketTemplateUpdateSubmission,
} from "./support";
import { PeopleTicketDetailPage } from "./ticket-detail";
const executeHousekeepingCommand = vi.hoisted(() => vi.fn());
vi.mock("@/actions/housekeeping-command", () => ({
executeHousekeepingCommand,
}));
function context(granted: readonly string[]): HousekeepingCapabilityContext {
const permissions = new Set(granted);
return {
@@ -180,6 +189,86 @@ describe("People support/moderation pages", () => {
expect(html).not.toContain("/mod/");
});
it("renders and submits a capability-gated template update with current defaults", async () => {
const template = {
id: 88,
title: "Greeting",
content: "Hello",
category: "general",
sortOrder: 4,
};
const html = renderToStaticMarkup(
<PeopleSupportPage
context={context([PERMS.TICKETS_EDIT])}
result={ok(
{
kind: "ticket-templates" as const,
page: {
items: [template],
total: 1,
pageSize: 20,
offset: 0,
},
},
"templates",
)}
/>
);
expect(html.match(/data-housekeeping-command="people\.ticket-template\.change"/gu)).toHaveLength(3);
expect(html).toContain("Update template");
expect(html).toContain('value="Greeting"');
expect(html).toContain('value="Hello"');
expect(html).toContain('value="general"');
expect(html).toContain('value="4"');
executeHousekeepingCommand.mockResolvedValue({
ok: true,
data: { before: template, after: { ...template, title: "Updated" } },
correlationId: "template-update",
});
const formData = new FormData();
formData.set("title", "Updated");
formData.set("content", "Updated content");
formData.set("category", "appeals");
formData.set("sortOrder", "7");
await submitPeopleCommandForm(
ticketTemplateUpdateSubmission(template),
null,
formData,
);
expect(executeHousekeepingCommand).toHaveBeenCalledWith({
commandId: "people.ticket-template.change",
input: {
action: "update",
id: 88,
title: "Updated",
content: "Updated content",
category: "appeals",
sortOrder: 7,
},
});
const readOnly = renderToStaticMarkup(
<PeopleSupportPage
context={context([PERMS.TICKETS_VIEW])}
result={ok(
{
kind: "ticket-templates" as const,
page: {
items: [template],
total: 1,
pageSize: 20,
offset: 0,
},
},
"read-only-templates",
)}
/>
);
expect(readOnly).not.toContain("Update template");
expect(readOnly).not.toContain("Delete template");
});
it("renders the loading state before data arrives", () => {
expect(
renderToStaticMarkup(<PeopleSupportPage context={context([])} />),
@@ -11,7 +11,10 @@ import {
peopleSupportQuery,
} from "../queries/support";
import { PeoplePageFrame, parsePeopleListInput } from "./page-state";
import { PeopleCommandForm } from "./people-command-form";
import {
PeopleCommandForm,
type PeopleCommandSubmission,
} from "./people-command-form";
interface Props {
readonly context: HousekeepingCapabilityContext;
@@ -34,6 +37,52 @@ function Queue({ queue }: { readonly queue: { tickets: number; helpTickets: numb
);
}
type PeopleTicketTemplate = Extract<
PeopleSupportQueryData,
{ readonly kind: "ticket-templates" }
>["page"]["items"][number];
export function ticketTemplateUpdateSubmission(
template: PeopleTicketTemplate,
): PeopleCommandSubmission {
return {
commandId: "people.ticket-template.change",
input: { action: "update", id: template.id },
fields: [
{
name: "title",
label: "Title",
type: "text",
required: true,
maxLength: 255,
defaultValue: template.title,
},
{
name: "content",
label: "Content",
type: "text",
required: true,
maxLength: 5_000,
defaultValue: template.content,
},
{
name: "category",
label: "Category",
type: "text",
maxLength: 50,
defaultValue: template.category,
},
{
name: "sortOrder",
label: "Sort order",
type: "number",
min: 0,
defaultValue: template.sortOrder,
},
],
};
}
export function PeopleSupportPage({ context, result }: Props) {
return (
<PeoplePageFrame
@@ -66,11 +115,17 @@ export function PeopleSupportPage({ context, result }: Props) {
<h2>{template.title}</h2>
<p>{template.content}</p>
{context.has(PERMS.TICKETS_EDIT) ? (
<PeopleCommandForm
commandId="people.ticket-template.change"
buttonLabel="Delete template"
input={{ action: "delete", id: template.id }}
/>
<div className="space-y-3">
<PeopleCommandForm
{...ticketTemplateUpdateSubmission(template)}
buttonLabel="Update template"
/>
<PeopleCommandForm
commandId="people.ticket-template.change"
buttonLabel="Delete template"
input={{ action: "delete", id: template.id }}
/>
</div>
) : null}
</li>
))}
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
import { anyCapability } from "../../foundation/contracts";
@@ -11,7 +11,10 @@ import {
createPeopleSearchProviders,
PEOPLE_SEARCH_PROVIDER_IDS,
} from "./search";
import { createPeopleWidgets } from "./widgets";
import {
createPeopleQueueAggregateLoader,
createPeopleWidgets,
} from "./widgets";
function context(granted: readonly string[]): HousekeepingCapabilityContext {
const permissions = new Set(granted);
@@ -66,6 +69,76 @@ describe("People providers", () => {
expect(result.data.map((item) => item.id)).not.toContain("candidate-1");
});
it("confines normalized search and inbox hrefs to Housekeeping", async () => {
const hrefs = [
"/ase/../admin",
"/ase/%2e%2e/admin",
"/ase/%2e%2e%2fadmin",
"/ase\\..\\admin",
"/ase/%5c../admin",
"//example.invalid/ase/people",
"https://example.invalid/ase/people",
"/ase/people/users/7?tab=profile#security",
"/ase?view=queue#top",
] as const;
const candidates = hrefs.map((href, index) => ({
id: `candidate-${index}`,
title: `Candidate ${index}`,
href,
capability: anyCapability(PERMS.MOD_USERS_VIEW),
}));
const search = await createPeopleSearchProviders({
users: async () => candidates,
guilds: async () => [],
tickets: async () => [],
})[0].search(context([PERMS.MOD_USERS_VIEW]), {
term: "candidate",
limit: 25,
});
expect(search).toMatchObject({
ok: true,
data: [
{ id: "candidate-7", href: "/ase/people/users/7?tab=profile#security" },
{ id: "candidate-8", href: "/ase?view=queue#top" },
],
});
const items = candidates.map((candidate, index) => ({
sourceId: "people.tickets",
itemId: String(index),
deduplicationKey: `ticket:${index}`,
domain: "people" as const,
capability: anyCapability(PERMS.MOD_TICKETS_VIEW),
severity: "info" as const,
priority: "normal" as const,
ageMs: 0,
state: "open",
occurredAt: "2026-08-29T00:00:00.000Z",
titleKey: "pages.housekeeping.items.ticket",
href: candidate.href as `/ase/${string}`,
freshness: "fresh" as const,
actions: [],
}));
const inbox = await createPeopleInboxSources({
tickets: async () => items,
helpTickets: async () => [],
cfh: async () => [],
activeBans: async () => [],
})[0].getItems(
context([PERMS.MOD_TICKETS_VIEW]),
new AbortController().signal,
);
expect(inbox).toMatchObject({
ok: true,
data: {
items: [
{ itemId: "7", href: "/ase/people/users/7?tab=profile#security" },
{ itemId: "8", href: "/ase?view=queue#top" },
],
},
});
});
it("bounds inbox sources and loads the mandatory real queue widget", async () => {
const items = Array.from({ length: 40 }, (_, index) => ({
sourceId: "people.tickets",
@@ -123,4 +196,64 @@ describe("People providers", () => {
data: { tickets: 1, helpTickets: 2, cfh: 0, activeBans: 0 },
});
});
it("loads only capability-authorized queue aggregates for every union context", async () => {
const support = vi.fn(async () => ({ tickets: 1, helpTickets: 2 }));
const cfh = vi.fn(async () => 3);
const activeBans = vi.fn(async () => 4);
const loader = createPeopleQueueAggregateLoader({
support,
cfh,
activeBans,
});
const signal = new AbortController().signal;
const cases = [
{
name: "ticket-only",
granted: [PERMS.MOD_TICKETS_VIEW],
want: { tickets: 1, helpTickets: 2, cfh: 0, activeBans: 0 },
calls: [1, 0, 0],
},
{
name: "CFH-only",
granted: [PERMS.MOD_CFH_VIEW],
want: { tickets: 0, helpTickets: 0, cfh: 3, activeBans: 0 },
calls: [0, 1, 0],
},
{
name: "ban-only",
granted: [PERMS.MOD_BANS_VIEW],
want: { tickets: 0, helpTickets: 0, cfh: 0, activeBans: 4 },
calls: [0, 0, 1],
},
{
name: "mixed",
granted: [
PERMS.MOD_TICKETS_VIEW,
PERMS.MOD_CFH_VIEW,
PERMS.MOD_BANS_VIEW,
],
want: { tickets: 1, helpTickets: 2, cfh: 3, activeBans: 4 },
calls: [1, 1, 1],
},
] as const;
for (const entry of cases) {
vi.clearAllMocks();
const widget = createPeopleWidgets({ queue: loader })[0];
await expect(widget.load(context(entry.granted), signal)).resolves.toMatchObject({
ok: true,
data: entry.want,
});
expect(
[support.mock.calls.length, cfh.mock.calls.length, activeBans.mock.calls.length],
entry.name,
).toEqual(entry.calls);
}
vi.clearAllMocks();
await expect(
createPeopleWidgets({ queue: loader })[0].load(context([]), signal),
).resolves.toMatchObject({ ok: false, error: { code: "FORBIDDEN" } });
expect([support, cfh, activeBans].every((load) => load.mock.calls.length === 0)).toBe(true);
});
});
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
const mocks = vi.hoisted(() => ({
execute: vi.fn(),
fetchTicketQueueOpenCounts: vi.fn(),
}));
vi.mock("@/lib/admin/ticket-queue-counts", () => ({
fetchTicketQueueOpenCounts: mocks.fetchTicketQueueOpenCounts,
}));
vi.mock("@/lib/db", () => {
return { db: { execute: mocks.execute } };
});
import { PEOPLE_WIDGETS } from "./widgets";
function context(granted: readonly string[]): HousekeepingCapabilityContext {
const permissions = new Set(granted);
return {
actor: { id: 42, username: "operator", rank: 4 },
isSuperAdmin: false,
has: (slug) => permissions.has(slug),
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
};
}
async function load(granted: readonly string[]) {
return PEOPLE_WIDGETS[0].load(
context(granted),
new AbortController().signal,
);
}
beforeEach(() => {
vi.clearAllMocks();
mocks.fetchTicketQueueOpenCounts.mockResolvedValue({
cmsOpen: 1,
helpOpen: 2,
});
});
describe("People production queue widget", () => {
it("wires ticket-only, CFH-only, ban-only, mixed, and denied contexts without unauthorized reads", async () => {
await expect(load([PERMS.MOD_TICKETS_VIEW])).resolves.toMatchObject({
ok: true,
data: { tickets: 1, helpTickets: 2, cfh: 0, activeBans: 0 },
});
expect(mocks.fetchTicketQueueOpenCounts).toHaveBeenCalledTimes(1);
expect(mocks.execute).not.toHaveBeenCalled();
vi.clearAllMocks();
mocks.execute.mockResolvedValueOnce([[{ total: 3 }], []]);
await expect(load([PERMS.MOD_CFH_VIEW])).resolves.toMatchObject({
ok: true,
data: { tickets: 0, helpTickets: 0, cfh: 3, activeBans: 0 },
});
expect(mocks.fetchTicketQueueOpenCounts).not.toHaveBeenCalled();
expect(mocks.execute).toHaveBeenCalledTimes(1);
vi.clearAllMocks();
mocks.execute.mockResolvedValueOnce([[{ total: 4 }], []]);
await expect(load([PERMS.MOD_BANS_VIEW])).resolves.toMatchObject({
ok: true,
data: { tickets: 0, helpTickets: 0, cfh: 0, activeBans: 4 },
});
expect(mocks.fetchTicketQueueOpenCounts).not.toHaveBeenCalled();
expect(mocks.execute).toHaveBeenCalledTimes(1);
vi.clearAllMocks();
mocks.fetchTicketQueueOpenCounts.mockResolvedValue({
cmsOpen: 1,
helpOpen: 2,
});
mocks.execute
.mockResolvedValueOnce([[{ total: 3 }], []])
.mockResolvedValueOnce([[{ total: 4 }], []]);
const mixed = await load([
PERMS.MOD_TICKETS_VIEW,
PERMS.MOD_CFH_VIEW,
PERMS.MOD_BANS_VIEW,
]);
expect(mocks.fetchTicketQueueOpenCounts).toHaveBeenCalledTimes(1);
expect(mocks.execute).toHaveBeenCalledTimes(2);
expect(mixed).toMatchObject({
ok: true,
data: { tickets: 1, helpTickets: 2, cfh: 3, activeBans: 4 },
});
vi.clearAllMocks();
await expect(load([])).resolves.toMatchObject({
ok: false,
error: { code: "FORBIDDEN" },
});
expect(mocks.fetchTicketQueueOpenCounts).not.toHaveBeenCalled();
expect(mocks.execute).not.toHaveBeenCalled();
});
});
@@ -11,6 +11,7 @@ import {
type HousekeepingSearchProvider,
ok,
} from "../../foundation/contracts";
import { isSafeHousekeepingHref } from "../../foundation/housekeeping-href";
import { peopleCommunityQuery } from "./queries/community";
import { peopleSupportQuery } from "./queries/support";
import { peopleUsersQuery } from "./queries/users";
@@ -47,17 +48,6 @@ export interface PeopleSearchAdapters {
): Promise<readonly PeopleSearchCandidate[]>;
}
function safeHref(href: string): href is `/ase/${string}` {
return (
href.startsWith("/ase/") &&
!href.includes("\\") &&
!Array.from(href).some((character) => {
const code = character.codePointAt(0) ?? 0;
return code < 32 || code === 127;
})
);
}
function boundedLimit(limit: number): number {
return Number.isFinite(limit) ? Math.min(25, Math.max(1, Math.trunc(limit))) : 25;
}
@@ -82,7 +72,7 @@ function createProvider(
candidates
.filter(
(item) =>
safeHref(item.href) &&
isSafeHousekeepingHref(item.href) &&
satisfiesCapability(context, item.capability),
)
.slice(0, limit)
@@ -0,0 +1,423 @@
import "server-only";
import { eq } from "drizzle-orm";
import { Ban, type Db, SupportTickets, User } from "@/lib/db";
import type {
AuditEntry,
HousekeepingAuditWriter,
} from "@/lib/services/audit";
import type {
ModerationAction,
ModerationActionTransport,
} from "@/lib/services/moderation";
import type {
HousekeepingCapabilityContext,
HousekeepingErrorCode,
HousekeepingPartialCompletion,
} from "../../../foundation/contracts";
export const PEOPLE_MODERATION_MUTATION_OPERATIONS = [
"cfh.sanction",
"ban.create",
"ban.lift",
"moderation.action",
] as const;
export type PeopleModerationMutationOperation =
(typeof PEOPLE_MODERATION_MUTATION_OPERATIONS)[number];
export interface PeopleModerationMutationContext {
readonly capability: HousekeepingCapabilityContext;
readonly correlationId: string;
readonly legacy: boolean;
}
export interface PeopleModerationMutationSnapshot {
readonly before: Readonly<Record<string, unknown>> | null;
readonly after: Readonly<Record<string, unknown>> | null;
readonly output?: Readonly<Record<string, unknown>>;
readonly completion?: HousekeepingPartialCompletion;
}
type AuditOutcome = "intent" | "success" | "failure" | "partial";
type ModerationTransport = ModerationActionTransport & {
disconnectUser(userId: number, username?: string): Promise<boolean>;
};
export interface PeopleModerationMutationDependencies {
readonly db: Db;
readonly writeAudit: HousekeepingAuditWriter["write"];
readonly auditEntry: (
context: PeopleModerationMutationContext,
operation: PeopleModerationMutationOperation,
target: string,
targetId: number | undefined,
snapshot: PeopleModerationMutationSnapshot,
outcome: AuditOutcome,
) => AuditEntry;
readonly failure: (
code: HousekeepingErrorCode,
messageKey: string,
) => Error;
readonly record: (input: unknown) => Record<string, unknown>;
readonly positiveInteger: (value: unknown) => number;
readonly nonNegativeInteger: (value: unknown) => number;
readonly normalizedText: (
value: unknown,
max: number,
required?: boolean,
) => string;
readonly requireRcon: (result: boolean) => Promise<void>;
readonly transport: ModerationTransport;
readonly executeModerationAction: (
transport: ModerationActionTransport,
input: ModerationAction,
) => Promise<boolean>;
readonly logStaffActivity: (input: {
staffId: number;
action: string;
description: string;
targetType?: string;
targetId?: number;
}) => Promise<void>;
readonly runExternalWithAudit: (
context: PeopleModerationMutationContext,
operation: PeopleModerationMutationOperation,
target: string,
targetId: number | undefined,
snapshot: PeopleModerationMutationSnapshot,
execute: () => Promise<void>,
confirmedFailureSnapshot: PeopleModerationMutationSnapshot,
) => Promise<PeopleModerationMutationSnapshot>;
readonly finalizeExternalWithAudit: (
context: PeopleModerationMutationContext,
operation: PeopleModerationMutationOperation,
target: string,
targetId: number | undefined,
snapshot: PeopleModerationMutationSnapshot,
execute: () => Promise<void>,
mutationCommitted?: boolean,
confirmedFailureSnapshot?: PeopleModerationMutationSnapshot,
) => Promise<PeopleModerationMutationSnapshot>;
}
export interface PeopleModerationMutationExecutor {
execute(
operation: PeopleModerationMutationOperation,
input: unknown,
context: PeopleModerationMutationContext,
): Promise<PeopleModerationMutationSnapshot>;
}
export function isPeopleModerationMutationOperation(
operation: string,
): operation is PeopleModerationMutationOperation {
return (PEOPLE_MODERATION_MUTATION_OPERATIONS as readonly string[]).includes(
operation,
);
}
export function createPeopleModerationMutationExecutor(
dependencies: PeopleModerationMutationDependencies,
): PeopleModerationMutationExecutor {
const {
db,
writeAudit,
auditEntry,
failure,
record,
positiveInteger,
nonNegativeInteger,
normalizedText,
requireRcon,
transport,
executeModerationAction,
logStaffActivity,
runExternalWithAudit,
finalizeExternalWithAudit,
} = dependencies;
function moderationAction(data: Record<string, unknown>): ModerationAction {
const action = normalizedText(data.action, 32);
if (action === "kick" || action === "unmute") {
return { action, userId: positiveInteger(data.userId) };
}
if (action === "mute") {
const duration = nonNegativeInteger(data.duration);
if (duration > 525_600) {
throw failure("VALIDATION", "errors.housekeeping.validation");
}
return { action, userId: positiveInteger(data.userId), duration };
}
if (action === "alert") {
return {
action,
userId: positiveInteger(data.userId),
message: normalizedText(data.message, 500),
};
}
if (action === "room-kick") {
return { action, roomId: positiveInteger(data.roomId) };
}
if (action === "broadcast") {
const type = normalizedText(data.type, 16);
if (type !== "hotel" && type !== "staff") {
throw failure("VALIDATION", "errors.housekeeping.validation");
}
return {
action,
type,
message: normalizedText(data.message, 500),
};
}
throw failure("VALIDATION", "errors.housekeeping.validation");
}
function actionTarget(input: ModerationAction): {
target: string;
targetId: number | undefined;
} {
if ("userId" in input) return { target: "User", targetId: input.userId };
if ("roomId" in input) return { target: "Room", targetId: input.roomId };
return { target: "broadcast", targetId: undefined };
}
async function deliverModerationAction(
input: ModerationAction,
context: PeopleModerationMutationContext,
): Promise<void> {
const delivered = await executeModerationAction(transport, input);
if (!context.legacy) await requireRcon(delivered);
}
async function executeModerationMutation(
input: unknown,
context: PeopleModerationMutationContext,
): Promise<PeopleModerationMutationSnapshot> {
const action = moderationAction(record(input));
const target = actionTarget(action);
const snapshot = {
before: null,
after: { ...action },
} satisfies PeopleModerationMutationSnapshot;
return runExternalWithAudit(
context,
"moderation.action",
target.target,
target.targetId,
snapshot,
() => deliverModerationAction(action, context),
{ before: null, after: null },
);
}
async function executeCfhSanction(
input: unknown,
context: PeopleModerationMutationContext,
): Promise<PeopleModerationMutationSnapshot> {
const data = record(input);
const ticketId = positiveInteger(data.ticketId);
const action = moderationAction({
...data,
message: data.message ?? data.reason,
});
const reason = normalizedText(data.reason, 500);
let snapshot!: PeopleModerationMutationSnapshot;
await db.transaction(async (tx) => {
const [ticket] = await tx
.select({
id: SupportTickets.id,
state: SupportTickets.state,
modId: SupportTickets.modId,
})
.from(SupportTickets)
.where(eq(SupportTickets.id, ticketId))
.limit(1);
if (!ticket) {
throw failure("NOT_FOUND", "errors.housekeeping.notFound");
}
snapshot = {
before: {
id: ticket.id,
state: ticket.state,
moderatorId: ticket.modId,
},
after: {
id: ticket.id,
state: 2,
moderatorId: context.capability.actor.id,
sanction: action.action,
reason,
},
};
await tx
.update(SupportTickets)
.set({ state: 2, modId: context.capability.actor.id })
.where(eq(SupportTickets.id, ticketId));
await writeAudit(
auditEntry(
context,
"cfh.sanction",
"support_tickets",
ticketId,
snapshot,
"intent",
),
tx,
);
});
return finalizeExternalWithAudit(
context,
"cfh.sanction",
"support_tickets",
ticketId,
snapshot,
() => deliverModerationAction(action, context),
);
}
async function executeBanMutation(
operation: Extract<PeopleModerationMutationOperation, `ban.${string}`>,
input: unknown,
context: PeopleModerationMutationContext,
): Promise<PeopleModerationMutationSnapshot> {
const data = record(input);
if (operation === "ban.create") {
const userId = positiveInteger(data.userId);
const hours = nonNegativeInteger(data.hours);
const type = normalizedText(data.type, 16);
const reason = normalizedText(data.reason, 500);
if (
hours > 876_000 ||
!["account", "ip", "machine", "super"].includes(type)
) {
throw failure("VALIDATION", "errors.housekeeping.validation");
}
const now = Math.floor(Date.now() / 1_000);
const banExpire = hours > 0 ? now + hours * 3_600 : 0;
let username: string | null = null;
let banId = 0;
let snapshot!: PeopleModerationMutationSnapshot;
await db.transaction(async (tx) => {
const [user] = await tx
.select({ username: User.username })
.from(User)
.where(eq(User.id, userId))
.limit(1);
username = user?.username ?? null;
const [result] = await tx.insert(Ban).values({
userId,
ip: "",
machineId: "",
userStaffId: context.capability.actor.id,
timestamp: now,
banExpire,
banReason: reason,
type: type as "account" | "ip" | "machine" | "super",
cfhTopic: -1,
});
banId = positiveInteger(result.insertId);
snapshot = {
before: null,
after: {
id: banId,
userId,
type,
reason,
expiresAt: banExpire,
},
};
await writeAudit(
auditEntry(
context,
operation,
"Ban",
banId,
snapshot,
"intent",
),
tx,
);
});
return finalizeExternalWithAudit(
context,
operation,
"Ban",
banId,
snapshot,
async () => {
let delivered = true;
if (username !== null) {
delivered = await transport.disconnectUser(userId, username);
}
await logStaffActivity({
staffId: context.capability.actor.id,
action: "user_ban",
description: `Banned user #${userId} (${type}, ${
hours > 0 ? `${hours}h` : "permanent"
}): ${reason}`,
targetType: "user",
targetId: userId,
});
await requireRcon(delivered);
},
);
}
const id = positiveInteger(data.id);
let snapshot!: PeopleModerationMutationSnapshot;
await db.transaction(async (tx) => {
const [existing] = await tx
.select({
id: Ban.id,
userId: Ban.userId,
reason: Ban.banReason,
type: Ban.type,
})
.from(Ban)
.where(eq(Ban.id, id))
.limit(1);
await tx.delete(Ban).where(eq(Ban.id, id));
snapshot = { before: existing ?? null, after: null };
await writeAudit(
auditEntry(
context,
operation,
"Ban",
id,
snapshot,
"intent",
),
tx,
);
});
return finalizeExternalWithAudit(
context,
operation,
"Ban",
id,
snapshot,
() =>
logStaffActivity({
staffId: context.capability.actor.id,
action: "ban_lift",
description: `Lifted ban #${id}`,
}),
);
}
return {
execute(operation, input, context) {
if (operation === "moderation.action") {
return executeModerationMutation(input, context);
}
if (operation === "cfh.sanction") {
return executeCfhSanction(input, context);
}
if (operation === "ban.create" || operation === "ban.lift") {
return executeBanMutation(operation, input, context);
}
throw failure("VALIDATION", "errors.housekeeping.validation");
},
};
}
@@ -17,8 +17,11 @@ const mocks = vi.hoisted(() => ({
giveCredits: vi.fn(),
giveDuckets: vi.fn(),
givePointsGotw: vi.fn(),
hotelAlert: vi.fn(),
kickAll: vi.fn(),
muteUser: vi.fn(),
setTradeLock: vi.fn(),
staffAlert: vi.fn(),
unmuteUser: vi.fn(),
updateWordFilter: vi.fn(),
},
@@ -101,7 +104,8 @@ vi.mock("@/lib/services/audit", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/services/audit")>()),
logAudit: mocks.audit,
}));
vi.mock("@/lib/services/moderation", () => ({
vi.mock("@/lib/services/moderation", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/services/moderation")>()),
reloadWordFilter: mocks.reloadWordFilter,
}));
vi.mock("@/lib/services/rcon", () => ({ rcon: mocks.rcon }));
@@ -979,4 +983,290 @@ describe("People production workflow adapter", () => {
mocks.audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome),
).toEqual(["intent", "partial"]);
});
it.each([
{
operation: "ticket.reply",
input: { ticketId: 7, message: "Handled" },
rows: [[{ id: 7, assigneeId: null, status: "open", priority: "normal" }]],
transactional: true,
},
{
operation: "ticket.assign",
input: { ticketId: 7, assigneeId: 42 },
rows: [[{ id: 7, assigneeId: null, status: "open", priority: "normal" }]],
transactional: true,
},
{
operation: "ticket.status",
input: { ticketId: 7, status: "waiting" },
rows: [[{ id: 7, assigneeId: 42, status: "open", priority: "normal" }]],
transactional: true,
},
{
operation: "ticket.priority",
input: { ticketId: 7, priority: "urgent" },
rows: [[{ id: 7, assigneeId: 42, status: "open", priority: "normal" }]],
transactional: true,
},
{
operation: "ticket-template.change",
input: { action: "update", id: 88, title: "Updated" },
rows: [[{ id: 88, title: "Greeting", content: "Hello", category: "general", sortOrder: 0 }]],
transactional: true,
},
{
operation: "help-ticket.reply",
input: { ticketId: "9007199254740993", content: "Handled" },
rows: [[{ id: 9_007_199_254_740_993n, userId: 7, open: true, title: "Appeal" }]],
transactional: true,
},
{
operation: "help-ticket.status",
input: { ticketId: "9007199254740993", status: "close" },
rows: [[{ id: 9_007_199_254_740_993n, userId: 7, open: true, title: "Appeal" }]],
transactional: true,
},
{
operation: "help-ticket.unban",
input: { ticketId: "9007199254740993" },
rows: [[{ id: 9_007_199_254_740_993n, userId: 7, open: true, title: "Appeal" }]],
transactional: true,
},
{
operation: "cfh.resolve",
input: { ticketId: 9, state: 2 },
rows: [[{ id: 9, state: 1, modId: 8 }]],
transactional: true,
},
{
operation: "cfh.sanction",
input: { ticketId: 9, action: "alert", userId: 7, reason: "Repeated abuse" },
rows: [[{ id: 9, state: 1, modId: 8 }]],
transactional: true,
},
{
operation: "ban.create",
input: { userId: 7, hours: 24, type: "account", reason: "Repeated abuse" },
rows: [[{ username: "Alice" }]],
transactional: true,
},
{
operation: "ban.lift",
input: { id: 12 },
rows: [[{ id: 12, userId: 7, reason: "Repeated abuse", type: "account" }]],
transactional: true,
},
{
operation: "moderation.action",
input: { action: "kick", userId: 7 },
rows: [],
transactional: false,
},
] as const)(
"executes and audits the direct Task 13 production operation $operation",
async ({ operation, input, rows, transactional }) => {
mocks.selectQueue.push(...rows.map((row) => [...row]));
const result = await peopleMutationService.execute(
{ ...invocation, legacy: false, correlationId: `task13-${operation}` },
operation,
input,
);
expect(result).toMatchObject({
ok: true,
correlationId: `task13-${operation}`,
});
expect(JSON.stringify(result)).toContain(`task13-${operation}`);
expect(mocks.audit).toHaveBeenCalledWith(
expect.objectContaining({
action: `people.${operation}`,
correlationId: `task13-${operation}`,
}),
...(
transactional
? [expect.any(Object)]
: []
),
);
if (transactional) expect(mocks.transaction).toHaveBeenCalledTimes(1);
},
);
it("clears closedAt when a closed CMS ticket is reopened", async () => {
mocks.selectQueue.push(
[{ id: 7, assigneeId: 42, status: "open", priority: "normal" }],
[{ id: 7, assigneeId: 42, status: "closed", priority: "normal" }],
);
await peopleMutationService.execute(invocation, "ticket.status", {
ticketId: 7,
status: "closed",
});
await peopleMutationService.execute(invocation, "ticket.status", {
ticketId: 7,
status: "open",
});
expect(mocks.updateSet).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ status: "closed", closedAt: expect.any(Date) }),
);
expect(mocks.updateSet).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ status: "open", closedAt: null }),
);
});
it.each([
["kick", { action: "kick", userId: 7 }, "disconnectUser"],
["mute", { action: "mute", userId: 7, duration: 60 }, "muteUser"],
["unmute", { action: "unmute", userId: 7 }, "unmuteUser"],
["alert", { action: "alert", userId: 7, message: "Stop" }, "alertUser"],
["room-kick", { action: "room-kick", roomId: 12 }, "kickAll"],
["broadcast", { action: "broadcast", message: "Notice", type: "staff" }, "staffAlert"],
] as const)(
"preserves legacy confirmed-false success for %s while recording an observed outcome",
async (_label, input, transport) => {
mocks.rcon[transport].mockResolvedValueOnce(false);
const result = await peopleMutationService.execute(
{ ...invocation, correlationId: `legacy-false-${transport}` },
"moderation.action",
input,
);
expect(result).toMatchObject({ ok: true });
expect(
mocks.audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome),
).toEqual(["intent", "success"]);
expect(mocks.audit.mock.invocationCallOrder[0]).toBeLessThan(
mocks.rcon[transport].mock.invocationCallOrder[0],
);
},
);
it("keeps Housekeeping false strict, propagates throws, and blocks delivery when intent audit fails", async () => {
mocks.rcon.disconnectUser.mockResolvedValueOnce(false);
await expect(
peopleMutationService.execute(
{ ...invocation, legacy: false, correlationId: "hk-false" },
"moderation.action",
{ action: "kick", userId: 7 },
),
).resolves.toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
});
expect(
mocks.audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome),
).toEqual(["intent", "failure"]);
expect(mocks.audit.mock.invocationCallOrder[0]).toBeLessThan(
mocks.rcon.disconnectUser.mock.invocationCallOrder[0],
);
vi.clearAllMocks();
mocks.resolveServerContext.mockResolvedValue(context());
mocks.audit.mockResolvedValue(undefined);
mocks.rcon.disconnectUser.mockRejectedValueOnce(new Error("RCON unavailable"));
await expect(
peopleMutationService.execute(
{ ...invocation, correlationId: "legacy-throw" },
"moderation.action",
{ action: "kick", userId: 7 },
),
).resolves.toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
});
expect(
mocks.audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome),
).toEqual(["intent", "failure"]);
vi.clearAllMocks();
mocks.resolveServerContext.mockResolvedValue(context());
mocks.audit.mockRejectedValueOnce(new Error("audit unavailable"));
await expect(
peopleMutationService.execute(
{ ...invocation, correlationId: "intent-failure" },
"moderation.action",
{ action: "kick", userId: 7 },
),
).resolves.toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
});
expect(mocks.rcon.disconnectUser).not.toHaveBeenCalled();
});
it("rolls back mixed workflow intent failure before external delivery", async () => {
mocks.selectQueue.push([{ id: 9, state: 1, modId: 8 }]);
mocks.audit.mockRejectedValueOnce(new Error("intent audit unavailable"));
const result = await peopleMutationService.execute(
{ ...invocation, legacy: false, correlationId: "cfh-intent-failure" },
"cfh.sanction",
{ ticketId: 9, action: "alert", userId: 7, reason: "Repeated abuse" },
);
expect(result).toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
correlationId: "cfh-intent-failure",
});
expect(mocks.transaction).toHaveBeenCalledTimes(1);
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({ state: 2, modId: 42 }),
);
expect(mocks.rcon.alertUser).not.toHaveBeenCalled();
});
it("keeps help-ticket BIGINT identifiers canonical and JSON serializable", async () => {
mocks.selectQueue.push([
{
id: 9_007_199_254_740_993n,
userId: 7,
open: true,
title: "Appeal",
},
]);
const result = await peopleMutationService.execute(
{ ...invocation, legacy: false, correlationId: "help-bigint" },
"help-ticket.reply",
{ ticketId: "9007199254740993", content: "Handled" },
);
expect(result).toMatchObject({
ok: true,
data: {
before: { id: "9007199254740993" },
after: { id: "9007199254740993" },
},
});
expect(() => JSON.stringify(result)).not.toThrow();
expect(mocks.audit).toHaveBeenCalledWith(
expect.objectContaining({
targetId: undefined,
before: expect.objectContaining({ id: "9007199254740993" }),
after: expect.objectContaining({ id: "9007199254740993" }),
}),
expect.any(Object),
);
});
it("reports committed CFH database work plus failed external sync as typed partial", async () => {
mocks.selectQueue.push([{ id: 9, state: 1, modId: 8 }]);
mocks.rcon.alertUser.mockResolvedValueOnce(false);
const result = await peopleMutationService.execute(
{ ...invocation, legacy: false, correlationId: "cfh-partial" },
"cfh.sanction",
{ ticketId: 9, action: "alert", userId: 7, reason: "Repeated abuse" },
);
expect(result).toMatchObject({
ok: true,
completion: {
status: "partial",
external: "failed",
audit: "persisted",
},
correlationId: "cfh-partial",
});
expect(mocks.transaction).toHaveBeenCalledTimes(1);
expect(
mocks.audit.mock.calls.map((call) => (call[0] as AuditEntry).outcome),
).toEqual(["intent", "partial"]);
expect(mocks.audit.mock.calls[0][1]).toBeDefined();
});
});
@@ -93,4 +93,24 @@ describe("People production server authority", () => {
expect(alertUser).not.toHaveBeenCalled();
expect(audit).not.toHaveBeenCalled();
});
it("blocks Task 13 moderation work before intent audit or transport on actor mismatch", async () => {
resolveServerContext.mockResolvedValue(context([PERMS.MOD_ACTIONS]));
const result = await peopleMutationService.execute(
{
correlationId: "task13-server-authority",
expectedActorId: 99,
legacy: true,
},
"moderation.action",
{ action: "alert", userId: 7, message: "Stop" },
);
expect(result).toMatchObject({
ok: false,
error: { code: "FORBIDDEN" },
correlationId: "task13-server-authority",
});
expect(alertUser).not.toHaveBeenCalled();
expect(audit).not.toHaveBeenCalled();
});
});
@@ -17,28 +17,21 @@ import {
Items,
Rooms,
Sanctions,
SupportTickets,
User,
UsersBadges,
UsersCurrency,
UsersSettings,
WebsiteHelpCenterTicketReplies,
WebsiteHelpCenterTickets,
WebsiteIpBlacklist,
WebsiteIpWhitelist,
WebsiteSetting,
WebsiteStaffApplications,
WebsiteTeams,
WebsiteTicket,
WebsiteTicketMessage,
WebsiteTicketTemplate,
WebsiteWordfilter,
} from "@/lib/db";
import { PERMS } from "@/lib/permission-slugs";
import { logAudit } from "@/lib/services/audit";
import {
executeModerationAction,
type ModerationAction,
reloadWordFilter,
} from "@/lib/services/moderation";
import { rcon } from "@/lib/services/rcon";
@@ -57,6 +50,14 @@ import {
ok,
} from "../../../foundation/contracts";
import { getHousekeepingCapabilityContext } from "../../../foundation/server-capability-context";
import {
createPeopleModerationMutationExecutor,
isPeopleModerationMutationOperation,
} from "./moderation-mutations";
import {
createPeopleSupportMutationExecutor,
isPeopleSupportMutationOperation,
} from "./support-mutations";
export type PeopleMutationOperation =
| "user.update"
@@ -1802,655 +1803,35 @@ async function executeWordFilterUpdate(
);
}
type TicketMutationOperation = Extract<
PeopleMutationOperation,
`ticket.${string}`
>;
const supportMutationExecutor = createPeopleSupportMutationExecutor({
db,
writeAudit: logAudit,
auditEntry: canonicalAuditEntry,
failure: (code, messageKey) => new PeopleMutationFailure(code, messageKey),
record,
positiveInteger,
nonNegativeInteger,
normalizedText,
canonicalTicketId,
auditTargetId,
});
async function executeTicketMutation(
operation: TicketMutationOperation,
input: unknown,
context: PeopleMutationContext,
): Promise<PeopleMutationSnapshot> {
const data = record(input);
const ticketId = positiveInteger(data.ticketId);
let snapshot!: PeopleMutationSnapshot;
await db.transaction(async (tx) => {
const [ticket] = await tx
.select({
id: WebsiteTicket.id,
assigneeId: WebsiteTicket.assigneeId,
status: WebsiteTicket.status,
priority: WebsiteTicket.priority,
})
.from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ticketId))
.limit(1);
if (!ticket) {
throw new PeopleMutationFailure(
"NOT_FOUND",
"errors.housekeeping.notFound",
);
}
const before = {
id: ticket.id,
assigneeId: ticket.assigneeId,
status: ticket.status,
priority: ticket.priority,
};
const now = new Date();
if (operation === "ticket.reply") {
const message = normalizedText(data.message, 5_000);
await tx.insert(WebsiteTicketMessage).values({
ticketId,
userId: context.capability.actor.id,
message,
isStaff: 1,
});
const assigneeId = ticket.assigneeId ?? context.capability.actor.id;
await tx
.update(WebsiteTicket)
.set({ status: "waiting", assigneeId, updatedAt: now })
.where(eq(WebsiteTicket.id, ticketId));
snapshot = {
before,
after: { ...before, assigneeId, status: "waiting" },
};
} else if (operation === "ticket.assign") {
const assigneeId =
data.assigneeId === null ? null : positiveInteger(data.assigneeId);
const status = assigneeId === null ? "open" : "in_progress";
await tx
.update(WebsiteTicket)
.set({ assigneeId, status, updatedAt: now })
.where(eq(WebsiteTicket.id, ticketId));
snapshot = { before, after: { ...before, assigneeId, status } };
} else if (operation === "ticket.status") {
const status = normalizedText(data.status, 32);
if (!["open", "in_progress", "waiting", "closed"].includes(status)) {
throw new PeopleMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
const assigneeId =
status === "in_progress" && ticket.assigneeId === null
? context.capability.actor.id
: ticket.assigneeId;
await tx
.update(WebsiteTicket)
.set({
status,
assigneeId,
updatedAt: now,
...(status === "closed" ? { closedAt: now } : {}),
})
.where(eq(WebsiteTicket.id, ticketId));
snapshot = { before, after: { ...before, assigneeId, status } };
} else {
const priority = normalizedText(data.priority, 32);
if (!["low", "normal", "high", "urgent"].includes(priority)) {
throw new PeopleMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
await tx
.update(WebsiteTicket)
.set({ priority, updatedAt: now })
.where(eq(WebsiteTicket.id, ticketId));
snapshot = { before, after: { ...before, priority } };
}
await logAudit(
canonicalAuditEntry(
context,
operation,
"WebsiteTicket",
ticketId,
snapshot,
"success",
),
tx,
);
});
return snapshot;
}
async function executeTicketTemplateChange(
input: unknown,
context: PeopleMutationContext,
): Promise<PeopleMutationSnapshot> {
const data = record(input);
const action = normalizedText(data.action, 16);
let snapshot!: PeopleMutationSnapshot;
await db.transaction(async (tx) => {
let targetId: number | undefined;
if (action === "create") {
const values = {
title: normalizedText(data.title, 255),
content: normalizedText(data.content, 5_000),
category: normalizedText(data.category ?? "general", 50),
sortOrder:
data.sortOrder === undefined ? 0 : nonNegativeInteger(data.sortOrder),
};
const [result] = await tx.insert(WebsiteTicketTemplate).values(values);
targetId = positiveInteger(result.insertId);
snapshot = { before: null, after: { id: targetId, ...values } };
} else {
const id = positiveInteger(data.id);
targetId = id;
const [existing] = await tx
.select({
id: WebsiteTicketTemplate.id,
title: WebsiteTicketTemplate.title,
content: WebsiteTicketTemplate.content,
category: WebsiteTicketTemplate.category,
sortOrder: WebsiteTicketTemplate.sortOrder,
})
.from(WebsiteTicketTemplate)
.where(eq(WebsiteTicketTemplate.id, id))
.limit(1);
if (action === "update" && !existing) {
throw new PeopleMutationFailure(
"NOT_FOUND",
"errors.housekeeping.notFound",
);
}
if (action === "delete") {
await tx
.delete(WebsiteTicketTemplate)
.where(eq(WebsiteTicketTemplate.id, id));
snapshot = { before: existing ?? null, after: null };
} else if (action === "update") {
const changes = {
...(data.title === undefined
? {}
: { title: normalizedText(data.title, 255) }),
...(data.content === undefined
? {}
: { content: normalizedText(data.content, 5_000) }),
...(data.category === undefined
? {}
: { category: normalizedText(data.category, 50, false) }),
...(data.sortOrder === undefined
? {}
: { sortOrder: nonNegativeInteger(data.sortOrder) }),
};
await tx
.update(WebsiteTicketTemplate)
.set(changes)
.where(eq(WebsiteTicketTemplate.id, id));
snapshot = { before: existing ?? null, after: { ...existing, ...changes } };
} else {
throw new PeopleMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
}
await logAudit(
canonicalAuditEntry(
context,
"ticket-template.change",
"WebsiteTicketTemplate",
targetId,
snapshot,
"success",
),
tx,
);
});
return snapshot;
}
async function executeHelpTicketMutation(
operation: Extract<PeopleMutationOperation, `help-ticket.${string}`>,
input: unknown,
context: PeopleMutationContext,
): Promise<PeopleMutationSnapshot> {
const data = record(input);
let ticketId: bigint;
try {
ticketId = canonicalTicketId(data.ticketId as string);
} catch {
throw new PeopleMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
let snapshot!: PeopleMutationSnapshot;
await db.transaction(async (tx) => {
const [ticket] = await tx
.select({
id: WebsiteHelpCenterTickets.id,
userId: WebsiteHelpCenterTickets.userId,
open: WebsiteHelpCenterTickets.open,
title: WebsiteHelpCenterTickets.title,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket) {
throw new PeopleMutationFailure(
"NOT_FOUND",
"errors.housekeeping.notFound",
);
}
const id = ticketId.toString();
const before = {
id,
userId: ticket.userId,
open: Boolean(ticket.open),
title: ticket.title,
};
const now = new Date();
if (operation === "help-ticket.reply") {
await tx.insert(WebsiteHelpCenterTicketReplies).values({
ticketId,
userId: context.capability.actor.id,
content: normalizedText(data.content, 5_000),
createdAt: now,
updatedAt: now,
});
await tx
.update(WebsiteHelpCenterTickets)
.set({ updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
snapshot = { before, after: before };
} else if (operation === "help-ticket.status") {
const status = normalizedText(data.status, 16);
const open =
status === "reopen"
? true
: status === "close"
? false
: null;
if (open === null) {
throw new PeopleMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
if (Boolean(ticket.open) === open) {
throw new PeopleMutationFailure(
"CONFLICT",
"errors.housekeeping.conflict",
);
}
await tx
.update(WebsiteHelpCenterTickets)
.set({ open, updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
snapshot = { before, after: { ...before, open } };
} else {
if (ticket.userId === null) {
throw new PeopleMutationFailure(
"CONFLICT",
"errors.housekeeping.conflict",
);
}
const deleted = await tx.delete(Ban).where(eq(Ban.userId, ticket.userId));
const removed = Number(
(deleted as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
);
if (ticket.open) {
await tx
.update(WebsiteHelpCenterTickets)
.set({ open: false, updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
}
snapshot = {
before,
after: { ...before, open: false, removedBans: removed },
output: { removed, userId: ticket.userId },
};
}
await logAudit(
canonicalAuditEntry(
context,
operation,
"WebsiteHelpCenterTickets",
auditTargetId(ticketId),
snapshot,
"success",
),
tx,
);
});
return snapshot;
}
async function executeCfhResolve(
input: unknown,
context: PeopleMutationContext,
): Promise<PeopleMutationSnapshot> {
const data = record(input);
const ticketId = positiveInteger(data.ticketId);
const state = nonNegativeInteger(data.state);
if (state > 3) {
throw new PeopleMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
let snapshot!: PeopleMutationSnapshot;
await db.transaction(async (tx) => {
const [ticket] = await tx
.select({
id: SupportTickets.id,
state: SupportTickets.state,
modId: SupportTickets.modId,
})
.from(SupportTickets)
.where(eq(SupportTickets.id, ticketId))
.limit(1);
if (!ticket) {
throw new PeopleMutationFailure(
"NOT_FOUND",
"errors.housekeeping.notFound",
);
}
snapshot = {
before: { id: ticket.id, state: ticket.state, moderatorId: ticket.modId },
after: {
id: ticket.id,
state,
moderatorId: context.capability.actor.id,
},
};
await tx
.update(SupportTickets)
.set({ state, modId: context.capability.actor.id })
.where(eq(SupportTickets.id, ticketId));
await logAudit(
canonicalAuditEntry(
context,
"cfh.resolve",
"support_tickets",
ticketId,
snapshot,
"success",
),
tx,
);
});
return snapshot;
}
function moderationAction(data: Record<string, unknown>): ModerationAction {
const action = normalizedText(data.action, 32);
if (action === "kick" || action === "unmute") {
return { action, userId: positiveInteger(data.userId) };
}
if (action === "mute") {
const duration = nonNegativeInteger(data.duration);
if (duration > 525_600) {
throw new PeopleMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
return { action, userId: positiveInteger(data.userId), duration };
}
if (action === "alert") {
return {
action,
userId: positiveInteger(data.userId),
message: normalizedText(data.message, 500),
};
}
if (action === "room-kick") {
return { action, roomId: positiveInteger(data.roomId) };
}
if (action === "broadcast") {
const type = normalizedText(data.type, 16);
if (type !== "hotel" && type !== "staff") {
throw new PeopleMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
return {
action,
type,
message: normalizedText(data.message, 500),
};
}
throw new PeopleMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
function actionTarget(input: ModerationAction): {
target: string;
targetId: number | undefined;
} {
if ("userId" in input) return { target: "User", targetId: input.userId };
if ("roomId" in input) return { target: "Room", targetId: input.roomId };
return { target: "broadcast", targetId: undefined };
}
async function deliverModerationAction(input: ModerationAction): Promise<void> {
await requireRcon(await executeModerationAction(rcon, input));
}
async function executeModerationMutation(
input: unknown,
context: PeopleMutationContext,
): Promise<PeopleMutationSnapshot> {
const action = moderationAction(record(input));
const target = actionTarget(action);
const snapshot = {
before: null,
after: { ...action },
} satisfies PeopleMutationSnapshot;
return runExternalWithAudit(
context,
"moderation.action",
target.target,
target.targetId,
snapshot,
() => deliverModerationAction(action),
{ before: null, after: null },
);
}
async function executeCfhSanction(
input: unknown,
context: PeopleMutationContext,
): Promise<PeopleMutationSnapshot> {
const data = record(input);
const ticketId = positiveInteger(data.ticketId);
const action = moderationAction({
...data,
message: data.message ?? data.reason,
});
const reason = normalizedText(data.reason, 500);
let snapshot!: PeopleMutationSnapshot;
await db.transaction(async (tx) => {
const [ticket] = await tx
.select({
id: SupportTickets.id,
state: SupportTickets.state,
modId: SupportTickets.modId,
})
.from(SupportTickets)
.where(eq(SupportTickets.id, ticketId))
.limit(1);
if (!ticket) {
throw new PeopleMutationFailure(
"NOT_FOUND",
"errors.housekeeping.notFound",
);
}
snapshot = {
before: { id: ticket.id, state: ticket.state, moderatorId: ticket.modId },
after: {
id: ticket.id,
state: 2,
moderatorId: context.capability.actor.id,
sanction: action.action,
reason,
},
};
await tx
.update(SupportTickets)
.set({ state: 2, modId: context.capability.actor.id })
.where(eq(SupportTickets.id, ticketId));
await logAudit(
canonicalAuditEntry(
context,
"cfh.sanction",
"support_tickets",
ticketId,
snapshot,
"intent",
),
tx,
);
});
return finalizeExternalWithAudit(
context,
"cfh.sanction",
"support_tickets",
ticketId,
snapshot,
() => deliverModerationAction(action),
);
}
async function executeBanMutation(
operation: "ban.create" | "ban.lift",
input: unknown,
context: PeopleMutationContext,
): Promise<PeopleMutationSnapshot> {
const data = record(input);
if (operation === "ban.create") {
const userId = positiveInteger(data.userId);
const hours = nonNegativeInteger(data.hours);
const type = normalizedText(data.type, 16);
const reason = normalizedText(data.reason, 500);
if (
hours > 876_000 ||
!["account", "ip", "machine", "super"].includes(type)
) {
throw new PeopleMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
const now = Math.floor(Date.now() / 1_000);
const banExpire = hours > 0 ? now + hours * 3_600 : 0;
let username: string | null = null;
let banId = 0;
let snapshot!: PeopleMutationSnapshot;
await db.transaction(async (tx) => {
const [user] = await tx
.select({ username: User.username })
.from(User)
.where(eq(User.id, userId))
.limit(1);
username = user?.username ?? null;
const [result] = await tx.insert(Ban).values({
userId,
ip: "",
machineId: "",
userStaffId: context.capability.actor.id,
timestamp: now,
banExpire,
banReason: reason,
type: type as "account" | "ip" | "machine" | "super",
cfhTopic: -1,
});
banId = positiveInteger(result.insertId);
snapshot = {
before: null,
after: {
id: banId,
userId,
type,
reason,
expiresAt: banExpire,
},
};
await logAudit(
canonicalAuditEntry(
context,
operation,
"Ban",
banId,
snapshot,
"intent",
),
tx,
);
});
return finalizeExternalWithAudit(
context,
operation,
"Ban",
banId,
snapshot,
async () => {
let delivered = true;
if (username !== null) {
delivered = await rcon.disconnectUser(userId, username);
}
await logStaffActivity({
staffId: context.capability.actor.id,
action: "user_ban",
description: `Banned user #${userId} (${type}, ${
hours > 0 ? `${hours}h` : "permanent"
}): ${reason}`,
targetType: "user",
targetId: userId,
});
await requireRcon(delivered);
},
);
}
const id = positiveInteger(data.id);
let snapshot!: PeopleMutationSnapshot;
await db.transaction(async (tx) => {
const [existing] = await tx
.select({
id: Ban.id,
userId: Ban.userId,
reason: Ban.banReason,
type: Ban.type,
})
.from(Ban)
.where(eq(Ban.id, id))
.limit(1);
await tx.delete(Ban).where(eq(Ban.id, id));
snapshot = { before: existing ?? null, after: null };
await logAudit(
canonicalAuditEntry(
context,
operation,
"Ban",
id,
snapshot,
"intent",
),
tx,
);
});
return finalizeExternalWithAudit(
context,
operation,
"Ban",
id,
snapshot,
() =>
logStaffActivity({
staffId: context.capability.actor.id,
action: "ban_lift",
description: `Lifted ban #${id}`,
}),
);
}
const moderationMutationExecutor = createPeopleModerationMutationExecutor({
db,
writeAudit: logAudit,
auditEntry: canonicalAuditEntry,
failure: (code, messageKey) => new PeopleMutationFailure(code, messageKey),
record,
positiveInteger,
nonNegativeInteger,
normalizedText,
requireRcon,
transport: rcon,
executeModerationAction,
logStaffActivity,
runExternalWithAudit,
finalizeExternalWithAudit,
});
const productionPeopleMutationAdapter: PeopleMutationAdapter = {
async execute(operation, input, context) {
@@ -2481,26 +1862,11 @@ const productionPeopleMutationAdapter: PeopleMutationAdapter = {
if (operation === "word-filter.update") {
return executeWordFilterUpdate(input, context);
}
if (operation.startsWith("ticket.")) {
return executeTicketMutation(operation as TicketMutationOperation, input, context);
if (isPeopleSupportMutationOperation(operation)) {
return supportMutationExecutor.execute(operation, input, context);
}
if (operation === "ticket-template.change") {
return executeTicketTemplateChange(input, context);
}
if (operation.startsWith("help-ticket.")) {
return executeHelpTicketMutation(
operation as Extract<PeopleMutationOperation, `help-ticket.${string}`>,
input,
context,
);
}
if (operation === "cfh.resolve") return executeCfhResolve(input, context);
if (operation === "cfh.sanction") return executeCfhSanction(input, context);
if (operation === "ban.create" || operation === "ban.lift") {
return executeBanMutation(operation, input, context);
}
if (operation === "moderation.action") {
return executeModerationMutation(input, context);
if (isPeopleModerationMutationOperation(operation)) {
return moderationMutationExecutor.execute(operation, input, context);
}
throw new PeopleMutationFailure(
"VALIDATION",
@@ -0,0 +1,497 @@
import "server-only";
import { eq } from "drizzle-orm";
import {
Ban,
type Db,
SupportTickets,
WebsiteHelpCenterTicketReplies,
WebsiteHelpCenterTickets,
WebsiteTicket,
WebsiteTicketMessage,
WebsiteTicketTemplate,
} from "@/lib/db";
import type {
AuditEntry,
HousekeepingAuditWriter,
} from "@/lib/services/audit";
import type {
HousekeepingCapabilityContext,
HousekeepingErrorCode,
HousekeepingPartialCompletion,
} from "../../../foundation/contracts";
export const PEOPLE_SUPPORT_MUTATION_OPERATIONS = [
"ticket.reply",
"ticket.assign",
"ticket.status",
"ticket.priority",
"ticket-template.change",
"help-ticket.reply",
"help-ticket.status",
"help-ticket.unban",
"cfh.resolve",
] as const;
export type PeopleSupportMutationOperation =
(typeof PEOPLE_SUPPORT_MUTATION_OPERATIONS)[number];
export interface PeopleSupportMutationContext {
readonly capability: HousekeepingCapabilityContext;
readonly correlationId: string;
readonly legacy: boolean;
}
export interface PeopleSupportMutationSnapshot {
readonly before: Readonly<Record<string, unknown>> | null;
readonly after: Readonly<Record<string, unknown>> | null;
readonly output?: Readonly<Record<string, unknown>>;
readonly completion?: HousekeepingPartialCompletion;
}
type AuditOutcome = "intent" | "success" | "failure" | "partial";
export interface PeopleSupportMutationDependencies {
readonly db: Db;
readonly writeAudit: HousekeepingAuditWriter["write"];
readonly auditEntry: (
context: PeopleSupportMutationContext,
operation: PeopleSupportMutationOperation,
target: string,
targetId: number | undefined,
snapshot: PeopleSupportMutationSnapshot,
outcome: AuditOutcome,
) => AuditEntry;
readonly failure: (
code: HousekeepingErrorCode,
messageKey: string,
) => Error;
readonly record: (input: unknown) => Record<string, unknown>;
readonly positiveInteger: (value: unknown) => number;
readonly nonNegativeInteger: (value: unknown) => number;
readonly normalizedText: (
value: unknown,
max: number,
required?: boolean,
) => string;
readonly canonicalTicketId: (
value: string | number | bigint,
) => bigint;
readonly auditTargetId: (value: bigint) => number | undefined;
}
export interface PeopleSupportMutationExecutor {
execute(
operation: PeopleSupportMutationOperation,
input: unknown,
context: PeopleSupportMutationContext,
): Promise<PeopleSupportMutationSnapshot>;
}
export function isPeopleSupportMutationOperation(
operation: string,
): operation is PeopleSupportMutationOperation {
return (PEOPLE_SUPPORT_MUTATION_OPERATIONS as readonly string[]).includes(
operation,
);
}
export function createPeopleSupportMutationExecutor(
dependencies: PeopleSupportMutationDependencies,
): PeopleSupportMutationExecutor {
const {
db,
writeAudit,
auditEntry,
failure,
record,
positiveInteger,
nonNegativeInteger,
normalizedText,
canonicalTicketId,
auditTargetId,
} = dependencies;
async function executeTicketMutation(
operation: Extract<PeopleSupportMutationOperation, `ticket.${string}`>,
input: unknown,
context: PeopleSupportMutationContext,
): Promise<PeopleSupportMutationSnapshot> {
const data = record(input);
const ticketId = positiveInteger(data.ticketId);
let snapshot!: PeopleSupportMutationSnapshot;
await db.transaction(async (tx) => {
const [ticket] = await tx
.select({
id: WebsiteTicket.id,
assigneeId: WebsiteTicket.assigneeId,
status: WebsiteTicket.status,
priority: WebsiteTicket.priority,
})
.from(WebsiteTicket)
.where(eq(WebsiteTicket.id, ticketId))
.limit(1);
if (!ticket) {
throw failure("NOT_FOUND", "errors.housekeeping.notFound");
}
const before = {
id: ticket.id,
assigneeId: ticket.assigneeId,
status: ticket.status,
priority: ticket.priority,
};
const now = new Date();
if (operation === "ticket.reply") {
const message = normalizedText(data.message, 5_000);
await tx.insert(WebsiteTicketMessage).values({
ticketId,
userId: context.capability.actor.id,
message,
isStaff: 1,
});
const assigneeId = ticket.assigneeId ?? context.capability.actor.id;
await tx
.update(WebsiteTicket)
.set({ status: "waiting", assigneeId, updatedAt: now })
.where(eq(WebsiteTicket.id, ticketId));
snapshot = {
before,
after: { ...before, assigneeId, status: "waiting" },
};
} else if (operation === "ticket.assign") {
const assigneeId =
data.assigneeId === null ? null : positiveInteger(data.assigneeId);
const status = assigneeId === null ? "open" : "in_progress";
await tx
.update(WebsiteTicket)
.set({ assigneeId, status, updatedAt: now })
.where(eq(WebsiteTicket.id, ticketId));
snapshot = { before, after: { ...before, assigneeId, status } };
} else if (operation === "ticket.status") {
const status = normalizedText(data.status, 32);
if (!["open", "in_progress", "waiting", "closed"].includes(status)) {
throw failure("VALIDATION", "errors.housekeeping.validation");
}
const assigneeId =
status === "in_progress" && ticket.assigneeId === null
? context.capability.actor.id
: ticket.assigneeId;
await tx
.update(WebsiteTicket)
.set({
status,
assigneeId,
updatedAt: now,
closedAt: status === "closed" ? now : null,
})
.where(eq(WebsiteTicket.id, ticketId));
snapshot = { before, after: { ...before, assigneeId, status } };
} else {
const priority = normalizedText(data.priority, 32);
if (!["low", "normal", "high", "urgent"].includes(priority)) {
throw failure("VALIDATION", "errors.housekeeping.validation");
}
await tx
.update(WebsiteTicket)
.set({ priority, updatedAt: now })
.where(eq(WebsiteTicket.id, ticketId));
snapshot = { before, after: { ...before, priority } };
}
await writeAudit(
auditEntry(
context,
operation,
"WebsiteTicket",
ticketId,
snapshot,
"success",
),
tx,
);
});
return snapshot;
}
async function executeTicketTemplateChange(
input: unknown,
context: PeopleSupportMutationContext,
): Promise<PeopleSupportMutationSnapshot> {
const data = record(input);
const action = normalizedText(data.action, 16);
let snapshot!: PeopleSupportMutationSnapshot;
await db.transaction(async (tx) => {
let targetId: number | undefined;
if (action === "create") {
const values = {
title: normalizedText(data.title, 255),
content: normalizedText(data.content, 5_000),
category: normalizedText(data.category ?? "general", 50),
sortOrder:
data.sortOrder === undefined
? 0
: nonNegativeInteger(data.sortOrder),
};
const [result] = await tx.insert(WebsiteTicketTemplate).values(values);
targetId = positiveInteger(result.insertId);
snapshot = { before: null, after: { id: targetId, ...values } };
} else {
const id = positiveInteger(data.id);
targetId = id;
const [existing] = await tx
.select({
id: WebsiteTicketTemplate.id,
title: WebsiteTicketTemplate.title,
content: WebsiteTicketTemplate.content,
category: WebsiteTicketTemplate.category,
sortOrder: WebsiteTicketTemplate.sortOrder,
})
.from(WebsiteTicketTemplate)
.where(eq(WebsiteTicketTemplate.id, id))
.limit(1);
if (action === "update" && !existing) {
throw failure("NOT_FOUND", "errors.housekeeping.notFound");
}
if (action === "delete") {
await tx
.delete(WebsiteTicketTemplate)
.where(eq(WebsiteTicketTemplate.id, id));
snapshot = { before: existing ?? null, after: null };
} else if (action === "update") {
const changes = {
...(data.title === undefined
? {}
: { title: normalizedText(data.title, 255) }),
...(data.content === undefined
? {}
: { content: normalizedText(data.content, 5_000) }),
...(data.category === undefined
? {}
: { category: normalizedText(data.category, 50, false) }),
...(data.sortOrder === undefined
? {}
: { sortOrder: nonNegativeInteger(data.sortOrder) }),
};
await tx
.update(WebsiteTicketTemplate)
.set(changes)
.where(eq(WebsiteTicketTemplate.id, id));
snapshot = {
before: existing ?? null,
after: { ...existing, ...changes },
};
} else {
throw failure("VALIDATION", "errors.housekeeping.validation");
}
}
await writeAudit(
auditEntry(
context,
"ticket-template.change",
"WebsiteTicketTemplate",
targetId,
snapshot,
"success",
),
tx,
);
});
return snapshot;
}
async function executeHelpTicketMutation(
operation: Extract<
PeopleSupportMutationOperation,
`help-ticket.${string}`
>,
input: unknown,
context: PeopleSupportMutationContext,
): Promise<PeopleSupportMutationSnapshot> {
const data = record(input);
let ticketId: bigint;
try {
ticketId = canonicalTicketId(
data.ticketId as string | number | bigint,
);
} catch {
throw failure("VALIDATION", "errors.housekeeping.validation");
}
let snapshot!: PeopleSupportMutationSnapshot;
await db.transaction(async (tx) => {
const [ticket] = await tx
.select({
id: WebsiteHelpCenterTickets.id,
userId: WebsiteHelpCenterTickets.userId,
open: WebsiteHelpCenterTickets.open,
title: WebsiteHelpCenterTickets.title,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket) {
throw failure("NOT_FOUND", "errors.housekeeping.notFound");
}
const id = ticketId.toString();
const before = {
id,
userId: ticket.userId,
open: Boolean(ticket.open),
title: ticket.title,
};
const now = new Date();
if (operation === "help-ticket.reply") {
await tx.insert(WebsiteHelpCenterTicketReplies).values({
ticketId,
userId: context.capability.actor.id,
content: normalizedText(data.content, 5_000),
createdAt: now,
updatedAt: now,
});
await tx
.update(WebsiteHelpCenterTickets)
.set({ updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
snapshot = { before, after: before };
} else if (operation === "help-ticket.status") {
const status = normalizedText(data.status, 16);
const open =
status === "reopen"
? true
: status === "close"
? false
: null;
if (open === null) {
throw failure("VALIDATION", "errors.housekeeping.validation");
}
if (Boolean(ticket.open) === open) {
throw failure("CONFLICT", "errors.housekeeping.conflict");
}
await tx
.update(WebsiteHelpCenterTickets)
.set({ open, updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
snapshot = { before, after: { ...before, open } };
} else {
if (ticket.userId === null) {
throw failure("CONFLICT", "errors.housekeeping.conflict");
}
const deleted = await tx
.delete(Ban)
.where(eq(Ban.userId, ticket.userId));
const removed = Number(
(deleted as unknown as [{ affectedRows: number }])[0]?.affectedRows ??
0,
);
if (ticket.open) {
await tx
.update(WebsiteHelpCenterTickets)
.set({ open: false, updatedAt: now })
.where(eq(WebsiteHelpCenterTickets.id, ticketId));
}
snapshot = {
before,
after: { ...before, open: false, removedBans: removed },
output: { removed, userId: ticket.userId },
};
}
await writeAudit(
auditEntry(
context,
operation,
"WebsiteHelpCenterTickets",
auditTargetId(ticketId),
snapshot,
"success",
),
tx,
);
});
return snapshot;
}
async function executeCfhResolve(
input: unknown,
context: PeopleSupportMutationContext,
): Promise<PeopleSupportMutationSnapshot> {
const data = record(input);
const ticketId = positiveInteger(data.ticketId);
const state = nonNegativeInteger(data.state);
if (state > 3) {
throw failure("VALIDATION", "errors.housekeeping.validation");
}
let snapshot!: PeopleSupportMutationSnapshot;
await db.transaction(async (tx) => {
const [ticket] = await tx
.select({
id: SupportTickets.id,
state: SupportTickets.state,
modId: SupportTickets.modId,
})
.from(SupportTickets)
.where(eq(SupportTickets.id, ticketId))
.limit(1);
if (!ticket) {
throw failure("NOT_FOUND", "errors.housekeeping.notFound");
}
snapshot = {
before: {
id: ticket.id,
state: ticket.state,
moderatorId: ticket.modId,
},
after: {
id: ticket.id,
state,
moderatorId: context.capability.actor.id,
},
};
await tx
.update(SupportTickets)
.set({ state, modId: context.capability.actor.id })
.where(eq(SupportTickets.id, ticketId));
await writeAudit(
auditEntry(
context,
"cfh.resolve",
"support_tickets",
ticketId,
snapshot,
"success",
),
tx,
);
});
return snapshot;
}
return {
async execute(operation, input, context) {
if (operation.startsWith("ticket.")) {
return executeTicketMutation(
operation as Extract<
PeopleSupportMutationOperation,
`ticket.${string}`
>,
input,
context,
);
}
if (operation === "ticket-template.change") {
return executeTicketTemplateChange(input, context);
}
if (operation.startsWith("help-ticket.")) {
return executeHelpTicketMutation(
operation as Extract<
PeopleSupportMutationOperation,
`help-ticket.${string}`
>,
input,
context,
);
}
if (operation === "cfh.resolve") {
return executeCfhResolve(input, context);
}
throw failure("VALIDATION", "errors.housekeeping.validation");
},
};
}
@@ -0,0 +1,43 @@
import "server-only";
import { sql } from "drizzle-orm";
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
import { db } from "@/lib/db";
import { createPeopleQueueAggregateLoader } from "./widgets";
function count(value: unknown): number {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < 0) {
throw new Error("invalid queue count");
}
return parsed;
}
export const loadPeopleQueueAggregate = createPeopleQueueAggregateLoader({
async support(signal) {
if (signal.aborted) throw new Error("aborted queue aggregate");
const counts = await fetchTicketQueueOpenCounts({ strict: true });
return { tickets: counts.cmsOpen, helpTickets: counts.helpOpen };
},
async cfh(signal) {
if (signal.aborted) throw new Error("aborted queue aggregate");
const result = await db.execute(
sql`SELECT COUNT(*) AS total FROM support_tickets WHERE state <> 2`,
);
if (!Array.isArray(result) || !Array.isArray(result[0])) {
throw new Error("invalid CFH queue count");
}
return count((result[0] as Array<{ total: unknown }>)[0]?.total);
},
async activeBans(signal) {
if (signal.aborted) throw new Error("aborted queue aggregate");
const result = await db.execute(sql`
SELECT COUNT(*) AS total FROM bans
WHERE ban_expire = 0 OR ban_expire > UNIX_TIMESTAMP()
`);
if (!Array.isArray(result) || !Array.isArray(result[0])) {
throw new Error("invalid ban queue count");
}
return count((result[0] as Array<{ total: unknown }>)[0]?.total);
},
});
@@ -11,7 +11,6 @@ import {
ok,
} from "../../foundation/contracts";
import type { PeopleQueueSnapshot } from "./models";
import { peopleSupportQuery } from "./queries/support";
export interface PeopleWidgetAdapters {
queue(
@@ -20,6 +19,14 @@ export interface PeopleWidgetAdapters {
): Promise<PeopleQueueSnapshot>;
}
export interface PeopleQueueAggregateAdapters {
support(
signal: AbortSignal,
): Promise<Readonly<{ tickets: number; helpTickets: number }>>;
cfh(signal: AbortSignal): Promise<number>;
activeBans(signal: AbortSignal): Promise<number>;
}
const supportQueueCapability = anyCapability(
PERMS.TICKETS_VIEW,
PERMS.MOD_TICKETS_VIEW,
@@ -38,6 +45,40 @@ const queueCapability = anyCapability(
...banQueueCapability.slugs,
);
function count(value: unknown): number {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < 0) {
throw new Error("invalid queue count");
}
return parsed;
}
export function createPeopleQueueAggregateLoader(
adapters: PeopleQueueAggregateAdapters,
): PeopleWidgetAdapters["queue"] {
return async (context, signal) => {
const authorization = authorizeHousekeeping(context, queueCapability);
if (!authorization.ok) throw new Error("forbidden queue aggregate");
if (signal.aborted) throw new Error("aborted queue aggregate");
const supportAllowed = satisfiesCapability(context, supportQueueCapability);
const cfhAllowed = satisfiesCapability(context, cfhQueueCapability);
const bansAllowed = satisfiesCapability(context, banQueueCapability);
const [support, cfh, activeBans] = await Promise.all([
supportAllowed
? adapters.support(signal)
: Promise.resolve({ tickets: 0, helpTickets: 0 }),
cfhAllowed ? adapters.cfh(signal) : Promise.resolve(0),
bansAllowed ? adapters.activeBans(signal) : Promise.resolve(0),
]);
return {
tickets: count(support.tickets),
helpTickets: count(support.helpTickets),
cfh: count(cfh),
activeBans: count(activeBans),
};
};
}
export function createPeopleWidgets(
adapters: PeopleWidgetAdapters,
): readonly HousekeepingWidgetDefinition[] {
@@ -80,11 +121,8 @@ export function createPeopleWidgets(
}
export const PEOPLE_WIDGETS = createPeopleWidgets({
async queue(context) {
const result = await peopleSupportQuery.run(context, {
routeId: "people.support.queue",
});
if (!result.ok || result.data.kind !== "queue") throw new Error("queue");
return result.data.queue;
async queue(context, signal) {
const { loadPeopleQueueAggregate } = await import("./widgets-production");
return loadPeopleQueueAggregate(context, signal);
},
});
@@ -138,10 +138,20 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
"src/lib/auth",
"src/lib/auth/password",
"src/lib/db",
"src/features/housekeeping/domains/people/services/moderation-mutations",
"src/features/housekeeping/domains/people/services/support-mutations",
"drizzle-orm",
"mysql2",
]),
],
[
"src/features/housekeeping/domains/people/services/moderation-mutations.ts",
new Set(["src/lib/db", "drizzle-orm"]),
],
[
"src/features/housekeeping/domains/people/services/support-mutations.ts",
new Set(["src/lib/db", "drizzle-orm"]),
],
[
"src/features/housekeeping/domains/people/manifest.ts",
new Set([
@@ -170,7 +180,15 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
"src/features/housekeeping/domains/people/widgets.ts",
new Set([
"src/features/housekeeping/domains/people/models",
"src/features/housekeeping/domains/people/queries/support",
"src/features/housekeeping/domains/people/widgets-production",
]),
],
[
"src/features/housekeeping/domains/people/widgets-production.ts",
new Set([
"src/features/housekeeping/domains/people/widgets",
"src/lib/db",
"drizzle-orm",
]),
],
[
@@ -0,0 +1,70 @@
const HOUSEKEEPING_ORIGIN = "https://housekeeping.invalid";
function containsControlCharacter(value: string): boolean {
return Array.from(value).some((character) => {
const code = character.codePointAt(0) ?? 0;
return code < 32 || code === 127;
});
}
function decodedVariants(value: string): readonly string[] | null {
const variants = [value];
for (let pass = 0; pass < 3; pass += 1) {
let decoded: string;
try {
decoded = decodeURIComponent(variants.at(-1) ?? "");
} catch {
return null;
}
if (decoded === variants.at(-1)) break;
variants.push(decoded);
}
return variants;
}
function containsDotSegment(path: string): boolean {
return path.split("/").some((segment) => segment === "." || segment === "..");
}
export function isSafeHousekeepingHref(href: string): boolean {
if (
href.length === 0 ||
!href.startsWith("/") ||
href.startsWith("//") ||
containsControlCharacter(href) ||
href.includes("\\")
) {
return false;
}
const variants = decodedVariants(href);
if (
variants === null ||
variants.some(
(value) => containsControlCharacter(value) || value.includes("\\"),
)
) {
return false;
}
const rawPath = href.split(/[?#]/u, 1)[0] ?? "";
const pathVariants = decodedVariants(rawPath);
if (
pathVariants === null ||
pathVariants.some((path) => containsDotSegment(path))
) {
return false;
}
let normalized: URL;
try {
normalized = new URL(href, HOUSEKEEPING_ORIGIN);
} catch {
return false;
}
return (
normalized.origin === HOUSEKEEPING_ORIGIN &&
(normalized.pathname === "/ase" ||
normalized.pathname.startsWith("/ase/"))
);
}
+35 -7
View File
@@ -19,23 +19,51 @@ export interface TicketReplyDb {
touchTicket(ticketId: bigint, updatedAt: Date): Promise<unknown>;
}
const MAX_UNSIGNED_BIGINT = 18_446_744_073_709_551_615n;
export const MAX_UNSIGNED_TICKET_ID = 18_446_744_073_709_551_615n;
function boundedDecimalPattern(maximum: string): RegExp {
const alternatives = [`[1-9]\\d{0,${maximum.length - 2}}`];
for (let index = 0; index < maximum.length; index += 1) {
const maximumDigit = Number(maximum[index]);
const minimumDigit = index === 0 ? 1 : 0;
const upperDigit = maximumDigit - 1;
if (upperDigit < minimumDigit) continue;
const digit =
upperDigit === minimumDigit
? String(minimumDigit)
: `[${minimumDigit}-${upperDigit}]`;
alternatives.push(
`${maximum.slice(0, index)}${digit}\\d{${maximum.length - index - 1}}`,
);
}
alternatives.push(maximum);
return new RegExp(`^(?:${alternatives.join("|")})$`, "u");
}
export const CANONICAL_TICKET_ID_PATTERN = boundedDecimalPattern(
MAX_UNSIGNED_TICKET_ID.toString(),
);
// Canonicalize a SQL BIGINT identifier without passing through Number.
export function canonicalTicketId(value: string | number | bigint): bigint {
let parsed: bigint;
try {
if (
typeof value === "number" &&
(!Number.isSafeInteger(value) || value <= 0)
) {
if (typeof value === "bigint") {
parsed = value;
} else if (typeof value === "string") {
if (!CANONICAL_TICKET_ID_PATTERN.test(value)) {
throw new Error("noncanonical identifier");
}
parsed = BigInt(value);
} else if (Number.isSafeInteger(value) && value > 0) {
parsed = BigInt(value);
} else {
throw new Error("unsafe identifier");
}
parsed = BigInt(String(value));
} catch {
throw new Error("invalid ticket identifier");
}
if (parsed <= 0n || parsed > MAX_UNSIGNED_BIGINT) {
if (parsed <= 0n || parsed > MAX_UNSIGNED_TICKET_ID) {
throw new Error("invalid ticket identifier");
}
return parsed;