Add player moderation workflows and game integration
CI / checks (push) Successful in 7m27s

This commit is contained in:
2026-10-15 21:33:50 -04:00
parent c91d49c298
commit 8b372fa2c1
50 changed files with 4744 additions and 354 deletions
+1
View File
@@ -41,6 +41,7 @@ export function routePermission(routeId: string | null, method: string): RoutePe
if (within(routeId, '/api/agent')) return 'machine';
if (routeId === '/api/bank/[action]' && method === 'POST') return 'machine';
if (routeId === '/api/telemetry' && method === 'POST') return 'machine';
if (routeId === '/api/moderation/runtime' && method === 'POST') return 'machine';
if (routeId === '/api/leaderboard' && reading) return 'public';
if ((routeId === '/login' || routeId === '/setup') && reading) return 'public';
if ((routeId === '/api/auth/login' || routeId === '/api/auth/setup') && method === 'POST')
+1
View File
@@ -16,6 +16,7 @@ function num(name: string, fallback: number): number {
}
export const config = {
publicUrl: env.EC_PUBLIC_URL ?? '',
serversRoot: req('SERVERS_ROOT', '/root/zombies/t5'),
t6ServersRoot:
env.T6_SERVERS_ROOT ?? join(dirname(req('SERVERS_ROOT', '/root/zombies/t5')), 't6'),
+18 -1
View File
@@ -143,7 +143,24 @@ export const migrations: readonly Migration[] = [
mode: 'foreign-keys-off',
run: migrateModeratorRole
},
{ version: 26, name: 'player moderation records and enforcement', run: migrateModeration }
{ version: 26, name: 'player moderation records and enforcement', run: migrateModeration },
{
version: 27,
name: 'moderation runtime sessions and evidence lookup',
run: (handle) => {
handle.exec(
'CREATE INDEX IF NOT EXISTS moderation_evidence_case ON moderation_evidence(case_id)'
);
if (
!(handle.pragma('table_info(moderation_runtimes)') as { name: string }[]).some(
(c) => c.name === 'session_sequence'
)
)
handle.exec(
'ALTER TABLE moderation_runtimes ADD COLUMN session_sequence INTEGER NOT NULL DEFAULT 0'
);
}
}
];
export const databaseVersion = migrations.length;
+21 -9
View File
@@ -2,11 +2,18 @@ import { posix } from 'node:path';
import { gameOfServer, relDestForKind, type ArtifactKind } from '../config.js';
export const BANK_CONFIG_FILE = 'electric_cherry_bank.cfg';
export const MODERATION_CONFIG_FILE = 'electric_cherry_moderation.cfg';
export const PRIVATE_CONFIG_FILES = [BANK_CONFIG_FILE, MODERATION_CONFIG_FILE] as const;
export function isPrivateConfigPath(path: string, game = 't5'): boolean {
return (
posix.normalize(path.replaceAll('\\', '/')) ===
posix.join(relDestForKind('config', game), BANK_CONFIG_FILE)
// Moderation bootstrap has one fixed runtime location, even when shared
// artifact destinations were customized for an older installation.
if (posix.normalize(path.replaceAll('\\', '/')) === `configs/${MODERATION_CONFIG_FILE}`)
return true;
return PRIVATE_CONFIG_FILES.some(
(filename) =>
posix.normalize(path.replaceAll('\\', '/')) ===
posix.join(relDestForKind('config', game), filename)
);
}
@@ -23,26 +30,31 @@ export function isPrivateConfigTarget(server: string, kind: string, path: string
export function assertPublicFileTarget(server: string, kind: ArtifactKind, path: string): void {
if (isPrivateConfigTarget(server, kind, path))
throw new Error(
'Private bank configuration cannot be captured or changed by a file campaign.'
'Private bank configuration and moderation configuration cannot be captured or changed by a file campaign.'
);
}
export function isReservedConfigFilename(path: string): boolean {
return posix.basename(path.replaceAll('\\', '/')).toLowerCase() === BANK_CONFIG_FILE;
return PRIVATE_CONFIG_FILES.some(
(filename) => posix.basename(path.replaceAll('\\', '/')).toLowerCase() === filename
);
}
// Legacy campaign evidence omits the kind and stores the path relative to its destination.
export function isPrivateConfigEvidencePath(path: string): boolean {
return (
posix.normalize(path.replaceAll('\\', '/')) === BANK_CONFIG_FILE ||
isPrivateConfigPath(path)
PRIVATE_CONFIG_FILES.some(
(filename) => posix.normalize(path.replaceAll('\\', '/')) === filename
) || isPrivateConfigPath(path)
);
}
/** Credential injection uses bank.configPath, never a shared file's path field. */
/** Credential injection uses a private configPath, never a shared file's path field. */
export class PrivateFileOperationError extends Error {
constructor() {
super('Private bank configuration cannot be read or changed by file operations.');
super(
'Private bank configuration and moderation configuration cannot be read or changed by file operations.'
);
}
}
+1 -1
View File
@@ -253,7 +253,7 @@ export function createScript(input: CreateScriptInput): {
if (!targetRelPath) throw new ScriptError('target rel path is required', 'path-empty');
if (isReservedConfigFilename(targetRelPath))
throw new ScriptError(
'electric_cherry_bank.cfg is reserved for instance credentials.',
`${targetRelPath} is reserved for instance credentials.`,
'path-reserved'
);
+27 -4
View File
@@ -1,6 +1,6 @@
import {
assertPublicFilePayload,
BANK_CONFIG_FILE,
PRIVATE_CONFIG_FILES,
PrivateFileOperationError,
isPrivateConfigPath
} from '../fleet/private-config.js';
@@ -1454,7 +1454,7 @@ export function getHostOperationPayload(hostId: string, id: string): Buffer {
.get(id, hostId) as { payload: Buffer } | undefined;
if (!row) throw new Error('Host operation was not found for this host.');
// Block old private-file commands before delivery to agents predating the guard.
if (row.payload.includes(BANK_CONFIG_FILE))
if (PRIVATE_CONFIG_FILES.some((filename) => row.payload.includes(filename)))
assertPublicFilePayload(JSON.parse(row.payload.toString()));
if (
!row.payload.includes('"contentSha256"') ||
@@ -1485,10 +1485,15 @@ export function reportHostOperationStatus(
if (['completed', 'failed'].includes(operation.state)) return operation;
const state = input.state as HostOperationState;
if (operation.state === 'running' && state === 'accepted') return operation;
const error =
let error =
input.error === undefined || input.error === null
? null
: String(input.error).slice(0, 4000);
if (operation.kind === 'instance.configure-moderation' && error)
error = error.replace(
/ec_(?:moderation|bank)_[A-Za-z0-9_-]+/g,
'[redacted credential]'
);
let result = input.result === undefined ? null : JSON.stringify(input.result);
if (result !== null && Buffer.byteLength(result) > 128 * 1024 * 1024)
throw new Error('Operation result exceeds the limit.');
@@ -1504,7 +1509,9 @@ export function reportHostOperationStatus(
? { redacted: true }
: operation.kind === 'logs.read'
? logResultMetadata(input.result)
: input.result
: operation.kind === 'instance.configure-moderation'
? moderationConfigurationReceipt(input.result)
: input.result
);
result = JSON.stringify(packed.value);
referenceBlobs('operation-result', id, packed.hashes);
@@ -1568,6 +1575,22 @@ export function reportHostOperationStatus(
queueMicrotask(() => workEvents.emit(`operation:${id}`));
return operation;
}
/** Configuration replies contain status only, never echoed dvars, file bodies or credentials. */
function moderationConfigurationReceipt(value: unknown): Record<string, unknown> {
const result =
value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
return {
configured: result.configured === true,
...(Number.isSafeInteger(result.generation) ? { generation: result.generation } : {}),
liveLoad: ['sent', 'unconfirmed', 'deferred'].includes(result.liveLoad as string)
? result.liveLoad
: 'unconfirmed',
...(result.reconciled === true ? { reconciled: true } : {})
};
}
export function reportHostOperation(
hostId: string,
id: string,
+396
View File
@@ -0,0 +1,396 @@
import { randomUUID } from 'node:crypto';
import { db } from '../db/index.js';
import { AuthError } from '../auth/index.js';
import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js';
import {
canRevokeModerationRestriction,
moderationGuid,
type ModerationGame,
type ModerationScope
} from '../../moderation.js';
import {
advancePolicy,
ensureSubject,
moderationEvent,
moderationMutation,
subject,
textField
} from './core.js';
export const runtimeFreshMs = 15_000;
function gameText(value: unknown, label: string): string {
const text = textField(value, label, 240);
if (Buffer.byteLength(text, 'utf8') > 240)
throw new AuthError(`${label} must fit within 240 UTF-8 bytes for the game server.`);
return text;
}
type SanctionRow = {
id: string;
game: ModerationGame;
guid: string;
actor_id: string;
scope: ModerationScope;
created_at: string;
expires_at: string | null;
revision: number;
revoked_at: string | null;
};
type CaseRow = { id: string; game: ModerationGame; guid: string; revision: number; status: string };
function serverTarget(name: unknown, game?: ModerationGame) {
const server = textField(name, 'Server', 255);
const row = db()
.prepare(
`SELECT s.name,s.game,s.created_at,n.id AS instance_id,n.generation
FROM servers s LEFT JOIN native_instances n ON n.server=s.name AND n.retired_at IS NULL
WHERE s.name=? AND s.destroyed_at IS NULL`
)
.get(server) as
| {
name: string;
game: ModerationGame;
created_at: string;
instance_id: string | null;
generation: number | null;
}
| undefined;
if (!row || (game && row.game !== game))
throw new AuthError('Choose an existing server for this game.', 404);
return row;
}
function createSanction(actor: AuthUser, input: Record<string, unknown>, now: string) {
const target = subject(input);
if (input.kind !== 'ban' && input.kind !== 'mute') throw new AuthError('Choose ban or mute.');
if (typeof input.scope !== 'string' || !['server', 'game', 'fleet'].includes(input.scope))
throw new AuthError('Choose a restriction scope.');
const duration = input.durationMinutes;
if (
duration !== null &&
(!Number.isSafeInteger(duration) ||
(duration as number) < 1 ||
(duration as number) > 5256000)
)
throw new AuthError('Choose a duration between 1 minute and 10 years, or permanent.');
if (
actor.role !== 'admin' &&
(input.scope === 'fleet' ||
duration === null ||
(duration as number) * 60_000 > moderatorMaximumRestrictionMs)
)
throw new AuthError(
'Moderators can apply server or game restrictions lasting up to 7 days.',
403
);
// Fleet identity is only shared where both games have reported the same numeric account ID.
if (
input.scope === 'fleet' &&
(!/^\d+$/.test(target.guid) ||
(
db()
.prepare(
'SELECT COUNT(DISTINCT game) AS count FROM moderation_aliases WHERE guid=?'
)
.get(target.guid) as { count: number }
).count !== 2)
)
throw new AuthError(
'Fleet restrictions require this account ID to have been observed in both games. Use game scope until its identity is established.'
);
const reason = gameText(input.reason, 'Player-visible reason');
const note = textField(input.note, 'Private note', 4000, true);
const server = input.scope === 'server' ? serverTarget(input.server, target.game) : null;
const activeCount = (
db()
.prepare(
'SELECT COUNT(*) AS count FROM moderation_sanctions WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)'
)
.get(now) as { count: number }
).count;
if (activeCount >= 1000)
throw new AuthError('The limit of 1,000 active restrictions has been reached.', 409);
const id = randomUUID();
const expires =
duration === null
? null
: new Date(Date.parse(now) + (duration as number) * 60_000).toISOString();
ensureSubject(target.game, target.guid);
db()
.prepare(
`INSERT INTO moderation_sanctions(id,game,guid,kind,scope,server,server_created_at,reason,note,actor_id,actor,created_at,expires_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`
)
.run(
id,
target.game,
target.guid,
input.kind,
input.scope,
server?.name ?? null,
server?.created_at ?? null,
reason,
note,
actor.id,
actor.username,
now,
expires
);
advancePolicy();
moderationEvent(
actor,
'restriction.created',
target,
id,
JSON.stringify({
kind: input.kind,
scope: input.scope,
server: server?.name ?? null,
reason,
expiresAt: expires,
note
}),
now
);
return { id };
}
function revokeSanction(actor: AuthUser, input: Record<string, unknown>, now: string) {
const id = textField(input.id, 'Restriction ID', 128);
const row = db().prepare('SELECT * FROM moderation_sanctions WHERE id=?').get(id) as
SanctionRow | undefined;
if (!row) throw new AuthError('Restriction not found.', 404);
if (actor.role !== 'admin' && row.actor_id !== actor.id)
throw new AuthError(
'Only an administrator can revoke another moderator’s restriction.',
403
);
if (
!canRevokeModerationRestriction(actor, {
actorId: row.actor_id,
scope: row.scope,
createdAt: row.created_at,
expiresAt: row.expires_at
})
)
throw new AuthError(
'Moderators can only revoke their own server or game restrictions originally lasting up to 7 days.',
403
);
if (row.revoked_at || row.revision !== input.revision)
throw new AuthError('This restriction changed. Refresh before trying again.', 409);
const reason = textField(input.reason, 'Revocation reason', 1000);
db()
.prepare('UPDATE moderation_sanctions SET revoked_at=?,revision=revision+1 WHERE id=?')
.run(now, id);
advancePolicy();
moderationEvent(actor, 'restriction.revoked', row, id, reason, now);
return { id };
}
function liveAction(actor: AuthUser, input: Record<string, unknown>, now: string) {
if (
typeof input.kind !== 'string' ||
!['warn', 'message', 'kick', 'announce'].includes(input.kind)
)
throw new AuthError('Choose a supported live action.');
const target = input.kind === 'announce' ? null : subject(input);
const server = serverTarget(input.server, target?.game);
const runtime = db()
.prepare('SELECT * FROM moderation_runtimes WHERE instance_id=? AND generation=?')
.get(server.instance_id, server.generation) as
| {
session: string;
players: string;
capabilities: string;
connected_at: string;
}
| undefined;
if (!runtime || Date.parse(now) - Date.parse(runtime.connected_at) > runtimeFreshMs)
throw new AuthError(
'The moderation integration is not connected to this server. Wait for a fresh connection.',
409
);
if (!(JSON.parse(runtime.capabilities) as string[]).includes(String(input.kind)))
throw new AuthError('This server does not support that action.', 409);
const matches = (JSON.parse(runtime.players) as { guid: string; connection: string }[]).filter(
(p) => p.guid === target?.guid
);
if (target && matches.length !== 1)
throw new AuthError(
'This player’s current connection could not be identified. Refresh the roster.',
409
);
const recent = (
db()
.prepare(
'SELECT COUNT(*) AS count FROM moderation_actions WHERE actor_id=? AND created_at>?'
)
.get(actor.id, new Date(Date.parse(now) - 60_000).toISOString()) as { count: number }
).count;
if (recent >= 30)
throw new AuthError('Too many moderation actions. Wait a minute before continuing.', 429);
const reason = gameText(input.reason, input.kind === 'kick' ? 'Kick reason' : 'Message');
const id = randomUUID();
db()
.prepare(
`INSERT INTO moderation_actions(id,game,guid,server,instance_id,generation,session,connection,kind,reason,actor_id,actor,created_at,expires_at,state)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'queued')`
)
.run(
id,
server.game,
target?.guid ?? null,
server.name,
server.instance_id,
server.generation,
runtime.session,
target ? matches[0].connection : null,
input.kind,
reason,
actor.id,
actor.username,
now,
new Date(Date.parse(now) + 30_000).toISOString()
);
moderationEvent(
actor,
'action.queued',
{ game: server.game, guid: target?.guid },
id,
JSON.stringify({ kind: input.kind, server: server.name, reason }),
now
);
return { id };
}
function caseRow(value: unknown): CaseRow {
const row = db()
.prepare('SELECT * FROM moderation_cases WHERE id=?')
.get(textField(value, 'Case ID', 128)) as CaseRow | undefined;
if (!row) throw new AuthError('Case not found.', 404);
return row;
}
function evidence(
actor: AuthUser,
id: string,
text: string,
source: string,
snapshot: string | null,
now: string
) {
if (
(
db()
.prepare('SELECT COUNT(*) AS count FROM moderation_evidence WHERE case_id=?')
.get(id) as { count: number }
).count >= 100
)
throw new AuthError('This case already contains 100 evidence entries.', 409);
db()
.prepare('INSERT INTO moderation_evidence VALUES(?,?,?,?,?,?,?,?)')
.run(randomUUID(), id, text, source, snapshot, actor.id, actor.username, now);
}
function createCase(actor: AuthUser, input: Record<string, unknown>, now: string) {
const target = subject(input);
const title = textField(input.title, 'Case title', 160);
const note = textField(input.note, 'Case note', 4000, true);
const id = randomUUID();
ensureSubject(target.game, target.guid);
db()
.prepare(
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at) VALUES(?,?,?,?,'open',?,?,?,?)`
)
.run(id, target.game, target.guid, title, actor.id, actor.username, now, now);
if (note) evidence(actor, id, note, 'Staff note', null, now);
if (input.evidence !== undefined) {
const requested = input.evidence as { matchId: unknown; message: unknown };
if (!requested || !Number.isSafeInteger(requested.matchId))
throw new AuthError('Choose a recorded chat message.');
const message = textField(requested.message, 'Chat message', 4096);
const match = db()
.prepare('SELECT game,server,started_at FROM leaderboard_matches WHERE id=?')
.get(requested.matchId) as
{ game: string; server: string; started_at: string } | undefined;
if (!match || match.game !== target.game)
throw new AuthError('The source match is unavailable.', 404);
const entries = db()
.prepare('SELECT entry FROM match_chat WHERE match_id=?')
.all(requested.matchId) as { entry: string }[];
const found = entries
.filter((row) => {
const entry = JSON.parse(row.entry);
return (
entry.type === 'message' &&
moderationGuid(target.game, entry.message.guid) === target.guid &&
entry.message.message === message
);
})
.slice(0, 10);
if (!found.length)
throw new AuthError(
'The original chat message could not be verified in the archive.',
409
);
for (const row of found)
evidence(
actor,
id,
message,
`Archived chat · ${match.server} · match ${requested.matchId}`,
JSON.stringify({ matchId: requested.matchId, match, entry: JSON.parse(row.entry) }),
now
);
}
moderationEvent(actor, 'case.opened', target, id, title, now);
return { id };
}
export function mutateModeration(actorId: string, input: Record<string, unknown>) {
return moderationMutation(actorId, input, (actor, now) => {
if (input.op === 'create-sanction') return createSanction(actor, input, now);
if (input.op === 'revoke-sanction') return revokeSanction(actor, input, now);
if (input.op === 'action') return liveAction(actor, input, now);
if (input.op === 'create-case') return createCase(actor, input, now);
if (input.op === 'case-note') {
const row = caseRow(input.id);
const text = textField(input.text, 'Note', 4000);
evidence(actor, row.id, text, 'Staff note', null, now);
db()
.prepare('UPDATE moderation_cases SET updated_at=?,revision=revision+1 WHERE id=?')
.run(now, row.id);
moderationEvent(actor, 'case.note', row, row.id, text, now);
return { id: row.id };
}
if (input.op === 'resolve-case') {
const row = caseRow(input.id);
if (row.revision !== input.revision || row.status !== 'open')
throw new AuthError('This case changed. Refresh before resolving it.', 409);
if (input.status !== 'resolved' && input.status !== 'dismissed')
throw new AuthError('Choose resolved or dismissed.');
const reason = textField(input.reason, 'Decision reason', 4000);
evidence(
actor,
row.id,
reason,
input.status === 'resolved' ? 'Resolution' : 'Dismissal',
null,
now
);
db()
.prepare(
'UPDATE moderation_cases SET status=?,updated_at=?,revision=revision+1 WHERE id=?'
)
.run(input.status, now, row.id);
moderationEvent(actor, `case.${input.status}`, row, row.id, reason, now);
return { id: row.id };
}
if (input.op === 'note') {
const target = subject(input);
const text = textField(input.text, 'Private note', 4000);
ensureSubject(target.game, target.guid);
moderationEvent(actor, 'player.note', target, null, text, now);
return {
id: String(
(db().prepare('SELECT last_insert_rowid() AS id').get() as { id: number }).id
)
};
}
throw new AuthError('Unknown moderation action.');
});
}
@@ -0,0 +1,49 @@
import { config } from '../config.js';
import { MODERATION_CONFIG_FILE } from '../fleet/private-config.js';
import { AuthError } from '../auth/index.js';
export const moderationConfigureCapability = 'moderation.configure';
export interface ModerationConfiguration {
token: string;
configPath: string;
url: string;
generation: number;
}
/** The game connects to the public controller, never an agent's private enrollment URL. */
export function moderationRuntimeUrl(): string {
let base: URL;
try {
base = new URL(config.publicUrl);
} catch {
throw new AuthError(
'Set EC_PUBLIC_URL to the public HTTPS controller URL before configuring moderation.',
409
);
}
if (
base.protocol !== 'https:' ||
base.username ||
base.password ||
base.search ||
base.hash ||
base.pathname !== '/'
)
throw new AuthError(
'EC_PUBLIC_URL must be a public HTTPS origin without a path, credentials, query or fragment.',
409
);
return new URL('/api/moderation/runtime', base).href;
}
export function moderationConfiguration(
token: string,
generation: number
): ModerationConfiguration {
return {
token,
generation,
configPath: `configs/${MODERATION_CONFIG_FILE}`,
url: moderationRuntimeUrl()
};
}
+54
View File
@@ -0,0 +1,54 @@
import { AuthError } from '../auth/index.js';
import { nativeInstance } from '../backends/native.js';
import { db } from '../db/index.js';
import { getHost, queueHostOperation } from '../hosts/index.js';
import { moderationActor, moderationEvent } from './core.js';
import { moderationCredential } from './credentials.js';
import { moderationConfiguration, moderationConfigureCapability } from './configuration.js';
/** Persist a private, generation-bound bootstrap without exposing the credential to the browser. */
export function configureModerationServer(actorId: string, server: string): { id: string } {
return db()
.transaction(() => {
const actor = moderationActor(actorId, true);
if (actor.role !== 'admin')
throw new AuthError(
'Administrator access is required to configure game moderation.',
403
);
const instance = nativeInstance(server);
const live = db()
.prepare('SELECT destroyed_at FROM servers WHERE name=?')
.get(server) as { destroyed_at: string | null } | undefined;
if (!instance || !live || live.destroyed_at)
throw new AuthError('Choose an active native server.', 404);
const host = getHost(instance.host_id);
if (!host?.inventory.capabilities?.includes(moderationConfigureCapability))
throw new AuthError(
'Update the host agent before configuring game moderation.',
409
);
const moderation = moderationConfiguration(
moderationCredential(instance.id, instance.generation),
instance.generation
);
const operation = queueHostOperation(
instance.host_id,
'instance.configure-moderation',
{ moderation },
{
instanceId: instance.id,
generation: instance.generation
}
);
moderationEvent(
actor,
'runtime.configure',
{},
operation.id,
`Configure game moderation on ${server}.`
);
return { id: operation.id };
})
.immediate();
}
+3 -3
View File
@@ -44,7 +44,7 @@ export function observeModerationPlayers(
): void {
const upsert = handle.prepare(`INSERT INTO moderation_players VALUES(?,?,?,?,?)
ON CONFLICT(game,guid) DO UPDATE SET name=CASE WHEN excluded.last_seen_at>=last_seen_at THEN excluded.name ELSE name END,
first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`);
first_seen_at=CASE WHEN first_seen_at='' THEN excluded.first_seen_at ELSE MIN(first_seen_at,excluded.first_seen_at) END,last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`);
const aliases = handle.prepare(`INSERT INTO moderation_aliases VALUES(?,?,?,?,?)
ON CONFLICT(game,guid,name) DO UPDATE SET first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`);
for (const player of players) {
@@ -53,10 +53,10 @@ export function observeModerationPlayers(
aliases.run(...values);
}
}
export function ensureSubject(game: ModerationGame, guid: string, now: string): void {
export function ensureSubject(game: ModerationGame, guid: string): void {
db()
.prepare('INSERT INTO moderation_players VALUES(?,?,?,?,?) ON CONFLICT DO NOTHING')
.run(game, guid, guid, now, now);
.run(game, guid, guid, '', '');
}
export function moderationEvent(
actor: Pick<AuthUser, 'id' | 'username'> | null,
+55
View File
@@ -0,0 +1,55 @@
import { createHash, randomBytes } from 'node:crypto';
import { db } from '../db/index.js';
import type { ModerationGame } from '../../moderation.js';
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
export function moderationCredential(instanceId: string, generation: number): string {
return db()
.transaction(() => {
if (
!Number.isSafeInteger(generation) ||
!db()
.prepare(
'SELECT 1 FROM native_instances WHERE id=? AND generation IN (?,?) AND retired_at IS NULL'
)
.get(instanceId, generation, generation - 1)
)
throw new Error('Moderation credentials require an active instance generation.');
const existing = db()
.prepare(
'SELECT token FROM moderation_credentials WHERE instance_id=? AND generation=?'
)
.get(instanceId, generation) as { token: string } | undefined;
if (existing) return existing.token;
const token = 'ec_moderation_' + randomBytes(32).toString('base64url');
db()
.prepare('INSERT INTO moderation_credentials VALUES(?,?,?,?,?)')
.run(instanceId, generation, token, digest(token), new Date().toISOString());
return token;
})
.immediate();
}
export interface ModerationRuntimeIdentity {
instanceId: string;
generation: number;
server: string;
game: ModerationGame;
serverCreatedAt: string;
credentialHash: string;
}
export function authenticateModerationRuntime(request: Request): ModerationRuntimeIdentity | null {
const token = request.headers
.get('authorization')
?.match(/^Bearer (ec_moderation_[A-Za-z0-9_-]{43})$/)?.[1];
if (!token) return null;
return (
(db()
.prepare(
`SELECT n.id AS instanceId,n.generation,n.server,s.game,s.created_at AS serverCreatedAt,c.token_hash AS credentialHash
FROM moderation_credentials c JOIN native_instances n ON n.id=c.instance_id AND n.generation=c.generation
JOIN servers s ON s.name=n.server
WHERE c.token_hash=? AND n.retired_at IS NULL AND s.destroyed_at IS NULL AND s.game IN ('t5','t6')`
)
.get(digest(token)) as ModerationRuntimeIdentity | undefined) ?? null
);
}
+210
View File
@@ -0,0 +1,210 @@
import { db } from '../db/index.js';
import { moderationActor, moderationNow, policyRevision } from './core.js';
import { runtimeFreshMs } from './actions.js';
import { refreshActionStates } from './runtime.js';
import {
moderationGuid,
type ModerationOverview,
type ModerationQuery,
type ModerationPlayer,
type ModerationSanction,
type ModerationCase,
type ModerationEvent,
type ModerationAction,
type ModerationCoverage
} from '../../moderation.js';
export function moderationQuery(params: URLSearchParams): ModerationQuery {
const view = params.get('view');
const game = params.get('game');
const guid = (params.get('guid') ?? '').slice(0, 128);
const offset = Number(params.get('offset') ?? 0);
return {
view: view === 'restrictions' || view === 'cases' || view === 'history' ? view : 'players',
q: (params.get('q') ?? '').trim().slice(0, 200),
game: game === 't5' || game === 't6' ? game : '',
guid: game === 't5' || game === 't6' ? moderationGuid(game, guid) : guid,
offset: Number.isSafeInteger(offset) && offset >= 0 ? offset : 0
};
}
export function moderationOverview(actorId: string, query: ModerationQuery): ModerationOverview {
moderationActor(actorId);
return db().transaction(() => {
const handle = db(),
now = moderationNow(),
limit = 25;
refreshActionStates(now);
const revision = policyRevision();
const runtimeRows = handle
.prepare(
`SELECT s.name AS server,s.game,n.id,r.connected_at AS connectedAt,r.revision,r.capabilities,r.players
FROM servers s LEFT JOIN native_instances n ON n.server=s.name AND n.retired_at IS NULL
LEFT JOIN moderation_runtimes r ON r.instance_id=n.id AND r.generation=n.generation
WHERE s.destroyed_at IS NULL AND s.game IN ('t5','t6') ORDER BY s.name`
)
.all() as {
server: string;
game: 't5' | 't6';
id: string | null;
connectedAt: string | null;
revision: number | null;
capabilities: string | null;
players: string | null;
}[];
const coverage: ModerationCoverage[] = runtimeRows.map((row) => ({
server: row.server,
game: row.game,
connectedAt: row.connectedAt,
revision: row.revision,
desiredRevision: revision,
state: !row.connectedAt
? 'unavailable'
: Date.parse(now) - Date.parse(row.connectedAt) > runtimeFreshMs
? 'offline'
: row.revision === revision &&
['ban', 'mute'].every((capability) =>
(JSON.parse(row.capabilities ?? '[]') as string[]).includes(capability)
)
? 'current'
: 'pending',
capabilities: row.capabilities ? JSON.parse(row.capabilities) : []
}));
const result: ModerationOverview = {
query: { ...query },
players: [],
sanctions: [],
cases: [],
events: [],
actions: [],
coverage,
total: 0,
limit,
checkedAt: now
};
const where: string[] = [],
values: (string | number)[] = [];
if (query.game) {
where.push(
query.view === 'restrictions' ? "(p.game=? OR p.scope='fleet')" : 'p.game=?'
);
values.push(query.game);
}
if (query.guid) {
where.push('p.guid=?');
values.push(query.guid);
}
if (query.q) {
const extra =
query.view === 'restrictions'
? 'p.reason'
: query.view === 'cases'
? 'p.title'
: query.view === 'history'
? 'p.detail'
: 'p.name';
where.push(
`(instr(lower(p.guid),lower(?))>0 OR instr(lower(${extra}),lower(?))>0 OR EXISTS (SELECT 1 FROM moderation_aliases a WHERE a.game=p.game AND a.guid=p.guid AND instr(lower(a.name),lower(?))>0))`
);
values.push(query.q, query.q, query.q);
}
const clause = where.length ? ' WHERE ' + where.join(' AND ') : '';
function rows<T>(table: string, select: string, order: string): T[] {
result.total = (
handle
.prepare(`SELECT COUNT(*) AS count FROM ${table} p${clause}`)
.get(...values) as { count: number }
).count;
result.query.offset = Math.min(
query.offset,
Math.max(0, Math.floor((result.total - 1) / limit) * limit)
);
return handle
.prepare(
`SELECT ${select} FROM ${table} p${clause} ORDER BY ${order} LIMIT ? OFFSET ?`
)
.all(...values, limit, result.query.offset) as T[];
}
if (query.view === 'players') {
result.players = rows<Omit<ModerationPlayer, 'aliases' | 'servers' | 'restrictions'>>(
'moderation_players',
"p.game,p.guid,p.name,NULLIF(p.first_seen_at,'') AS firstSeen,NULLIF(p.last_seen_at,'') AS lastSeen",
'p.last_seen_at DESC,p.game,p.guid'
).map((player) => ({
...player,
aliases: (
handle
.prepare(
'SELECT name FROM moderation_aliases WHERE game=? AND guid=? ORDER BY last_seen_at DESC LIMIT 30'
)
.all(player.game, player.guid) as { name: string }[]
).map((a) => a.name),
servers: runtimeRows
.filter(
(r) =>
r.game === player.game &&
r.connectedAt &&
Date.parse(now) - Date.parse(r.connectedAt) <= runtimeFreshMs &&
r.players &&
(JSON.parse(r.players) as { guid: string }[]).some(
(p) => p.guid === player.guid
)
)
.map((r) => r.server),
restrictions: (
handle
.prepare(
"SELECT COUNT(*) AS count FROM moderation_sanctions WHERE guid=? AND (game=? OR scope='fleet') AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)"
)
.get(player.guid, player.game, now) as { count: number }
).count
}));
} else if (query.view === 'restrictions') {
result.sanctions = rows<Omit<ModerationSanction, 'active'>>(
'moderation_sanctions',
'p.id,p.game,p.guid,p.kind,p.scope,p.server,p.reason,p.note,p.created_at AS createdAt,p.expires_at AS expiresAt,p.revoked_at AS revokedAt,p.actor_id AS actorId,p.actor,p.revision',
'p.created_at DESC,p.id'
).map((s) => ({ ...s, active: !s.revokedAt && (!s.expiresAt || s.expiresAt > now) }));
} else if (query.view === 'cases') {
result.cases = rows<Omit<ModerationCase, 'evidence'>>(
'moderation_cases',
'p.id,p.game,p.guid,p.title,p.status,p.actor,p.created_at AS createdAt,p.updated_at AS updatedAt,p.revision',
'p.updated_at DESC,p.id'
).map((c) => ({
...c,
evidence: handle
.prepare(
'SELECT id,text,source,created_at AS createdAt,actor FROM moderation_evidence WHERE case_id=? ORDER BY created_at,id'
)
.all(c.id) as ModerationCase['evidence']
}));
} else {
result.events = rows<ModerationEvent>(
'moderation_events',
'p.id,p.game,p.guid,p.action,p.subject_id AS subjectId,p.actor,p.detail,p.created_at AS createdAt',
'p.id DESC'
);
}
const actionWhere: string[] = [],
actionValues: string[] = [];
if (query.game) {
actionWhere.push('game=?');
actionValues.push(query.game);
}
if (query.guid) {
actionWhere.push('guid=?');
actionValues.push(query.guid);
}
if (query.q) {
actionWhere.push(
'(instr(lower(guid),lower(?))>0 OR instr(lower(reason),lower(?))>0 OR EXISTS (SELECT 1 FROM moderation_aliases a WHERE a.game=moderation_actions.game AND a.guid=moderation_actions.guid AND instr(lower(a.name),lower(?))>0))'
);
actionValues.push(query.q, query.q, query.q);
}
result.actions = handle
.prepare(
`SELECT id,game,guid,server,kind,reason,actor,created_at AS createdAt,expires_at AS expiresAt,state,detail FROM moderation_actions${actionWhere.length ? ' WHERE ' + actionWhere.join(' AND ') : ''} ORDER BY created_at DESC,id LIMIT 25`
)
.all(...actionValues) as ModerationAction[];
return result;
})();
}
+313
View File
@@ -0,0 +1,313 @@
import { db } from '../db/index.js';
import { AuthError, getUser } from '../auth/index.js';
import { canModerate } from '../../auth.js';
import { moderationGuid } from '../../moderation.js';
import {
moderationEvent,
moderationNow,
observeModerationPlayers,
policyRevision,
textField
} from './core.js';
import type { ModerationRuntimeIdentity } from './credentials.js';
export { moderationCredential } from './credentials.js';
const capabilities = ['warn', 'message', 'kick', 'announce', 'ban', 'mute'];
export function refreshActionStates(now = moderationNow()): void {
const rows = db()
.prepare(
"SELECT id,game,guid,state FROM moderation_actions WHERE state IN ('queued','delivered') AND expires_at<=?"
)
.all(now) as { id: string; game: string; guid: string | null; state: string }[];
for (const row of rows) {
const state = row.state === 'queued' ? 'expired' : 'unknown';
const detail =
state === 'expired'
? 'The action expired before delivery.'
: 'The game did not acknowledge the delivered action before its deadline.';
db()
.prepare(
'UPDATE moderation_actions SET state=?,detail=?,finished_at=? WHERE id=? AND state=?'
)
.run(state, detail, now, row.id, row.state);
moderationEvent(null, `action.${state}`, row, row.id, detail, now);
}
}
function token(value: unknown, label: string): string {
const result = textField(value, label, 128);
if (!/^[A-Za-z0-9:_-]+$/.test(result)) throw new AuthError(`Invalid ${label.toLowerCase()}.`);
return result;
}
export function syncModerationRuntime(
identity: ModerationRuntimeIdentity,
input: Record<string, unknown>
) {
if (
input.protocol !== 1 ||
input.server !== identity.server ||
input.generation !== identity.generation
)
throw new AuthError('The runtime identity does not match this credential.', 403);
const session = token(input.session, 'Runtime session');
if (!Number.isSafeInteger(input.sessionSequence) || (input.sessionSequence as number) < 1)
throw new AuthError('A positive runtime session sequence is required.');
if (
!Array.isArray(input.players) ||
input.players.length > 64 ||
!Array.isArray(input.receipts) ||
input.receipts.length > 256 ||
!Array.isArray(input.capabilities) ||
input.capabilities.length > capabilities.length ||
!Number.isSafeInteger(input.appliedRevision) ||
(input.appliedRevision as number) < 0
)
throw new AuthError('Invalid moderation synchronization request.');
const supported = [...new Set(input.capabilities)];
if (supported.some((c) => typeof c !== 'string' || !capabilities.includes(c)))
throw new AuthError('Unknown moderation capability.');
const players = input.players.map((value) => {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new AuthError('Invalid player.');
const raw = token(value.guid, 'Player GUID');
const name = textField(value.name, 'Player name', 255);
if (Buffer.byteLength(name, 'utf8') > 255)
throw new AuthError('Player names must fit within 255 UTF-8 bytes.');
return {
guid: moderationGuid(identity.game, raw),
name,
connection: token(value.connection, 'Player connection')
};
});
if (
new Set(players.map((p) => p.connection)).size !== players.length ||
new Set(players.map((p) => p.guid)).size !== players.length
)
throw new AuthError('Player identities and connections must be unique.');
const receipts = input.receipts.map((value) => {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!['confirmed', 'failed', 'unknown', 'expired'].includes(value.state)
)
throw new AuthError('Invalid action receipt.');
return {
id: token(value.id, 'Action ID'),
state: String(value.state),
detail: textField(value.detail, 'Receipt detail', 500, true)
};
});
return db()
.transaction(() => {
// Reading a request body yields: retirement/rebuild/credential replacement may
// have completed since the HTTP handler first authenticated this request.
const current = db()
.prepare(
`SELECT 1 FROM native_instances n
JOIN servers s ON s.name=n.server
JOIN moderation_credentials c ON c.instance_id=n.id AND c.generation=n.generation
WHERE n.id=? AND n.generation=? AND n.retired_at IS NULL AND n.server=?
AND s.destroyed_at IS NULL AND s.game=? AND s.created_at=? AND c.token_hash=?`
)
.get(
identity.instanceId,
identity.generation,
identity.server,
identity.game,
identity.serverCreatedAt,
identity.credentialHash
);
if (!current)
throw new AuthError('This moderation runtime credential is no longer active.', 403);
const previous = db()
.prepare(
'SELECT session,session_sequence FROM moderation_runtimes WHERE instance_id=? AND generation=?'
)
.get(identity.instanceId, identity.generation) as
{ session: string; session_sequence: number } | undefined;
if (
previous &&
((input.sessionSequence as number) < previous.session_sequence ||
(input.sessionSequence === previous.session_sequence &&
session !== previous.session))
)
throw new AuthError('This runtime session has been superseded.', 409);
const now = moderationNow();
const revision = policyRevision();
if ((input.appliedRevision as number) > revision)
throw new AuthError(
'The runtime has a newer policy revision than this controller. Review the controller restore before reconfiguring this integration.',
409
);
refreshActionStates(now);
for (const receipt of receipts) {
const action = db()
.prepare(
'SELECT * FROM moderation_actions WHERE id=? AND instance_id=? AND generation=?'
)
.get(receipt.id, identity.instanceId, identity.generation) as
| {
id: string;
game: string;
guid: string | null;
session: string;
state: string;
delivered_at: string | null;
detail: string | null;
}
| undefined;
// Old journal receipts can outlive controller retention/restores, but cannot target other instances.
if (!action) continue;
if (!action.delivered_at)
throw new AuthError('An undelivered action cannot be acknowledged.', 409);
if (['confirmed', 'failed', 'expired'].includes(action.state)) continue;
// Durable native receipts may be resubmitted after a game process restart.
if (action.state === receipt.state && action.detail === receipt.detail) continue;
db()
.prepare(
'UPDATE moderation_actions SET state=?,detail=?,finished_at=? WHERE id=?'
)
.run(receipt.state, receipt.detail || null, now, action.id);
moderationEvent(
null,
`action.${receipt.state}`,
action,
action.id,
receipt.detail || receipt.state,
now
);
}
if (previous && previous.session !== session) {
const abandoned = db()
.prepare(
"SELECT id,game,guid,state FROM moderation_actions WHERE instance_id=? AND generation=? AND session<>? AND state IN ('queued','delivered')"
)
.all(identity.instanceId, identity.generation, session) as {
id: string;
game: string;
guid: string | null;
state: string;
}[];
for (const action of abandoned) {
const state = action.state === 'queued' ? 'expired' : 'unknown';
db()
.prepare(
'UPDATE moderation_actions SET state=?,detail=?,finished_at=? WHERE id=?'
)
.run(state, 'The game runtime changed before completion.', now, action.id);
moderationEvent(
null,
`action.${state}`,
action,
action.id,
'The game runtime changed before completion.',
now
);
}
}
db()
.prepare(
`INSERT INTO moderation_runtimes(instance_id,generation,session,capabilities,players,revision,connected_at,session_sequence) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(instance_id) DO UPDATE SET
generation=excluded.generation,session=excluded.session,capabilities=excluded.capabilities,players=excluded.players,
revision=excluded.revision,connected_at=excluded.connected_at,session_sequence=excluded.session_sequence`
)
.run(
identity.instanceId,
identity.generation,
session,
JSON.stringify(supported),
JSON.stringify(players),
input.appliedRevision,
now,
input.sessionSequence
);
observeModerationPlayers(db(), identity.game, players, now);
const sanctions = db()
.prepare(
`SELECT id,revision,guid,kind,reason,expires_at AS expiresAt FROM moderation_sanctions
WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?) AND
((scope='fleet') OR (game=? AND (scope='game' OR (scope='server' AND server=? AND server_created_at=?)))) ORDER BY id LIMIT 1001`
)
.all(now, identity.game, identity.server, identity.serverCreatedAt);
if (sanctions.length > 1000)
throw new AuthError(
'The moderation policy exceeds the runtime’s supported size.',
503
);
const pending = db()
.prepare(
`SELECT * FROM moderation_actions WHERE instance_id=? AND generation=? AND session=?
AND state IN ('queued','delivered') AND expires_at>? ORDER BY created_at,id LIMIT 100`
)
.all(identity.instanceId, identity.generation, session, now) as {
id: string;
guid: string | null;
connection: string | null;
kind: string;
reason: string;
expires_at: string;
actor_id: string;
game: string;
state: string;
}[];
const actions = [];
for (const action of pending) {
const actor = getUser(action.actor_id);
let failure: string | null = null;
if (
!actor ||
actor.disabled ||
actor.mustChangePassword ||
!canModerate(actor.role)
)
failure = 'The requesting moderator no longer has access.';
else if (!supported.includes(action.kind))
failure = 'The runtime no longer supports this action.';
else if (
action.guid &&
!players.some(
(p) => p.guid === action.guid && p.connection === action.connection
)
)
failure = 'The targeted player connection has ended.';
if (failure) {
const state = action.state === 'delivered' ? 'unknown' : 'failed';
db()
.prepare(
'UPDATE moderation_actions SET state=?,detail=?,finished_at=? WHERE id=?'
)
.run(state, failure, now, action.id);
moderationEvent(null, `action.${state}`, action, action.id, failure, now);
continue;
}
db()
.prepare(
"UPDATE moderation_actions SET state='delivered',delivered_at=COALESCE(delivered_at,?) WHERE id=?"
)
.run(now, action.id);
actions.push({
id: action.id,
guid: action.guid,
connection: action.connection,
kind: action.kind,
reason: action.reason,
expiresAt: action.expires_at
});
}
const response = {
ok: true,
revision,
serverTime: now,
sanctions,
actions,
pollAfterMs: 3000
};
if (Buffer.byteLength(JSON.stringify(response)) > 1024 * 1024)
throw new AuthError(
'The moderation response exceeds the runtime’s supported size.',
503
);
return response;
})
.immediate();
}
+23 -13
View File
@@ -79,23 +79,33 @@ export function migrateModeration(handle: Database.Database): void {
const alias = handle.prepare(`INSERT INTO moderation_aliases VALUES(?,?,?,?,?)
ON CONFLICT(game,guid,name) DO UPDATE SET
first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`);
for (const row of handle
.prepare("SELECT * FROM leaderboard_players WHERE game IN ('t5','t6')")
.iterate() as Iterable<{
type Player = {
game: ModerationGame;
guid: string;
name: string;
first_seen_at: string;
last_seen_at: string;
}>) {
const values = [
row.game,
moderationGuid(row.game, row.guid),
row.name,
row.first_seen_at,
row.last_seen_at
];
player.run(...values);
alias.run(...values);
};
const read = handle.prepare(`SELECT * FROM leaderboard_players
WHERE game IN ('t5','t6') AND (game,guid)>(?,?) ORDER BY game,guid LIMIT 1000`);
let cursor = ['', ''];
for (;;) {
// Finish each read before writing on this connection, without loading
// the full historical player directory into memory during an upgrade.
const rows = read.all(...cursor) as Player[];
if (!rows.length) break;
for (const row of rows) {
const values = [
row.game,
moderationGuid(row.game, row.guid),
row.name,
row.first_seen_at,
row.last_seen_at
];
player.run(...values);
alias.run(...values);
}
const last = rows[rows.length - 1];
cursor = [last.game, last.guid];
}
}
@@ -5,6 +5,7 @@ import { db } from '../db/index.js';
import type { NativeInstance } from '../backends/native.js';
import type { LaunchRecipePlan } from '../fleet/launch-recipes.js';
import type { PlacementOptions } from '../hosts/capacity.js';
import type { ModerationConfiguration } from '../moderation/configuration.js';
export interface NativeLaunchInput {
game: 't5' | 't6';
@@ -14,6 +15,7 @@ export interface NativeLaunchInput {
serverKey: string;
rconPassword: string;
bankToken: string;
moderation?: ModerationConfiguration;
rebuild: boolean;
safetyOverride?: boolean;
unhealthyRecovery?: boolean;
+11 -5
View File
@@ -12,6 +12,7 @@ import {
import { sha256Buffer } from '../fleet/files.js';
import type { NativeLaunchInput } from './launch-preparation.js';
import { choosePlacement, type PlacementOptions } from '../hosts/capacity.js';
import { isPrivateConfigPath } from '../fleet/private-config.js';
parentPort!.on(
'message',
@@ -79,12 +80,16 @@ parentPort!.on(
'electric_cherry_bank.cfg'
);
if (
recipe.files.some(
(file) =>
posix.join(relDestForKind(file.kind, recipe.game), file.path) === bankPath
recipe.files.some((file) =>
isPrivateConfigPath(
posix.join(relDestForKind(file.kind, recipe.game), file.path),
recipe.game
)
)
)
throw new Error('electric_cherry_bank.cfg is reserved for instance credentials.');
throw new Error(
'Private configuration files are reserved for instance credentials.'
);
const payload = {
...(input.activationId ? { stageId: input.activationId } : {}),
name: instance.server,
@@ -95,13 +100,14 @@ parentPort!.on(
bindAddress: instance.bind_address,
rconPassword: password,
bank: { token: input.bankToken, configPath: bankPath },
...(input.moderation ? { moderation: input.moderation } : {}),
env: {
PLUTAINER_SERVER_NAME: instance.server,
PLUTAINER_PORT: String(instance.port),
PLUTAINER_GAME: recipe.game === 't6' ? 't6zm' : 't5sp',
PLUTAINER_CONFIG_FILE: recipe.configFile,
PLUTAINER_MAP_ROTATE: 'false',
PLUTAINER_EXTRA_ARGS: `+exec electric_cherry_map.cfg +exec electric_cherry_bank.cfg +set tws_server_id ${instance.server} +map_rotate`,
PLUTAINER_EXTRA_ARGS: `+exec electric_cherry_map.cfg +exec electric_cherry_bank.cfg${input.moderation ? ' +exec electric_cherry_moderation.cfg' : ''} +set tws_server_id ${instance.server} +map_rotate`,
PLUTO_SERVER_KEY: input.serverKey
},
files: recipe.files.map((file) => ({
+30 -1
View File
@@ -14,6 +14,11 @@ import { setImmediate as yieldToController } from 'node:timers/promises';
import { choosePlacement, availableHostPort, type PlacementOptions } from '../hosts/capacity.js';
import { randomBytes, randomUUID } from 'node:crypto';
import { bankCredential } from '../auth/bank.js';
import { moderationCredential } from '../moderation/credentials.js';
import {
moderationConfiguration,
moderationConfigureCapability
} from '../moderation/configuration.js';
import { posix } from 'node:path';
import { db } from '../db/index.js';
import { unsettledCancelledCommand } from './cancellation.js';
@@ -1996,6 +2001,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
let prepared: PreparedNativeLaunch | undefined;
let preparedKey: string | null = null;
let preparedPassword: string | undefined;
let preparedModeration = false;
try {
if (job.action === 'create' || job.action === 'rebuild') {
if (
@@ -2027,6 +2033,11 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
(files.length ? runtimePasswordForServer(job.server) : undefined) ||
config.rcon.password ||
randomBytes(32).toString('hex');
const rebuilding = job.action === 'rebuild' && !migration;
const generation = instance.generation + (rebuilding ? 1 : 0);
preparedModeration =
!!config.publicUrl &&
!!host.inventory.capabilities?.includes(moderationConfigureCapability);
prepared = await prepareNativeLaunch({
game: prior.game,
mapSlug: prior.mapSlug,
@@ -2035,7 +2046,15 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
serverKey: preparedKey,
rconPassword: preparedPassword,
bankToken: bankCredential(instance.id),
rebuild: job.action === 'rebuild' && !migration,
...(preparedModeration
? {
moderation: moderationConfiguration(
moderationCredential(instance.id, generation),
generation
)
}
: {}),
rebuild: rebuilding,
safetyOverride: overrideSafety,
unhealthyRecovery:
!files.length &&
@@ -2124,6 +2143,16 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
return;
}
if (prepared) {
if (
preparedModeration &&
!liveHost.inventory.capabilities?.includes(moderationConfigureCapability)
) {
hold(
job,
'Host moderation support changed during preparation; preparing the current selection next pass.'
);
return;
}
// Compare the same runtime config bytes while rechecking current promoted files.
const expected =
migration && isRebalanceMigration(job.id)