diff --git a/src/actions/admin-help-tickets.ts b/src/actions/admin-help-tickets.ts index 6de92f99..90a0b13d 100644 --- a/src/actions/admin-help-tickets.ts +++ b/src/actions/admin-help-tickets.ts @@ -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, diff --git a/src/actions/moderation.ts b/src/actions/moderation.ts index dfd3e002..4cf1a808 100644 --- a/src/actions/moderation.ts +++ b/src/actions/moderation.ts @@ -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(); - }, + }), ); diff --git a/src/actions/people-support-moderation-wrappers.test.ts b/src/actions/people-support-moderation-wrappers.test.ts index 57f9e62c..60d9e947 100644 --- a/src/actions/people-support-moderation-wrappers.test.ts +++ b/src/actions/people-support-moderation-wrappers.test.ts @@ -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, diff --git a/src/features/housekeeping/domains/people/commands/support-commands.ts b/src/features/housekeeping/domains/people/commands/support-commands.ts index 27cc4eac..b0f0fc54 100644 --- a/src/features/housekeeping/domains/people/commands/support-commands.ts +++ b/src/features/housekeeping/domains/people/commands/support-commands.ts @@ -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( service: Pick, diff --git a/src/features/housekeeping/domains/people/inbox.ts b/src/features/housekeeping/domains/people/inbox.ts index 4e542212..e52beb86 100644 --- a/src/features/housekeeping/domains/people/inbox.ts +++ b/src/features/housekeeping/domains/people/inbox.ts @@ -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; } -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), diff --git a/src/features/housekeeping/domains/people/pages/people-support-pages.test.tsx b/src/features/housekeeping/domains/people/pages/people-support-pages.test.tsx index ebf45b7b..06e68e39 100644 --- a/src/features/housekeeping/domains/people/pages/people-support-pages.test.tsx +++ b/src/features/housekeeping/domains/people/pages/people-support-pages.test.tsx @@ -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( + + ); + 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( + + ); + expect(readOnly).not.toContain("Update template"); + expect(readOnly).not.toContain("Delete template"); + }); + it("renders the loading state before data arrives", () => { expect( renderToStaticMarkup(), diff --git a/src/features/housekeeping/domains/people/pages/support.tsx b/src/features/housekeeping/domains/people/pages/support.tsx index dfe6ebb5..02a6d1d4 100644 --- a/src/features/housekeeping/domains/people/pages/support.tsx +++ b/src/features/housekeeping/domains/people/pages/support.tsx @@ -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 ( {template.title}

{template.content}

{context.has(PERMS.TICKETS_EDIT) ? ( - +
+ + +
) : null} ))} diff --git a/src/features/housekeeping/domains/people/people-providers.test.ts b/src/features/housekeeping/domains/people/people-providers.test.ts index 229791c9..16fe6f07 100644 --- a/src/features/housekeeping/domains/people/people-providers.test.ts +++ b/src/features/housekeeping/domains/people/people-providers.test.ts @@ -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); + }); }); diff --git a/src/features/housekeeping/domains/people/people-widgets-production.test.ts b/src/features/housekeeping/domains/people/people-widgets-production.test.ts new file mode 100644 index 00000000..033018ad --- /dev/null +++ b/src/features/housekeeping/domains/people/people-widgets-production.test.ts @@ -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(); + }); +}); diff --git a/src/features/housekeeping/domains/people/search.ts b/src/features/housekeeping/domains/people/search.ts index 2efc2c6f..6e9bf8a6 100644 --- a/src/features/housekeeping/domains/people/search.ts +++ b/src/features/housekeeping/domains/people/search.ts @@ -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; } -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) diff --git a/src/features/housekeeping/domains/people/services/moderation-mutations.ts b/src/features/housekeeping/domains/people/services/moderation-mutations.ts new file mode 100644 index 00000000..78f696a1 --- /dev/null +++ b/src/features/housekeeping/domains/people/services/moderation-mutations.ts @@ -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> | null; + readonly after: Readonly> | null; + readonly output?: Readonly>; + readonly completion?: HousekeepingPartialCompletion; +} + +type AuditOutcome = "intent" | "success" | "failure" | "partial"; +type ModerationTransport = ModerationActionTransport & { + disconnectUser(userId: number, username?: string): Promise; +}; + +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; + readonly positiveInteger: (value: unknown) => number; + readonly nonNegativeInteger: (value: unknown) => number; + readonly normalizedText: ( + value: unknown, + max: number, + required?: boolean, + ) => string; + readonly requireRcon: (result: boolean) => Promise; + readonly transport: ModerationTransport; + readonly executeModerationAction: ( + transport: ModerationActionTransport, + input: ModerationAction, + ) => Promise; + readonly logStaffActivity: (input: { + staffId: number; + action: string; + description: string; + targetType?: string; + targetId?: number; + }) => Promise; + readonly runExternalWithAudit: ( + context: PeopleModerationMutationContext, + operation: PeopleModerationMutationOperation, + target: string, + targetId: number | undefined, + snapshot: PeopleModerationMutationSnapshot, + execute: () => Promise, + confirmedFailureSnapshot: PeopleModerationMutationSnapshot, + ) => Promise; + readonly finalizeExternalWithAudit: ( + context: PeopleModerationMutationContext, + operation: PeopleModerationMutationOperation, + target: string, + targetId: number | undefined, + snapshot: PeopleModerationMutationSnapshot, + execute: () => Promise, + mutationCommitted?: boolean, + confirmedFailureSnapshot?: PeopleModerationMutationSnapshot, + ) => Promise; +} + +export interface PeopleModerationMutationExecutor { + execute( + operation: PeopleModerationMutationOperation, + input: unknown, + context: PeopleModerationMutationContext, + ): Promise; +} + +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): 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 { + const delivered = await executeModerationAction(transport, input); + if (!context.legacy) await requireRcon(delivered); + } + + async function executeModerationMutation( + input: unknown, + context: PeopleModerationMutationContext, + ): Promise { + 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 { + 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, + input: unknown, + context: PeopleModerationMutationContext, + ): Promise { + 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"); + }, + }; +} diff --git a/src/features/housekeeping/domains/people/services/mutations-production-workflows.test.ts b/src/features/housekeeping/domains/people/services/mutations-production-workflows.test.ts index 6d123986..1903d7ff 100644 --- a/src/features/housekeeping/domains/people/services/mutations-production-workflows.test.ts +++ b/src/features/housekeeping/domains/people/services/mutations-production-workflows.test.ts @@ -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()), logAudit: mocks.audit, })); -vi.mock("@/lib/services/moderation", () => ({ +vi.mock("@/lib/services/moderation", async (importOriginal) => ({ + ...(await importOriginal()), 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(); + }); }); diff --git a/src/features/housekeeping/domains/people/services/mutations-server-auth.test.ts b/src/features/housekeeping/domains/people/services/mutations-server-auth.test.ts index c68b9994..555ee067 100644 --- a/src/features/housekeeping/domains/people/services/mutations-server-auth.test.ts +++ b/src/features/housekeeping/domains/people/services/mutations-server-auth.test.ts @@ -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(); + }); }); diff --git a/src/features/housekeeping/domains/people/services/mutations.ts b/src/features/housekeeping/domains/people/services/mutations.ts index 33173b7d..87517a2d 100644 --- a/src/features/housekeeping/domains/people/services/mutations.ts +++ b/src/features/housekeeping/domains/people/services/mutations.ts @@ -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 { - 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 { - 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, - input: unknown, - context: PeopleMutationContext, -): Promise { - 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 { - 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): 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 { - await requireRcon(await executeModerationAction(rcon, input)); -} - -async function executeModerationMutation( - input: unknown, - context: PeopleMutationContext, -): Promise { - 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 { - 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 { - 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, - 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", diff --git a/src/features/housekeeping/domains/people/services/support-mutations.ts b/src/features/housekeeping/domains/people/services/support-mutations.ts new file mode 100644 index 00000000..c89ed4fd --- /dev/null +++ b/src/features/housekeeping/domains/people/services/support-mutations.ts @@ -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> | null; + readonly after: Readonly> | null; + readonly output?: Readonly>; + 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; + 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; +} + +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, + input: unknown, + context: PeopleSupportMutationContext, + ): Promise { + 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 { + 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 { + 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 { + 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"); + }, + }; +} diff --git a/src/features/housekeeping/domains/people/widgets-production.ts b/src/features/housekeeping/domains/people/widgets-production.ts new file mode 100644 index 00000000..9fee326e --- /dev/null +++ b/src/features/housekeeping/domains/people/widgets-production.ts @@ -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); + }, +}); diff --git a/src/features/housekeeping/domains/people/widgets.ts b/src/features/housekeeping/domains/people/widgets.ts index 8b286bac..9f3faf8f 100644 --- a/src/features/housekeeping/domains/people/widgets.ts +++ b/src/features/housekeeping/domains/people/widgets.ts @@ -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; } +export interface PeopleQueueAggregateAdapters { + support( + signal: AbortSignal, + ): Promise>; + cfh(signal: AbortSignal): Promise; + activeBans(signal: AbortSignal): Promise; +} + 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); }, }); diff --git a/src/features/housekeeping/foundation/foundation-source-contract.test.ts b/src/features/housekeeping/foundation/foundation-source-contract.test.ts index 76048538..d44fd1f1 100644 --- a/src/features/housekeeping/foundation/foundation-source-contract.test.ts +++ b/src/features/housekeeping/foundation/foundation-source-contract.test.ts @@ -138,10 +138,20 @@ const approvedRuntimeImports = new Map>([ "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>([ "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", ]), ], [ diff --git a/src/features/housekeeping/foundation/housekeeping-href.ts b/src/features/housekeeping/foundation/housekeeping-href.ts new file mode 100644 index 00000000..fefdfcda --- /dev/null +++ b/src/features/housekeeping/foundation/housekeeping-href.ts @@ -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/")) + ); +} diff --git a/src/lib/services/ticket-replies.ts b/src/lib/services/ticket-replies.ts index 1fd76ffc..008a87b8 100644 --- a/src/lib/services/ticket-replies.ts +++ b/src/lib/services/ticket-replies.ts @@ -19,23 +19,51 @@ export interface TicketReplyDb { touchTicket(ticketId: bigint, updatedAt: Date): Promise; } -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;