Files
Epicnabbo-Catalogus-Updated…/src/features/housekeeping/domains/people/services/mutations.ts
T

1792 lines
43 KiB
TypeScript

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,
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 { reloadWordFilter } from "@/lib/services/moderation";
import { rcon } from "@/lib/services/rcon";
import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity";
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";
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";
export interface PeopleMutationSnapshot {
readonly before: Readonly<Record<string, unknown>> | null;
readonly after: Readonly<Record<string, unknown>> | null;
readonly output?: Readonly<Record<string, unknown>>;
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<PeopleMutationSnapshot>;
}
export interface PeopleMutationService {
execute(
invocation: PeopleMutationInvocation,
operation: PeopleMutationOperation,
input: unknown,
): Promise<HousekeepingResult<PeopleMutationSnapshot>>;
}
export type ResolvePeopleCapabilityContext =
() => Promise<HousekeepingCapabilityContext>;
class PeopleMutationFailure extends Error {
constructor(
readonly code: HousekeepingErrorCode,
readonly messageKey: string,
readonly fieldErrors?: Readonly<Record<string, readonly string[]>>,
) {
super(messageKey);
this.name = "PeopleMutationFailure";
}
}
class ConfirmedExternalNoopFailure extends PeopleMutationFailure {}
function operationCapability(operation: PeopleMutationOperation) {
if (operation === "user.ban" || operation === "user.unban") {
return anyCapability(PERMS.USERS_BAN);
}
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<string, unknown> {
if (typeof input !== "object" || input === null || Array.isArray(input)) {
throw new PeopleMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
return input as Record<string, unknown>;
}
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<void> {
if (!result) {
throw new ConfirmedExternalNoopFailure(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
);
}
}
type TargetUser = {
id: number;
username: string;
rank: number;
motto: string;
credits: number;
pixels: number;
online: string;
};
async function loadTarget(
userId: number,
context: PeopleMutationContext,
guardHierarchy: boolean,
): Promise<TargetUser> {
const [target] = await db
.select({
id: User.id,
username: User.username,
rank: User.rank,
motto: User.motto,
credits: User.credits,
pixels: User.pixels,
online: User.online,
})
.from(User)
.where(eq(User.id, userId))
.limit(1);
if (!target) {
throw new PeopleMutationFailure(
"NOT_FOUND",
"errors.housekeeping.notFound",
);
}
if (
guardHierarchy &&
target.rank >= context.capability.actor.rank &&
context.capability.actor.rank < 7
) {
throw new PeopleMutationFailure(
"FORBIDDEN",
"errors.housekeeping.forbidden",
);
}
return target;
}
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<void>,
mutationCommitted = true,
confirmedFailureSnapshot?: PeopleMutationSnapshot,
): Promise<PeopleMutationSnapshot> {
try {
await execute();
} catch (error) {
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<void>,
confirmedFailureSnapshot: PeopleMutationSnapshot,
): Promise<PeopleMutationSnapshot> {
await logAudit(
canonicalAuditEntry(
context,
operation,
target,
targetId,
snapshot,
"intent",
),
);
return finalizeExternalWithAudit(
context,
operation,
target,
targetId,
snapshot,
execute,
false,
confirmedFailureSnapshot,
);
}
async function executeUserMutation(
operation: Extract<PeopleMutationOperation, `user.${string}`>,
input: unknown,
context: PeopleMutationContext,
): Promise<PeopleMutationSnapshot> {
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 },
};
return runExternalWithAudit(
context,
operation,
"User",
userId,
snapshot,
async () => requireRcon(await rcon.alertUser(userId, message)),
{ before: null, after: { userId, alertDelivered: false } },
);
}
const guardHierarchy = operation !== "user.trade-lock";
const target = await loadTarget(userId, context, guardHierarchy);
const before = targetSnapshot(target);
if (operation === "user.update") {
const fields = record(data.fields);
const nextRank = fields.rank;
if (
nextRank !== undefined &&
positiveInteger(nextRank) >= context.capability.actor.rank &&
context.capability.actor.rank < 7
) {
throw new PeopleMutationFailure(
"FORBIDDEN",
"errors.housekeeping.forbidden",
);
}
const userPatch = Object.fromEntries(
["username", "mail", "rank", "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 snapshot: PeopleMutationSnapshot = {
before,
after: { ...before, ...safeFields },
};
await db.transaction(async (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,
);
});
return finalizeExternalWithAudit(
context,
operation,
"User",
userId,
snapshot,
async () => {
invalidateLoginCache(target.username);
void notify({
action: "user_edit",
actor: context.capability.actor.username,
target: target.username,
targetId: userId,
});
},
);
}
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) => {
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,
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));
}
await 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<PeopleMutationOperation, `users.${string}`>,
input: unknown,
context: PeopleMutationContext,
): Promise<PeopleMutationSnapshot> {
const data = record(input);
const ids = userIds(data.userIds, context.legacy);
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<PeopleMutationSnapshot> {
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<PeopleMutationSnapshot> {
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<PeopleMutationSnapshot> {
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<PeopleMutationSnapshot> {
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<PeopleMutationSnapshot> {
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<PeopleMutationSnapshot> {
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 productionPeopleMutationAdapter: PeopleMutationAdapter = {
async execute(operation, input, context) {
if (operation.startsWith("user.")) {
return executeUserMutation(
operation as Extract<PeopleMutationOperation, `user.${string}`>,
input,
context,
);
}
if (operation.startsWith("users.")) {
return executeBulkMutation(
operation as Extract<PeopleMutationOperation, `users.${string}`>,
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);
}
return executeWordFilterUpdate(input, context);
},
};
export const peopleMutationService = createPeopleMutationService(
productionPeopleMutationAdapter,
getHousekeepingCapabilityContext,
);