fix(housekeeping): unify moderation target authority
This commit is contained in:
1 parent
9b91880e33
commit
8a894617e7
12 files changed
+873
-236
No files matched your search
@@ -8,6 +8,7 @@ import { invalidateLoginCache } from "@/lib/auth";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import {
|
||||
Ban,
|
||||
type Db,
|
||||
db,
|
||||
GuildForumViews,
|
||||
Guilds,
|
||||
@@ -150,6 +151,13 @@ class PeopleMutationFailure extends Error {
|
||||
|
||||
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" ||
|
||||
@@ -374,6 +382,8 @@ type TargetUser = {
|
||||
id: number;
|
||||
username: string;
|
||||
rank: number;
|
||||
ipCurrent: string;
|
||||
machineId: string;
|
||||
motto: string;
|
||||
credits: number;
|
||||
pixels: number;
|
||||
@@ -384,20 +394,24 @@ async function loadTarget(
|
||||
userId: number,
|
||||
context: PeopleMutationContext,
|
||||
guardHierarchy: boolean,
|
||||
source: Pick<Db, "select"> = db,
|
||||
lock = false,
|
||||
): Promise<TargetUser> {
|
||||
const [target] = await db
|
||||
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))
|
||||
.limit(1);
|
||||
.where(eq(User.id, userId));
|
||||
const [target] = lock ? await query.for("update") : await query.limit(1);
|
||||
if (!target) {
|
||||
throw new PeopleMutationFailure(
|
||||
"NOT_FOUND",
|
||||
@@ -417,6 +431,24 @@ async function loadTarget(
|
||||
return target;
|
||||
}
|
||||
|
||||
async function runWithLockedTargetAuthority(
|
||||
userId: number,
|
||||
context: PeopleMutationContext,
|
||||
execute: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
let externalCompleted = false;
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
await loadTarget(userId, context, true, tx, true);
|
||||
await execute();
|
||||
externalCompleted = true;
|
||||
});
|
||||
} catch (error) {
|
||||
if (externalCompleted) throw new CompletedExternalEffectFailure(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertBulkTargetHierarchy(
|
||||
userIds: readonly number[],
|
||||
context: PeopleMutationContext,
|
||||
@@ -489,6 +521,28 @@ async function finalizeExternalWithAudit(
|
||||
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
|
||||
@@ -607,20 +661,41 @@ async function executeUserMutation(
|
||||
before: null,
|
||||
after: { userId, alertDelivered: true },
|
||||
};
|
||||
return runExternalWithAudit(
|
||||
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,
|
||||
async () => requireRcon(await rcon.alertUser(userId, message)),
|
||||
() =>
|
||||
runWithLockedTargetAuthority(userId, context, async () => {
|
||||
await requireRcon(await rcon.alertUser(userId, message));
|
||||
}),
|
||||
false,
|
||||
{ before: null, after: { userId, alertDelivered: false } },
|
||||
);
|
||||
}
|
||||
|
||||
const guardHierarchy = operation !== "user.trade-lock";
|
||||
const target = await loadTarget(userId, context, guardHierarchy);
|
||||
const before = targetSnapshot(target);
|
||||
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);
|
||||
@@ -1093,6 +1168,8 @@ async function executeUserMutation(
|
||||
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,
|
||||
@@ -1169,29 +1246,30 @@ async function executeUserMutation(
|
||||
"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,
|
||||
});
|
||||
},
|
||||
() =>
|
||||
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));
|
||||
}
|
||||
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(
|
||||
@@ -1956,6 +2034,8 @@ const moderationMutationExecutor = createPeopleModerationMutationExecutor({
|
||||
nonNegativeInteger,
|
||||
normalizedText,
|
||||
requireRcon,
|
||||
loadTarget,
|
||||
runWithLockedTargetAuthority,
|
||||
transport: rcon,
|
||||
executeModerationAction,
|
||||
logStaffActivity,
|
||||
|
||||
Reference in new issue
Block a user