import "server-only"; import crypto from "node:crypto"; import { and, desc, eq, gt, inArray, max, or, sql } from "drizzle-orm"; import type { ResultSetHeader } from "mysql2"; import { AuditOutcomePersistenceError } from "@/features/housekeeping/foundation/commands/audit-envelope"; import { invalidateLoginCache } from "@/lib/auth"; import { hashPassword } from "@/lib/auth/password"; import { Ban, type Db, db, GuildForumViews, Guilds, GuildsForumsComments, GuildsForumsThreads, GuildsMembers, Items, Rooms, Sanctions, User, UsersBadges, UsersCurrency, UsersSettings, WebsiteIpBlacklist, WebsiteIpWhitelist, WebsiteSetting, WebsiteStaffApplications, WebsiteTeams, WebsiteWordfilter, } from "@/lib/db"; import { PERMS } from "@/lib/permission-slugs"; import { logAudit } from "@/lib/services/audit"; import { executeModerationAction, reloadWordFilter, } from "@/lib/services/moderation"; import { RankAssignmentFailure, type RankAssignmentTransaction, rankAssignmentCoordinator, } from "@/lib/services/rank-assignment"; import { rcon } from "@/lib/services/rcon"; import { siteSettings } from "@/lib/services/site-settings"; import { logStaffActivity } from "@/lib/services/staff-activity"; import { canonicalTicketId } from "@/lib/services/ticket-replies"; import { notify } from "@/lib/services/webhook"; import { satisfiesCapability } from "../../../foundation/capability-context"; import { anyCapability, fail, type HousekeepingCapabilityContext, type HousekeepingErrorCode, type HousekeepingPartialCompletion, type HousekeepingResult, 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" | "user.ban" | "user.unban" | "user.alert" | "user.disconnect" | "user.mute" | "user.unmute" | "user.reset-password" | "user.send-currency" | "user.trade-lock" | "users.bulk-ban" | "users.bulk-unban" | "users.bulk-currency" | "users.bulk-badge" | "guild.disband" | "application.decide" | "team.change" | "ip.action" | "vpn.configure" | "word-filter.update" | "ticket.reply" | "ticket.assign" | "ticket.status" | "ticket.priority" | "ticket-template.change" | "help-ticket.reply" | "help-ticket.status" | "help-ticket.unban" | "cfh.resolve" | "cfh.sanction" | "ban.create" | "ban.lift" | "moderation.action"; export interface PeopleMutationSnapshot { readonly before: Readonly> | null; readonly after: Readonly> | null; readonly output?: Readonly>; readonly completion?: HousekeepingPartialCompletion; } export interface PeopleMutationInvocation { readonly correlationId: string; readonly expectedActorId: number; readonly legacy?: boolean; } export interface PeopleMutationContext { readonly capability: HousekeepingCapabilityContext; readonly correlationId: string; readonly legacy: boolean; } export interface PeopleMutationAdapter { execute( operation: PeopleMutationOperation, input: unknown, context: PeopleMutationContext, ): Promise; } export interface PeopleMutationService { execute( invocation: PeopleMutationInvocation, operation: PeopleMutationOperation, input: unknown, ): Promise>; } export type ResolvePeopleCapabilityContext = () => Promise; class PeopleMutationFailure extends Error { constructor( readonly code: HousekeepingErrorCode, readonly messageKey: string, readonly fieldErrors?: Readonly>, ) { super(messageKey); this.name = "PeopleMutationFailure"; } } class ConfirmedExternalNoopFailure extends PeopleMutationFailure {} class CompletedExternalEffectFailure extends Error { constructor(readonly cause: unknown) { super("External effect completed before the authority transaction failed"); this.name = "CompletedExternalEffectFailure"; } } function operationCapability(operation: PeopleMutationOperation) { if ( operation === "user.ban" || operation === "user.unban" || operation === "users.bulk-ban" || operation === "users.bulk-unban" || operation === "ban.create" || operation === "ban.lift" || operation === "help-ticket.unban" ) { return anyCapability(PERMS.USERS_BAN); } if ( operation.startsWith("ticket.") || operation === "help-ticket.reply" || operation === "help-ticket.status" ) { return anyCapability(PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT); } if (operation === "ticket-template.change") { return anyCapability(PERMS.TICKETS_EDIT); } if (operation === "cfh.resolve" || operation === "cfh.sanction") { return anyCapability(PERMS.MODERATION_EDIT, PERMS.MOD_CFH_EDIT); } if (operation === "moderation.action") { return anyCapability(PERMS.MODERATION_EDIT, PERMS.MOD_ACTIONS); } if (operation === "user.reset-password") { return anyCapability(PERMS.USERS_RESET_PASSWORD); } if (operation === "ip.action" || operation === "vpn.configure") { return anyCapability(PERMS.SETTINGS_EDIT); } if (operation === "word-filter.update") { return anyCapability(PERMS.WORDFILTER_EDIT); } return anyCapability(PERMS.USERS_EDIT); } export function createPeopleMutationService( adapter: PeopleMutationAdapter, resolveCapabilityContext: ResolvePeopleCapabilityContext, ): PeopleMutationService { return { async execute(invocation, operation, input) { let capability: HousekeepingCapabilityContext; try { capability = await resolveCapabilityContext(); } catch { return fail( "UNAUTHENTICATED", "errors.housekeeping.unauthenticated", invocation.correlationId, ); } if ( capability.actor.id !== invocation.expectedActorId || !satisfiesCapability(capability, operationCapability(operation)) ) { return fail( "FORBIDDEN", "errors.housekeeping.forbidden", invocation.correlationId, ); } const context: PeopleMutationContext = { capability, correlationId: invocation.correlationId, legacy: invocation.legacy === true, }; try { const snapshot = await adapter.execute(operation, input, context); return ok(snapshot, invocation.correlationId, snapshot.completion); } catch (error) { if (error instanceof AuditOutcomePersistenceError) { const operationSnapshot = error.operationResult as PeopleMutationSnapshot; const completion: HousekeepingPartialCompletion = { status: "partial", external: operationSnapshot.completion?.external ?? "completed", audit: "unavailable", }; const snapshot = { ...operationSnapshot, completion, }; return ok(snapshot, invocation.correlationId, completion); } if (error instanceof PeopleMutationFailure) { return fail( error.code, error.messageKey, invocation.correlationId, error.fieldErrors, ); } return fail( "DEPENDENCY_UNAVAILABLE", "errors.housekeeping.dependencyUnavailable", invocation.correlationId, ); } }, }; } export function createPeopleMutationInvocation( actor: { readonly id: number }, correlationId: string, ): PeopleMutationInvocation { return { correlationId, expectedActorId: actor.id, legacy: true }; } function record(input: unknown): Record { if (typeof input !== "object" || input === null || Array.isArray(input)) { throw new PeopleMutationFailure( "VALIDATION", "errors.housekeeping.validation", ); } return input as Record; } function positiveInteger(value: unknown): number { const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed <= 0) { throw new PeopleMutationFailure( "VALIDATION", "errors.housekeeping.validation", ); } return parsed; } const MAX_UNSIGNED_BIGINT = 18_446_744_073_709_551_615n; const MAX_AUDIT_TARGET_ID = 2_147_483_647n; function positiveBigIntIdentifier(value: unknown): bigint { let parsed: bigint; if (typeof value === "bigint") { parsed = value; } else if (typeof value === "string" && /^[1-9]\d{0,19}$/u.test(value)) { parsed = BigInt(value); } else if ( typeof value === "number" && Number.isSafeInteger(value) && value > 0 ) { parsed = BigInt(value); } else { throw new PeopleMutationFailure( "VALIDATION", "errors.housekeeping.validation", ); } if (parsed <= 0n || parsed > MAX_UNSIGNED_BIGINT) { throw new PeopleMutationFailure( "VALIDATION", "errors.housekeeping.validation", ); } return parsed; } function serializeBigIntIdentifier(value: unknown): string { return positiveBigIntIdentifier(value).toString(); } function auditTargetId(value: bigint): number | undefined { return value <= MAX_AUDIT_TARGET_ID ? Number(value) : undefined; } function nonNegativeInteger(value: unknown): number { const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed < 0) { throw new PeopleMutationFailure( "VALIDATION", "errors.housekeeping.validation", ); } return parsed; } function normalizedText(value: unknown, max: number, required = true): string { const text = String(value ?? "") .normalize("NFC") .trim() .slice(0, max); if (required && text.length === 0) { throw new PeopleMutationFailure( "VALIDATION", "errors.housekeeping.validation", ); } return text; } function userIds(value: unknown, legacy: boolean): number[] { if ( !Array.isArray(value) || value.length === 0 || (!legacy && value.length > 100) ) { throw new PeopleMutationFailure( "VALIDATION", "errors.housekeeping.validation", ); } return value.map(positiveInteger); } async function requireRcon(result: boolean): Promise { if (!result) { throw new ConfirmedExternalNoopFailure( "DEPENDENCY_UNAVAILABLE", "errors.housekeeping.dependencyUnavailable", ); } } type TargetUser = { id: number; username: string; rank: number; ipCurrent: string; machineId: string; motto: string; credits: number; pixels: number; online: string; }; async function loadTarget( userId: number, context: PeopleMutationContext, guardHierarchy: boolean, source: Pick = db, lock = false, ): Promise { const query = source .select({ id: User.id, username: User.username, rank: User.rank, ipCurrent: User.ipCurrent, machineId: User.machineId, motto: User.motto, credits: User.credits, pixels: User.pixels, online: User.online, }) .from(User) .where(eq(User.id, userId)); const [target] = lock ? await query.for("update") : await query.limit(1); if (!target) { throw new PeopleMutationFailure( "NOT_FOUND", "errors.housekeeping.notFound", ); } if ( guardHierarchy && target.rank >= context.capability.actor.rank && !context.capability.isSuperAdmin ) { throw new PeopleMutationFailure( "FORBIDDEN", "errors.housekeeping.forbidden", ); } return target; } async function runWithLockedTargetAuthority( userId: number, context: PeopleMutationContext, execute: () => Promise, afterRelease?: () => Promise, ): Promise { let externalCompleted = false; let completedFailure: CompletedExternalEffectFailure | undefined; try { await db.transaction(async (tx) => { await loadTarget(userId, context, true, tx, true); await execute(); externalCompleted = true; }); } catch (error) { if (!externalCompleted) throw error; completedFailure = new CompletedExternalEffectFailure(error); } try { await afterRelease?.(); } catch (error) { completedFailure ??= new CompletedExternalEffectFailure(error); } if (completedFailure) throw completedFailure; } async function assertBulkTargetHierarchy( userIds: readonly number[], context: PeopleMutationContext, ): Promise { const uniqueIds = [...new Set(userIds)]; const targets = await db .select({ id: User.id, rank: User.rank }) .from(User) .where(inArray(User.id, uniqueIds)); if (targets.length !== uniqueIds.length) { throw new PeopleMutationFailure( "NOT_FOUND", "errors.housekeeping.notFound", ); } if ( !context.capability.isSuperAdmin && targets.some((target) => target.rank >= context.capability.actor.rank) ) { throw new PeopleMutationFailure( "FORBIDDEN", "errors.housekeeping.forbidden", ); } } function targetSnapshot(target: TargetUser) { return { id: target.id, username: target.username, rank: target.rank, motto: target.motto, credits: target.credits, pixels: target.pixels, online: target.online === "1", }; } function canonicalAuditEntry( context: PeopleMutationContext, operation: PeopleMutationOperation, target: string, targetId: number | undefined, snapshot: PeopleMutationSnapshot, outcome: "intent" | "success" | "failure" | "partial", ) { return { userId: context.capability.actor.id, action: `people.${operation}`, target, targetId, before: snapshot.before ?? undefined, after: snapshot.after ?? undefined, correlationId: context.correlationId, outcome, domain: "people" as const, }; } async function finalizeExternalWithAudit( context: PeopleMutationContext, operation: PeopleMutationOperation, target: string, targetId: number | undefined, snapshot: PeopleMutationSnapshot, execute: () => Promise, mutationCommitted = true, confirmedFailureSnapshot?: PeopleMutationSnapshot, ): Promise { try { await execute(); } catch (error) { if (error instanceof CompletedExternalEffectFailure) { let audit: HousekeepingPartialCompletion["audit"] = "persisted"; try { await logAudit( canonicalAuditEntry( context, operation, target, targetId, snapshot, "partial", ), ); } catch { audit = "unavailable"; } return withPartialCompletion(snapshot, { status: "partial", external: "completed", audit, }); } const failureSnapshot = mutationCommitted ? snapshot : error instanceof ConfirmedExternalNoopFailure ? (confirmedFailureSnapshot ?? { before: snapshot.before, after: null }) : { before: snapshot.before, after: null }; let audit: HousekeepingPartialCompletion["audit"] = "persisted"; try { await logAudit( canonicalAuditEntry( context, operation, target, targetId, failureSnapshot, mutationCommitted ? "partial" : "failure", ), ); } catch { audit = "unavailable"; } if ( !mutationCommitted || (context.legacy && !(error instanceof PeopleMutationFailure)) ) { throw error; } return withPartialCompletion(snapshot, { status: "partial", external: "failed", audit, }); } try { await logAudit( canonicalAuditEntry( context, operation, target, targetId, snapshot, "success", ), ); return snapshot; } catch { let audit: HousekeepingPartialCompletion["audit"] = "persisted"; try { await logAudit( canonicalAuditEntry( context, operation, target, targetId, snapshot, "partial", ), ); } catch { audit = "unavailable"; } return withPartialCompletion(snapshot, { status: "partial", external: "completed", audit, }); } } function withPartialCompletion( snapshot: PeopleMutationSnapshot, completion: HousekeepingPartialCompletion, ): PeopleMutationSnapshot { return { ...snapshot, completion }; } async function runExternalWithAudit( context: PeopleMutationContext, operation: PeopleMutationOperation, target: string, targetId: number | undefined, snapshot: PeopleMutationSnapshot, execute: () => Promise, confirmedFailureSnapshot: PeopleMutationSnapshot, ): Promise { await logAudit( canonicalAuditEntry( context, operation, target, targetId, snapshot, "intent", ), ); return finalizeExternalWithAudit( context, operation, target, targetId, snapshot, execute, false, confirmedFailureSnapshot, ); } async function executeUserMutation( operation: Extract, input: unknown, context: PeopleMutationContext, ): Promise { const data = record(input); const userId = positiveInteger(data.userId); if (operation === "user.alert") { const message = normalizedText(data.message, 500); const snapshot: PeopleMutationSnapshot = { before: null, after: { userId, alertDelivered: true }, }; await db.transaction(async (tx) => { await loadTarget(userId, context, true, tx, true); await logAudit( canonicalAuditEntry( context, operation, "User", userId, snapshot, "intent", ), tx, ); }); return finalizeExternalWithAudit( context, operation, "User", userId, snapshot, () => runWithLockedTargetAuthority(userId, context, async () => { await requireRcon(await rcon.alertUser(userId, message)); }), false, { before: null, after: { userId, alertDelivered: false } }, ); } let target!: TargetUser; let before!: ReturnType; if (operation !== "user.trade-lock") { target = await loadTarget(userId, context, true); before = targetSnapshot(target); } if (operation === "user.update") { const fields = record(data.fields); const nextRank = fields.rank; if (nextRank !== undefined) { const rank = positiveInteger(nextRank); if ( rank >= context.capability.actor.rank && !context.capability.isSuperAdmin ) { throw new PeopleMutationFailure( "FORBIDDEN", "errors.housekeeping.forbidden", ); } } const userPatch = Object.fromEntries( ["username", "mail", "motto", "credits", "pixels"].flatMap((key) => fields[key] === undefined ? [] : [[key, fields[key]]], ), ); const safeFields = Object.fromEntries( Object.entries(fields).filter(([key]) => key !== "mail"), ); if (fields.mail !== undefined) safeFields.mailChanged = true; const after: Record = { ...before, ...safeFields }; const rankSyncAudit = { userId: context.capability.actor.id, action: "system.external-sync", target: "rcon.set-rank", targetId: userId, correlationId: context.correlationId, domain: "system" as const, after: { kind: "set-rank", operation: "rcon.set-rank", userId, rank: nextRank, }, }; if (nextRank !== undefined) after.rankRecoveryId = context.correlationId; const snapshot: PeopleMutationSnapshot = { before, after, }; const persist = async (tx: RankAssignmentTransaction) => { if (nextRank !== undefined) await logAudit({ ...rankSyncAudit, outcome: "intent" }, tx); if (Object.keys(userPatch).length > 0) { await tx.update(User).set(userPatch).where(eq(User.id, userId)); } for (const [key, type] of [ ["duckets", 0], ["diamonds", 5], ] as const) { if (fields[key] === undefined) continue; const amount = nonNegativeInteger(fields[key]); await tx .insert(UsersCurrency) .values({ userId, type, amount }) .onDuplicateKeyUpdate({ set: { amount } }); } await logAudit( canonicalAuditEntry( context, operation, "User", userId, snapshot, "intent", ), tx, ); }; if (nextRank !== undefined) { try { await rankAssignmentCoordinator.commit({ userId, rank: positiveInteger(nextRank), authorize(currentRank) { before.rank = currentRank; if ( !context.capability.isSuperAdmin && currentRank >= context.capability.actor.rank ) { throw new PeopleMutationFailure( "FORBIDDEN", "errors.housekeeping.forbidden", ); } }, mutate: persist, }); } catch (error) { if (error instanceof RankAssignmentFailure) { if (error.code === "RANK_NOT_FOUND") { throw new PeopleMutationFailure( "VALIDATION", "errors.validation.invalid", { rank: ["errors.validation.invalid"], }, ); } throw new PeopleMutationFailure( "NOT_FOUND", "errors.housekeeping.notFound", ); } throw error; } } else { await db.transaction(persist); } let rankAuditUnavailable = false; const completed = await finalizeExternalWithAudit( context, operation, "User", userId, snapshot, async () => { invalidateLoginCache(target.username); if (nextRank !== undefined) { const delivery = await rankAssignmentCoordinator.synchronize(userId); after.rankSynchronization = delivery.status; if (delivery.status !== "superseded") after.synchronizedRank = delivery.rank; try { await logAudit({ ...rankSyncAudit, after: { ...rankSyncAudit.after, ...(delivery.status === "superseded" ? { superseded: true } : { rank: delivery.rank }), }, outcome: delivery.status === "pending" ? "partial" : "success", }); } catch { rankAuditUnavailable = true; after.rankCompletionAudit = "pending"; } if (delivery.status === "pending") { throw new PeopleMutationFailure( "DEPENDENCY_UNAVAILABLE", "errors.housekeeping.dependencyUnavailable", ); } } void notify({ action: "user_edit", actor: context.capability.actor.username, target: target.username, targetId: userId, }); }, ); if (rankAuditUnavailable) { return withPartialCompletion(completed, { status: "partial", external: completed.completion?.external ?? "completed", audit: "unavailable", }); } return completed; } if (operation === "user.ban") { const reason = normalizedText(data.reason, 500); const duration = nonNegativeInteger(data.duration); const typeValue = normalizedText(data.type || "account", 32); if (!(["account", "ip", "machine"] as const).includes(typeValue as never)) { throw new PeopleMutationFailure( "VALIDATION", "errors.housekeeping.validation", ); } const type = typeValue as "account" | "ip" | "machine"; const now = Math.floor(Date.now() / 1000); const banExpire = duration > 0 ? now + duration * 3600 : 0; let snapshot!: PeopleMutationSnapshot; await db.transaction(async (tx) => { const [activeBan] = await tx .select({ id: Ban.id, banExpire: Ban.banExpire, banReason: Ban.banReason, type: Ban.type, }) .from(Ban) .where( and( eq(Ban.userId, userId), or(eq(Ban.banExpire, 0), gt(Ban.banExpire, now)), ), ) .orderBy(desc(Ban.timestamp), desc(Ban.id)) .limit(1); await tx.insert(Ban).values({ userId, userStaffId: context.capability.actor.id, timestamp: now, banExpire, banReason: reason, type, ip: normalizedText(data.ip, 255, false), machineId: "", }); const [observedAfterBan] = await tx .select({ id: Ban.id, banExpire: Ban.banExpire, banReason: Ban.banReason, type: Ban.type, }) .from(Ban) .where( and( eq(Ban.userId, userId), or(eq(Ban.banExpire, 0), gt(Ban.banExpire, now)), ), ) .orderBy(desc(Ban.timestamp), desc(Ban.id)) .limit(1); snapshot = { before: { ...before, banned: activeBan !== undefined, ...(activeBan ? { banExpire: activeBan.banExpire, type: activeBan.type, reason: activeBan.banReason, } : {}), }, after: { ...before, banned: observedAfterBan !== undefined, ...(observedAfterBan ? { banExpire: observedAfterBan.banExpire, type: observedAfterBan.type, reason: observedAfterBan.banReason, } : {}), }, }; await logAudit( canonicalAuditEntry( context, operation, "User", userId, snapshot, "intent", ), tx, ); }); return finalizeExternalWithAudit( context, operation, "User", userId, snapshot, async () => { await requireRcon(await rcon.disconnectUser(userId)); void notify({ action: "ban", actor: context.capability.actor.username, target: target.username, details: reason, }); }, ); } if (operation === "user.unban") { const now = Math.floor(Date.now() / 1000); const snapshot = await db.transaction(async (tx) => { const [activeBan] = await tx .select({ id: Ban.id, banExpire: Ban.banExpire, banReason: Ban.banReason, type: Ban.type, }) .from(Ban) .where( and( eq(Ban.userId, userId), or(eq(Ban.banExpire, 0), gt(Ban.banExpire, now)), ), ) .orderBy(desc(Ban.timestamp), desc(Ban.id)) .limit(1); await tx.delete(Ban).where(eq(Ban.userId, userId)); const [observedAfterBan] = await tx .select({ id: Ban.id, banExpire: Ban.banExpire, banReason: Ban.banReason, type: Ban.type, }) .from(Ban) .where( and( eq(Ban.userId, userId), or(eq(Ban.banExpire, 0), gt(Ban.banExpire, now)), ), ) .orderBy(desc(Ban.timestamp), desc(Ban.id)) .limit(1); const value: PeopleMutationSnapshot = { before: { ...before, banned: activeBan !== undefined, ...(activeBan ? { banExpire: activeBan.banExpire, type: activeBan.type, reason: activeBan.banReason, } : {}), }, after: { ...before, banned: observedAfterBan !== undefined, ...(observedAfterBan ? { banExpire: observedAfterBan.banExpire, type: observedAfterBan.type, reason: observedAfterBan.banReason, } : {}), }, }; await logAudit( canonicalAuditEntry( context, operation, "User", userId, value, "success", ), tx, ); return value; }); void notify({ action: "unban", actor: context.capability.actor.username, target: target.username, }); return snapshot; } if (operation === "user.disconnect") { const snapshot = { before, after: { ...before, online: false } }; return runExternalWithAudit( context, operation, "User", userId, snapshot, async () => { await requireRcon(await rcon.disconnectUser(userId)); void notify({ action: "disconnect", actor: context.capability.actor.username, target: target.username, }); }, { before, after: before }, ); } if (operation === "user.mute" || operation === "user.unmute") { const duration = operation === "user.mute" ? nonNegativeInteger(data.duration) : 0; const snapshot = { before, after: { ...before, muted: operation === "user.mute", ...(operation === "user.mute" ? { duration } : {}), }, }; return runExternalWithAudit( context, operation, "User", userId, snapshot, async () => { if (operation === "user.mute") { await requireRcon(await rcon.muteUser(userId, duration)); } else { await requireRcon(await rcon.unmuteUser(userId)); } }, { before, after: before }, ); } if (operation === "user.reset-password") { const newPassword = crypto .randomBytes(12) .toString("base64url") .slice(0, 16); const hashedPassword = await hashPassword(newPassword); const snapshot: PeopleMutationSnapshot = { before: { ...before, passwordReset: false }, after: { ...before, passwordReset: true }, output: { newPassword }, }; await db.transaction(async (tx) => { await tx .update(User) .set({ password: hashedPassword }) .where(eq(User.id, userId)); await logAudit( canonicalAuditEntry( context, operation, "User", userId, snapshot, "intent", ), tx, ); }); return finalizeExternalWithAudit( context, operation, "User", userId, snapshot, async () => { invalidateLoginCache(target.username); void notify({ action: "user_edit", actor: context.capability.actor.username, target: target.username, details: "Password reset", }); }, ); } if (operation === "user.send-currency") { const amount = positiveInteger(data.amount); const snapshot = { before, after: { ...before, creditsSent: amount } }; return runExternalWithAudit( context, operation, "User", userId, snapshot, async () => requireRcon(await rcon.giveCredits(userId, amount)), { before, after: before }, ); } const untilUnix = nonNegativeInteger(data.untilUnix); const locked = untilUnix > 0; let snapshot!: PeopleMutationSnapshot; await db.transaction(async (tx) => { target = await loadTarget(userId, context, true, tx, true); before = targetSnapshot(target); const [existing] = await tx .select({ id: Sanctions.id, tradeLockedUntil: Sanctions.tradeLockedUntil, reason: Sanctions.reason, }) .from(Sanctions) .where(eq(Sanctions.habboId, userId)) .limit(1); const [settings] = await tx .select({ canTrade: UsersSettings.canTrade, tradelockAmount: UsersSettings.tradelockAmount, }) .from(UsersSettings) .where(eq(UsersSettings.userId, userId)) .limit(1); if (existing) { await tx .update(Sanctions) .set({ tradeLockedUntil: untilUnix, ...(locked ? { reason: "Trade lock (CMS)" } : {}), }) .where(eq(Sanctions.id, existing.id)); } else { await tx.insert(Sanctions).values({ habboId: userId, tradeLockedUntil: untilUnix, reason: locked ? "Trade lock (CMS)" : "", }); } await tx .update(UsersSettings) .set({ canTrade: locked ? "0" : "1", ...(locked ? { tradelockAmount: sql`${UsersSettings.tradelockAmount} + 1` } : {}), }) .where(eq(UsersSettings.userId, userId)); snapshot = { before: { ...before, tradeLockedUntil: existing?.tradeLockedUntil ?? 0, canTrade: settings?.canTrade ?? null, tradelockAmount: settings?.tradelockAmount ?? null, }, after: { ...before, tradeLockedUntil: untilUnix, canTrade: settings ? (locked ? "0" : "1") : null, tradelockAmount: settings ? settings.tradelockAmount + (locked ? 1 : 0) : null, }, output: { userId, untilUnix }, }; await logAudit( canonicalAuditEntry( context, operation, "User", userId, snapshot, "intent", ), tx, ); }); return finalizeExternalWithAudit( context, operation, "User", userId, snapshot, () => runWithLockedTargetAuthority( userId, context, async () => { await requireRcon(await rcon.setTradeLock(userId, locked)); await requireRcon( await rcon.alertUser( userId, locked ? "Trading has been disabled by staff." : "Trading has been re-enabled by staff.", ), ); if (target.online === "1") { await requireRcon( await rcon.disconnectUser(userId, target.username), ); } }, () => logStaffActivity({ staffId: context.capability.actor.id, action: locked ? "trade_lock" : "trade_unlock", description: locked ? `Trade-locked ${target.username} (#${userId}) until ${untilUnix}` : `Cleared trade lock for ${target.username} (#${userId})`, targetType: "user", targetId: userId, }), ), ); } async function executeBulkMutation( operation: Extract, input: unknown, context: PeopleMutationContext, ): Promise { const data = record(input); const ids = userIds(data.userIds, context.legacy); await assertBulkTargetHierarchy(ids, context); const failures: Array<{ userId: number; reason: string }> = []; const externalSyncFailures: Array<{ userId: number; reason: string }> = []; const externalSyncFailureReason = "Database applied; emulator sync failed. Do not retry automatically."; let completed = 0; let mutationReason: string | undefined; if (operation === "users.bulk-unban") { const snapshot = await db.transaction(async (tx) => { const result = await tx.delete(Ban).where(inArray(Ban.userId, ids)); completed = Number( (result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0, ); const value: PeopleMutationSnapshot = { before: { userIds: ids }, after: { completed, total: ids.length, failedIds: failures }, }; await logAudit( canonicalAuditEntry( context, operation, "User", undefined, value, "success", ), tx, ); return value; }); await logStaffActivity({ staffId: context.capability.actor.id, action: "bulk_unban", description: `Unbanned ${completed} user(s)`, targetType: "user", }); return snapshot; } if (operation === "users.bulk-ban") { const reason = normalizedText(data.reason, 500); mutationReason = reason; const durationSeconds = nonNegativeInteger(data.duration); const now = Math.floor(Date.now() / 1000); const snapshot = await db.transaction(async (tx) => { for (const userId of ids) { try { await tx.insert(Ban).values({ userId, ip: "", machineId: "", userStaffId: context.capability.actor.id, timestamp: now, banExpire: durationSeconds > 0 ? now + durationSeconds : 0, banReason: reason, type: "account", }); completed += 1; } catch { failures.push({ userId, reason: "Database error" }); } } const value: PeopleMutationSnapshot = { before: { userIds: ids }, after: { completed, total: ids.length, failedIds: failures, reason, }, }; await logAudit( canonicalAuditEntry( context, operation, "User", undefined, value, failures.length > 0 ? "partial" : "success", ), tx, ); return value; }); await logStaffActivity({ staffId: context.capability.actor.id, action: "bulk_ban", description: `Banned ${completed} user(s)`, targetType: "user", }); return snapshot; } const preliminary: PeopleMutationSnapshot = { before: { userIds: ids }, after: null, }; await logAudit( canonicalAuditEntry( context, operation, "User", undefined, preliminary, "intent", ), ); if (operation === "users.bulk-currency") { const amount = positiveInteger(data.amount); const type = normalizedText(data.type, 16) as | "credits" | "pixels" | "points"; for (const userId of ids) { try { if (type === "credits") { await db .update(User) .set({ credits: sql`${User.credits} + ${amount}` }) .where(eq(User.id, userId)); } else { const currencyType = type === "pixels" ? 0 : 101; await db .insert(UsersCurrency) .values({ userId, type: currencyType, amount }) .onDuplicateKeyUpdate({ set: { amount: sql`${UsersCurrency.amount} + ${amount}` }, }); } } catch { failures.push({ userId, reason: "Database error" }); continue; } completed += 1; try { const delivered = type === "credits" ? await rcon.giveCredits(userId, amount) : type === "pixels" ? await rcon.giveDuckets(userId, amount) : await rcon.givePointsGotw(userId, amount); if (!context.legacy) await requireRcon(delivered); } catch { externalSyncFailures.push({ userId, reason: externalSyncFailureReason, }); } } await logStaffActivity({ staffId: context.capability.actor.id, action: "bulk_give_currency", description: `Gave ${amount} ${type} to ${completed} user(s)`, targetType: "user", }); } else { const badgeCode = normalizedText(data.badgeCode, 20); for (const userId of ids) { let shouldSync = false; try { const [existing] = await db .select({ id: UsersBadges.id }) .from(UsersBadges) .where( and( eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, badgeCode), ), ) .limit(1); if (!existing) { const [aggregate] = await db .select({ maxSlot: max(UsersBadges.slotId) }) .from(UsersBadges) .where(eq(UsersBadges.userId, userId)); await db.insert(UsersBadges).values({ userId, slotId: (aggregate?.maxSlot ?? 0) + 1, badgeCode, }); shouldSync = true; } completed += 1; } catch { failures.push({ userId, reason: "Database error" }); continue; } if (!shouldSync) continue; try { const delivered = await rcon.giveBadge(userId, badgeCode); if (!context.legacy) await requireRcon(delivered); } catch { externalSyncFailures.push({ userId, reason: externalSyncFailureReason, }); } } await logStaffActivity({ staffId: context.capability.actor.id, action: "bulk_give_badge", description: `Gave badge "${badgeCode}" to ${completed} user(s)`, targetType: "user", }); } const hasPartialCompletion = failures.length > 0 || externalSyncFailures.length > 0; const completion: HousekeepingPartialCompletion | undefined = hasPartialCompletion ? { status: "partial", external: externalSyncFailures.length > 0 ? "failed" : "not-required", audit: "persisted", } : undefined; const snapshot: PeopleMutationSnapshot = { before: { userIds: ids }, after: { completed, total: ids.length, failedIds: failures, ...(externalSyncFailures.length > 0 ? { externalSyncFailures } : {}), ...(mutationReason ? { reason: mutationReason } : {}), }, ...(completion === undefined ? {} : { completion }), }; try { await logAudit( canonicalAuditEntry( context, operation, "User", undefined, snapshot, hasPartialCompletion ? "partial" : "success", ), ); } catch (auditOutcomeError) { throw new AuditOutcomePersistenceError(snapshot, auditOutcomeError); } return snapshot; } async function executeGuildDisband( input: unknown, context: PeopleMutationContext, ): Promise { const guildId = positiveInteger(record(input).guildId); let guildName = ""; let guildOwnerId = 0; const snapshot = await db.transaction(async (tx) => { const [guild] = await tx .select({ id: Guilds.id, name: Guilds.name, userId: Guilds.userId }) .from(Guilds) .where(eq(Guilds.id, guildId)) .limit(1); if (!guild) { throw new PeopleMutationFailure( "NOT_FOUND", "errors.housekeeping.notFound", ); } guildName = guild.name; guildOwnerId = guild.userId; const threads = await tx .select({ id: GuildsForumsThreads.id }) .from(GuildsForumsThreads) .where(eq(GuildsForumsThreads.guildId, guildId)); const threadIds = threads.map((thread) => thread.id); if (threadIds.length > 0) { await tx .delete(GuildsForumsComments) .where(inArray(GuildsForumsComments.threadId, threadIds)); await tx .delete(GuildsForumsThreads) .where(eq(GuildsForumsThreads.guildId, guildId)); } await tx .delete(GuildForumViews) .where(eq(GuildForumViews.guildId, guildId)); await tx.delete(GuildsMembers).where(eq(GuildsMembers.guildId, guildId)); await tx .update(Rooms) .set({ guildId: 0 }) .where(eq(Rooms.guildId, guildId)); await tx .update(Items) .set({ guildId: 0 }) .where(eq(Items.guildId, guildId)); await tx.delete(Guilds).where(eq(Guilds.id, guildId)); const value: PeopleMutationSnapshot = { before: { id: guild.id, name: guild.name, userId: guild.userId }, after: null, }; await logAudit( canonicalAuditEntry( context, "guild.disband", "Guild", guildId, value, "success", ), tx, ); return value; }); await logStaffActivity({ staffId: context.capability.actor.id, action: "guild_disband", description: `Disbanded guild #${guildId} (${guildName}), owner #${guildOwnerId}`, targetType: "guild", targetId: guildId, }); return snapshot; } async function executeApplicationDecision( input: unknown, context: PeopleMutationContext, ): Promise { const data = record(input); const applicationId = positiveBigIntIdentifier(data.applicationId); if (data.decision !== "dismiss") { throw new PeopleMutationFailure( "VALIDATION", "errors.housekeeping.validation", ); } return db.transaction(async (tx) => { const [application] = await tx .select({ id: WebsiteStaffApplications.id, userId: WebsiteStaffApplications.userId, rankId: WebsiteStaffApplications.rankId, content: WebsiteStaffApplications.content, }) .from(WebsiteStaffApplications) .where(eq(WebsiteStaffApplications.id, applicationId)) .limit(1); if (!application) { throw new PeopleMutationFailure( "NOT_FOUND", "errors.housekeeping.notFound", ); } await tx .delete(WebsiteStaffApplications) .where(eq(WebsiteStaffApplications.id, applicationId)); const snapshot = { before: { id: serializeBigIntIdentifier(application.id), userId: application.userId, rankId: application.rankId, content: application.content, }, after: null, }; await logAudit( canonicalAuditEntry( context, "application.decide", "StaffApplication", auditTargetId(applicationId), snapshot, "success", ), tx, ); return snapshot; }); } async function executeTeamChange( input: unknown, context: PeopleMutationContext, ): Promise { const data = record(input); if (data.action === "delete") { const teamId = positiveBigIntIdentifier(data.teamId); return db.transaction(async (tx) => { const [team] = await tx .select({ id: WebsiteTeams.id, rankName: WebsiteTeams.rankName, badge: WebsiteTeams.badge, jobDescription: WebsiteTeams.jobDescription, hiddenRank: WebsiteTeams.hiddenRank, }) .from(WebsiteTeams) .where(eq(WebsiteTeams.id, teamId)) .limit(1); if (!team) { throw new PeopleMutationFailure( "NOT_FOUND", "errors.housekeeping.notFound", ); } await tx.delete(WebsiteTeams).where(eq(WebsiteTeams.id, teamId)); const snapshot: PeopleMutationSnapshot = { before: { ...team, id: serializeBigIntIdentifier(team.id) }, after: null, }; await logAudit( canonicalAuditEntry( context, "team.change", "Team", auditTargetId(teamId), snapshot, "success", ), tx, ); return snapshot; }); } const rankName = normalizedText(data.rankName, 255); const badge = normalizedText(data.badge, 255, false); const jobDescription = normalizedText(data.jobDescription, 255, false); const staffColor = normalizedText(data.staffColor || "#327fa8", 255); const hiddenRank = Boolean(data.hiddenRank); const now = new Date(); return db.transaction(async (tx) => { const [result] = await tx.insert(WebsiteTeams).values({ rankName, badge: badge || null, jobDescription: jobDescription || null, staffColor, hiddenRank, createdAt: now, updatedAt: now, }); const teamId = positiveBigIntIdentifier(result.insertId); const snapshot: PeopleMutationSnapshot = { before: null, after: { teamId: teamId.toString(), rankName, badge: badge || null, jobDescription: jobDescription || null, staffColor, hiddenRank, }, }; await logAudit( canonicalAuditEntry( context, "team.change", "Team", auditTargetId(teamId), snapshot, "success", ), tx, ); return snapshot; }); } async function executeIpAction( input: unknown, context: PeopleMutationContext, ): Promise { const data = record(input); const action = normalizedText(data.action, 32); if ( !( [ "add-blacklist", "delete-blacklist", "add-whitelist", "delete-whitelist", ] as const ).includes(action as never) ) { throw new PeopleMutationFailure( "VALIDATION", "errors.housekeeping.validation", ); } const blacklist = action.endsWith("blacklist"); const adding = action.startsWith("add-"); if (adding) { const ipAddress = normalizedText(data.ipAddress, 255); const asn = normalizedText(data.asn, 255, false) || null; return db.transaction(async (tx) => { const [result] = blacklist ? await tx .insert(WebsiteIpBlacklist) .values({ ipAddress, asn, blacklistAsn: asn !== null }) : await tx .insert(WebsiteIpWhitelist) .values({ ipAddress, asn, whitelistAsn: asn !== null }); const id = positiveBigIntIdentifier(result.insertId); const snapshot: PeopleMutationSnapshot = { before: null, after: { id: id.toString(), action, ipAddress, asn }, }; await logAudit( canonicalAuditEntry( context, "ip.action", "IpRule", auditTargetId(id), snapshot, "success", ), tx, ); return snapshot; }); } const id = positiveBigIntIdentifier(data.id); return db.transaction(async (tx) => { const [existing] = blacklist ? await tx .select({ id: WebsiteIpBlacklist.id, ipAddress: WebsiteIpBlacklist.ipAddress, asn: WebsiteIpBlacklist.asn, }) .from(WebsiteIpBlacklist) .where(eq(WebsiteIpBlacklist.id, id)) .limit(1) : await tx .select({ id: WebsiteIpWhitelist.id, ipAddress: WebsiteIpWhitelist.ipAddress, asn: WebsiteIpWhitelist.asn, }) .from(WebsiteIpWhitelist) .where(eq(WebsiteIpWhitelist.id, id)) .limit(1); if (blacklist) { await tx.delete(WebsiteIpBlacklist).where(eq(WebsiteIpBlacklist.id, id)); } else { await tx.delete(WebsiteIpWhitelist).where(eq(WebsiteIpWhitelist.id, id)); } const snapshot: PeopleMutationSnapshot = { before: existing ? { id: serializeBigIntIdentifier(existing.id), action, ipAddress: existing.ipAddress, asn: existing.asn, } : null, after: null, }; await logAudit( canonicalAuditEntry( context, "ip.action", "IpRule", auditTargetId(id), snapshot, "success", ), tx, ); return snapshot; }); } async function executeVpnConfiguration( input: unknown, context: PeopleMutationContext, ): Promise { const data = record(input); const enabled = Boolean(data.enabled); const provider = normalizedText(data.provider, 32); const apiKey = normalizedText(data.apiKey, 255, false); const blockMessage = normalizedText(data.blockMessage, 255, false); const [storedEnabled, storedProvider, storedApiKey, storedBlockMessage] = await Promise.all([ siteSettings.get("vpn_block_enabled"), siteSettings.get("vpn_provider"), siteSettings.get("vpn_api_key"), siteSettings.get("vpn_block_message"), ]); const snapshot: PeopleMutationSnapshot = { before: { enabled: storedEnabled === "1", provider: storedProvider ?? "none", apiKeyConfigured: Boolean(storedApiKey), blockMessage: storedBlockMessage ?? "", }, after: { enabled, provider, apiKeyConfigured: apiKey.length > 0, blockMessage, }, }; await db.transaction(async (tx) => { for (const [key, value, comment] of [ [ "vpn_block_enabled", enabled ? "1" : "0", "Block registrations from detected VPN/proxy IPs (0=no, 1=yes)", ], [ "vpn_provider", provider, "VPN/proxy detection provider (none/proxycheck/ipqualityscore)", ], ["vpn_api_key", apiKey, "API key for the VPN/proxy detection provider"], [ "vpn_block_message", blockMessage, "Message shown to users blocked for using a VPN/proxy", ], ] as const) { await tx .insert(WebsiteSetting) .values({ key, value, comment }) .onDuplicateKeyUpdate({ set: { value } }); } await logAudit( canonicalAuditEntry( context, "vpn.configure", "VpnConfiguration", undefined, snapshot, "intent", ), tx, ); }); return finalizeExternalWithAudit( context, "vpn.configure", "VpnConfiguration", undefined, snapshot, async () => { await siteSettings.reload(); await logStaffActivity({ staffId: context.capability.actor.id, action: "vpn_update", description: `Updated VPN/proxy detection (block=${enabled ? "on" : "off"}, provider=${provider})`, }); }, ); } async function executeWordFilterUpdate( input: unknown, context: PeopleMutationContext, ): Promise { const data = record(input); if (data.action === "add") { const word = normalizedText(data.word, 255); let id = 0n; let snapshot!: PeopleMutationSnapshot; await db.transaction(async (tx) => { const [result] = (await tx .insert(WebsiteWordfilter) .values({ word })) as unknown as [ResultSetHeader]; id = positiveBigIntIdentifier(result.insertId); snapshot = { before: null, after: { id: id.toString(), word } }; await logAudit( canonicalAuditEntry( context, "word-filter.update", "WordFilter", auditTargetId(id), snapshot, "intent", ), tx, ); }); return finalizeExternalWithAudit( context, "word-filter.update", "WordFilter", auditTargetId(id), snapshot, async () => { reloadWordFilter(); await requireRcon(await rcon.updateWordFilter()); }, ); } if (data.action !== "delete") { throw new PeopleMutationFailure( "VALIDATION", "errors.housekeeping.validation", ); } const id = positiveBigIntIdentifier(data.id); let snapshot!: PeopleMutationSnapshot; await db.transaction(async (tx) => { const [existing] = await tx .select({ id: WebsiteWordfilter.id, word: WebsiteWordfilter.word }) .from(WebsiteWordfilter) .where(eq(WebsiteWordfilter.id, id)) .limit(1); await tx.delete(WebsiteWordfilter).where(eq(WebsiteWordfilter.id, id)); snapshot = { before: existing ? { id: serializeBigIntIdentifier(existing.id), word: existing.word } : null, after: null, }; await logAudit( canonicalAuditEntry( context, "word-filter.update", "WordFilter", auditTargetId(id), snapshot, "intent", ), tx, ); }); return finalizeExternalWithAudit( context, "word-filter.update", "WordFilter", auditTargetId(id), snapshot, async () => { reloadWordFilter(); await requireRcon(await rcon.updateWordFilter()); }, ); } const supportMutationExecutor = createPeopleSupportMutationExecutor({ db, writeAudit: logAudit, auditEntry: canonicalAuditEntry, failure: (code, messageKey) => new PeopleMutationFailure(code, messageKey), record, positiveInteger, nonNegativeInteger, normalizedText, canonicalTicketId, auditTargetId, }); const moderationMutationExecutor = createPeopleModerationMutationExecutor({ db, writeAudit: logAudit, auditEntry: canonicalAuditEntry, failure: (code, messageKey) => new PeopleMutationFailure(code, messageKey), record, positiveInteger, nonNegativeInteger, normalizedText, requireRcon, loadTarget, runWithLockedTargetAuthority, transport: rcon, executeModerationAction, logStaffActivity, runExternalWithAudit, finalizeExternalWithAudit, }); const productionPeopleMutationAdapter: PeopleMutationAdapter = { async execute(operation, input, context) { if (operation.startsWith("user.")) { return executeUserMutation( operation as Extract, input, context, ); } if (operation.startsWith("users.")) { return executeBulkMutation( operation as Extract, input, context, ); } if (operation === "guild.disband") return executeGuildDisband(input, context); if (operation === "application.decide") { return executeApplicationDecision(input, context); } if (operation === "team.change") return executeTeamChange(input, context); if (operation === "ip.action") return executeIpAction(input, context); if (operation === "vpn.configure") { return executeVpnConfiguration(input, context); } if (operation === "word-filter.update") { return executeWordFilterUpdate(input, context); } if (isPeopleSupportMutationOperation(operation)) { return supportMutationExecutor.execute(operation, input, context); } if (isPeopleModerationMutationOperation(operation)) { return moderationMutationExecutor.execute(operation, input, context); } throw new PeopleMutationFailure( "VALIDATION", "errors.housekeeping.validation", ); }, }; export const peopleMutationService = createPeopleMutationService( productionPeopleMutationAdapter, getHousekeepingCapabilityContext, );