2107 lines
51 KiB
TypeScript
2107 lines
51 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,
|
|
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<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 {}
|
|
|
|
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<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;
|
|
ipCurrent: string;
|
|
machineId: string;
|
|
motto: string;
|
|
credits: number;
|
|
pixels: number;
|
|
online: string;
|
|
};
|
|
|
|
async function loadTarget(
|
|
userId: number,
|
|
context: PeopleMutationContext,
|
|
guardHierarchy: boolean,
|
|
source: Pick<Db, "select"> = db,
|
|
lock = false,
|
|
): Promise<TargetUser> {
|
|
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<void>,
|
|
afterRelease?: () => Promise<void>,
|
|
): Promise<void> {
|
|
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<void> {
|
|
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<void>,
|
|
mutationCommitted = true,
|
|
confirmedFailureSnapshot?: PeopleMutationSnapshot,
|
|
): Promise<PeopleMutationSnapshot> {
|
|
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<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 },
|
|
};
|
|
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<typeof targetSnapshot>;
|
|
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<string, unknown> = { ...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<PeopleMutationOperation, `users.${string}`>,
|
|
input: unknown,
|
|
context: PeopleMutationContext,
|
|
): Promise<PeopleMutationSnapshot> {
|
|
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<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 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<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);
|
|
}
|
|
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,
|
|
);
|