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
+21 -2
View File
@@ -1,3 +1,5 @@
import { moderatorMaximumRestrictionMs, type AuthUser } from './auth.js';
export type ModerationGame = 't5' | 't6';
export type ModerationScope = 'server' | 'game' | 'fleet';
export type ModerationActionKind = 'warn' | 'message' | 'kick' | 'announce';
@@ -9,8 +11,8 @@ export interface ModerationPlayer {
guid: string;
name: string;
aliases: string[];
firstSeen: string;
lastSeen: string;
firstSeen: string | null;
lastSeen: string | null;
servers: string[];
restrictions: number;
}
@@ -31,6 +33,23 @@ export interface ModerationSanction {
revision: number;
active: boolean;
}
export function canRevokeModerationRestriction(
actor: Pick<AuthUser, 'id' | 'role'> | null | undefined,
restriction: Pick<ModerationSanction, 'actorId' | 'scope' | 'createdAt' | 'expiresAt'>
): boolean {
if (actor?.role === 'admin') return true;
if (
actor?.role !== 'moderator' ||
actor.id !== restriction.actorId ||
(restriction.scope !== 'server' && restriction.scope !== 'game') ||
!restriction.expiresAt
)
return false;
const duration = Date.parse(restriction.expiresAt) - Date.parse(restriction.createdAt);
return duration > 0 && duration <= moderatorMaximumRestrictionMs;
}
export interface ModerationAction {
id: string;
game: ModerationGame;
+9 -2
View File
@@ -1,9 +1,10 @@
import { canReadAdmin, type UserRole } from './auth.js';
import { canReadAdmin, canReadModeration, type UserRole } from './auth.js';
export interface NavigationItem {
label: string;
href: string;
shortcut?: string;
moderation?: boolean;
matches?: (path: string) => boolean;
}
export interface NavigationGroup {
@@ -71,6 +72,7 @@ export const navigationGroups: NavigationGroup[] = [
{ label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' },
{ label: 'Matches', href: '/matches' },
{ label: 'Bank', href: '/bank' },
{ label: 'Moderation', href: '/moderation', moderation: true },
{ label: 'Usage', href: '/metrics', shortcut: 'm' }
]
},
@@ -92,7 +94,12 @@ export const navigationGroups: NavigationGroup[] = [
];
export function visibleNavigation(role: UserRole): NavigationGroup[] {
return navigationGroups.filter((group) => !group.admin || canReadAdmin(role));
return navigationGroups
.filter((group) => !group.admin || canReadAdmin(role))
.map((group) => ({
...group,
items: group.items.filter((item) => !item.moderation || canReadModeration(role))
}));
}
export function navigationItemActive(item: NavigationItem, path: string): boolean {
+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)
+25
View File
@@ -1,5 +1,8 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { page } from '$app/stores';
import { canReadModeration } from '$lib/auth.js';
import { moderationHref } from '$ui/moderation.js';
import type { ChatContext, ContextualChatEntry } from '$lib/game-chat.js';
import { formatDuration } from '$ui/duration.js';
import { groupChatTimeline, type ChatBoundaryEntry } from '$ui/chat-timeline.js';
@@ -10,16 +13,19 @@
let {
server,
game,
history,
hostOffline = false,
hostName
}: {
server?: string;
game?: string;
history?: ContextualChatEntry[];
hostOffline?: boolean;
hostName?: string;
} = $props();
let updatedAt = $state<number | null>(null);
const moderationGame = $derived(game ?? server?.split('_')[0]);
let liveEntries = $state<ContextualChatEntry[]>([]);
const entries = $derived(history ?? liveEntries);
let timeline = $derived(groupChatTimeline(entries));
@@ -242,6 +248,18 @@
<div class="message-head">
<strong>{item.entry.message.player}</strong
>{@render chatContext(item.entry.context)}
{#if canReadModeration($page.data?.user?.role) && (moderationGame === 't5' || moderationGame === 't6')}<a
class="moderation-link"
href={moderationHref({
game: moderationGame,
q: item.entry.message.guid,
server,
matchId: item.entry.matchId,
message: item.entry.message.message
})}
aria-label={`Review ${item.entry.message.player} in moderation`}
>Moderation</a
>{/if}
</div>
<p>{item.entry.message.message}</p>
</div>
@@ -371,6 +389,9 @@
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.moderation-link {
font-size: var(--text-xs);
}
.match-events {
padding: var(--space-1) 0;
}
@@ -463,5 +484,9 @@
grid-row: 3;
margin-top: 0;
}
.moderation-link {
grid-column: 1 / -1;
grid-row: 4;
}
}
</style>
@@ -1,5 +1,8 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { page } from '$app/stores';
import { canReadModeration } from '$lib/auth.js';
import { moderationHref } from '$ui/moderation.js';
import type { GameTelemetry } from '$lib/telemetry.js';
import { freshTelemetry } from '$lib/server-observation.js';
import { uiNow } from '$ui/stores/servers.js';
@@ -164,6 +167,14 @@
<a class="player-name" href={playerProfileHref(player.guid)}
>{player.name}</a
>
{#if canReadModeration($page.data?.user?.role)}<a
class="player-details"
href={moderationHref({
game: report.server.split('_')[0],
guid: player.guid,
server: report.server
})}>Moderation</a
>{/if}
<button
class="player-details"
aria-label={`Details for ${player.name}`}
@@ -0,0 +1,408 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { ModerationCoverage, ModerationGame, ModerationScope } from '$lib/moderation.js';
import { moderationGameName } from '$ui/moderation.js';
import { serverTitle } from '$ui/server-labels.js';
import { confirmAction } from '$ui/confirm.js';
import Dialog from '../primitives/Dialog.svelte';
import Field from '../primitives/Field.svelte';
import Input from '../primitives/Input.svelte';
import Select from '../primitives/Select.svelte';
import Textarea from '../primitives/Textarea.svelte';
import Button from '../primitives/Button.svelte';
let {
open = $bindable(false),
mode,
coverage,
admin,
seed = {},
onsubmitted
}: {
open?: boolean;
mode: 'restriction' | 'action' | 'case' | 'note' | 'revoke' | 'case-note' | 'resolve';
coverage: ModerationCoverage[];
admin: boolean;
seed?: {
game?: ModerationGame;
guid?: string;
server?: string;
id?: string;
revision?: number;
matchId?: number;
message?: string;
};
onsubmitted: (target: { game?: ModerationGame; guid?: string | null }) => void;
} = $props();
let game = $state<ModerationGame | ''>(untrack(() => seed.game ?? ''));
let guid = $state(untrack(() => seed.guid ?? ''));
let server = $state(untrack(() => seed.server ?? ''));
let scope = $state<ModerationScope>(untrack(() => (seed.server ? 'server' : 'game')));
let kind = $state('ban');
let action = $state('warn');
let duration = $state('60');
let reason = $state('');
let note = $state('');
let title = $state('');
const reasonBytes = $derived(new TextEncoder().encode(reason.trim()).length);
const gameText = $derived(mode === 'restriction' || mode === 'action');
let resolution = $state('resolved');
let busy = $state(false);
let error = $state('');
let uncertain = $state(false);
let pending: Record<string, unknown> | null = null;
const titleText = $derived(
{
restriction: 'Restrict player',
action: 'Live action',
case: 'Open moderation case',
note: 'Add private note',
revoke: 'Revoke restriction',
'case-note': 'Add case note',
resolve: 'Review case'
}[mode]
);
const targetRequired = $derived(['restriction', 'action', 'case', 'note'].includes(mode));
const needsServer = $derived(
mode === 'action' || (mode === 'restriction' && scope === 'server')
);
const serverOptions = $derived([
{ value: '', label: 'Select a server' },
...coverage
.filter((item) => item.game === game)
.map((item) => ({ value: item.server, label: serverTitle(item.server) }))
]);
const selectedServer = $derived(
coverage.find((item) => item.server === server && item.game === game)
);
const actionUnavailable = $derived(
mode === 'action' &&
(!selectedServer ||
['offline', 'unavailable'].includes(selectedServer.state) ||
!selectedServer.capabilities.includes(action))
);
const actionOptions = $derived(
[
{ value: 'warn', label: 'Warn player' },
{ value: 'message', label: 'Private message' },
{ value: 'kick', label: 'Kick player' },
{ value: 'announce', label: 'Server announcement' }
].map((item) => ({ ...item, disabled: !selectedServer?.capabilities.includes(item.value) }))
);
function payload(): Record<string, unknown> {
if (mode === 'restriction')
return {
op: 'create-sanction',
game,
guid: guid.trim(),
kind,
scope,
server: scope === 'server' ? server : undefined,
reason: reason.trim(),
note: note.trim(),
durationMinutes: duration === 'permanent' ? null : Number(duration)
};
if (mode === 'action')
return {
op: 'action',
game,
guid: action === 'announce' ? null : guid.trim(),
server,
kind: action,
reason: reason.trim()
};
if (mode === 'case')
return {
op: 'create-case',
game,
guid: guid.trim(),
title: title.trim(),
note: note.trim(),
...(seed.matchId && seed.message
? { evidence: { matchId: seed.matchId, message: seed.message } }
: {})
};
if (mode === 'note') return { op: 'note', game, guid: guid.trim(), text: note.trim() };
if (mode === 'case-note') return { op: 'case-note', id: seed.id, text: note.trim() };
if (mode === 'revoke')
return {
op: 'revoke-sanction',
id: seed.id,
revision: seed.revision,
reason: reason.trim()
};
return {
op: 'resolve-case',
id: seed.id,
revision: seed.revision,
status: resolution,
reason: reason.trim()
};
}
async function submit(event: SubmitEvent) {
event.preventDefault();
if (busy) return;
if (!pending && gameText && reasonBytes > 240) {
error = 'The player-visible text is too long. Shorten it to 240 bytes or fewer.';
return;
}
if (!pending && targetRequired && !game) {
error = 'Choose the game for this player identity.';
return;
}
if (!pending && actionUnavailable) {
error = 'The selected server does not currently support this action.';
return;
}
busy = true;
error = '';
if (!pending && mode === 'restriction' && (scope === 'fleet' || duration === 'permanent')) {
const accepted = await confirmAction({
title: 'Confirm restriction',
message: `${kind === 'ban' ? 'Ban' : 'Mute'} player ${guid} ${scope === 'fleet' ? 'across the fleet' : scope === 'game' ? `in ${moderationGameName(game as ModerationGame)}` : `on ${serverTitle(server)}`} ${duration === 'permanent' ? 'permanently' : `for ${Number(duration).toLocaleString()} minutes`}. Reason: ${reason}`,
confirmLabel: 'Apply restriction',
danger: true
});
if (!accepted) {
busy = false;
return;
}
}
pending ??= { ...payload(), idempotencyKey: crypto.randomUUID() };
try {
const response = await fetch('/api/moderation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pending)
});
const result = await response.json().catch(() => null);
if (!response.ok || !result?.ok) {
uncertain = response.status >= 500 || response.ok;
if (!uncertain) pending = null;
throw new Error(result?.error || 'Could not save this moderation action.');
}
open = false;
onsubmitted({
game: pending.game as ModerationGame | undefined,
guid: pending.guid as string | null | undefined
});
} catch (cause) {
if (pending) uncertain = true;
error =
cause instanceof Error ? cause.message : 'Could not save this moderation action.';
} finally {
busy = false;
}
}
</script>
<Dialog
bind:open
title={titleText}
{busy}
description="Every moderation change is recorded with your identity and reason."
>
<form onsubmit={submit} class="moderation-form">
<fieldset disabled={busy || uncertain}>
{#if targetRequired}
<Field label="Game" required
><Select
bind:value={game}
required
onchange={() => (server = '')}
options={[
{ value: '', label: 'Select a game' },
{ value: 't5', label: 'Black Ops I' },
{ value: 't6', label: 'Black Ops II' }
]}
/></Field
>
{#if mode === 'action'}
<Field label="Action"
><Select bind:value={action} options={actionOptions} /></Field
>
{/if}
{#if mode !== 'action' || action !== 'announce'}
<Field
label="Player GUID"
required
hint="Use the recorded player identity. Names can change."
><Input name="target-guid" bind:value={guid} required mono /></Field
>
{/if}
{/if}
{#if mode === 'restriction'}
<div class="paired">
<Field label="Restriction"
><Select
bind:value={kind}
options={[
{ value: 'ban', label: 'Ban' },
{ value: 'mute', label: 'Mute' }
]}
/></Field
>
<Field label="Scope"
><Select
bind:value={scope}
options={[
{ value: 'server', label: 'One server' },
{ value: 'game', label: 'This game' },
...(admin ? [{ value: 'fleet', label: 'Entire fleet' }] : [])
]}
/></Field
>
</div>
<Field
label="Duration"
hint={admin
? undefined
: 'Moderators can restrict a player for up to seven days.'}
>
<Select
bind:value={duration}
options={[
{ value: '15', label: '15 minutes' },
{ value: '60', label: '1 hour' },
{ value: '1440', label: '1 day' },
{ value: '10080', label: '7 days' },
...(admin
? [
{ value: '43200', label: '30 days' },
{ value: 'permanent', label: 'Permanent' }
]
: [])
]}
/>
</Field>
{#if scope === 'fleet'}<p class="hint">
Fleet restrictions require a verified player identity across games.
</p>{/if}
{/if}
{#if needsServer}<Field label="Server" required
><Select bind:value={server} options={serverOptions} required /></Field
>{/if}
{#if actionUnavailable}<p class="hint">
Select a connected server with support for this action. Unavailable actions
cannot be sent.
</p>{/if}
{#if mode === 'restriction'}<p class="hint">
Restrictions are saved immediately. They take effect only on servers with the
required capability and current policy; review enforcement coverage after
saving.
</p>{/if}
{#if mode === 'case'}
<Field label="Case title" required
><Input bind:value={title} required maxlength={160} /></Field
>
{#if seed.matchId && seed.message}<div class="evidence">
<strong>Selected chat evidence · Match #{seed.matchId}</strong>
<p>{seed.message}</p>
<small
>The original archived message and its context will be preserved when
the case is saved.</small
>
</div>{/if}
{/if}
{#if mode === 'resolve'}<Field label="Decision"
><Select
bind:value={resolution}
options={[
{ value: 'resolved', label: 'Resolved' },
{ value: 'dismissed', label: 'Dismissed' }
]}
/></Field
>{/if}
{#if ['restriction', 'action', 'revoke', 'resolve'].includes(mode)}
<Field
label={mode === 'action' && ['message', 'announce'].includes(action)
? 'Message'
: mode === 'restriction' || mode === 'action'
? 'Player-visible reason'
: 'Review reason'}
hint={gameText
? `${reasonBytes} / 240 bytes. Some characters use more than one byte.`
: undefined}
required
><Textarea
bind:value={reason}
required
rows={3}
maxlength={gameText ? 240 : undefined}
/></Field
>
{/if}
{#if ['restriction', 'case', 'note', 'case-note'].includes(mode)}
<Field label="Private staff note" required={mode === 'note' || mode === 'case-note'}
><Textarea
bind:value={note}
required={mode === 'note' || mode === 'case-note'}
rows={4}
/></Field
>
{/if}
</fieldset>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if uncertain}<p class="hint" role="status">
The outcome is not yet known. Retry sends the same request safely; check History
before starting a different action.
</p>{/if}
<div class="actions">
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
type="submit"
disabled={!uncertain && actionUnavailable}
variant={mode === 'restriction' || mode === 'revoke' ? 'danger' : 'primary'}
loading={busy}>{uncertain ? 'Retry same request' : 'Save'}</Button
>
</div>
</form>
</Dialog>
<style>
.moderation-form,
fieldset {
display: grid;
gap: var(--space-4);
min-width: 0;
}
fieldset {
border: 0;
padding: 0;
margin: 0;
}
.paired {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
.actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
}
.hint,
.evidence {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm);
}
.evidence {
padding: var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
overflow-wrap: anywhere;
}
.evidence p {
white-space: pre-wrap;
}
.evidence small {
font-size: var(--text-xs);
}
@media (max-width: 640px) {
.paired {
grid-template-columns: 1fr;
}
}
</style>
@@ -11,6 +11,7 @@
required?: boolean;
disabled?: boolean;
readonly?: boolean;
maxlength?: number;
mono?: boolean;
oninput?: (e: Event) => void;
ariaLabel?: string;
@@ -25,6 +26,7 @@
required = false,
disabled = false,
readonly = false,
maxlength,
mono = false,
oninput,
ariaLabel
@@ -41,6 +43,7 @@
{required}
{disabled}
{readonly}
{maxlength}
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : field?.labelId}
aria-describedby={field?.messageId}
+91
View File
@@ -0,0 +1,91 @@
import type { ModerationGame } from '$lib/moderation.js';
import { serverTitle } from './server-labels.js';
export function moderationHref(context: {
game?: string;
guid?: string;
q?: string;
server?: string;
matchId?: number;
message?: string;
}): string {
const params = new URLSearchParams();
if (context.game === 't5' || context.game === 't6') params.set('game', context.game);
if (context.guid) params.set('guid', context.guid);
if (context.q) params.set('q', context.q);
if (context.server) params.set('server', context.server);
if (context.matchId !== undefined && context.message) {
params.set('matchId', String(context.matchId));
params.set('message', context.message);
}
return `/moderation?${params}`;
}
export function moderationGameName(game: ModerationGame): string {
return game === 't5' ? 'Black Ops I' : 'Black Ops II';
}
export const moderationActionLabels: Record<string, string> = {
warn: 'Warning',
message: 'Private message',
kick: 'Kick',
announce: 'Announcement',
'create-sanction': 'Restriction created',
'revoke-sanction': 'Restriction revoked',
'create-case': 'Case opened',
'case-note': 'Case note added',
'resolve-case': 'Case reviewed',
note: 'Private note',
'restriction.created': 'Restriction created',
'restriction.revoked': 'Restriction revoked',
'action.queued': 'Live action requested',
'action.confirmed': 'Live action confirmed',
'action.failed': 'Live action failed',
'action.expired': 'Live action expired',
'action.unknown': 'Live action outcome unknown',
'case.opened': 'Case opened',
'case.note': 'Case note added',
'case.resolved': 'Case resolved',
'case.dismissed': 'Case dismissed',
'player.note': 'Private player note',
'runtime.configure': 'Integration configuration requested'
};
export function moderationEventDetail(detail: string, action: string): string {
if (!['restriction.created', 'action.queued'].includes(action)) return detail;
try {
const value: unknown = JSON.parse(detail);
if (
!value ||
typeof value !== 'object' ||
!('reason' in value) ||
typeof value.reason !== 'string'
)
return detail;
const record = value as Record<string, unknown>;
const context = [
typeof record.kind === 'string'
? (moderationActionLabels[record.kind] ??
(record.kind === 'ban' ? 'Ban' : record.kind === 'mute' ? 'Mute' : ''))
: '',
typeof record.server === 'string'
? serverTitle(record.server)
: record.scope === 'fleet'
? 'Entire fleet'
: record.scope === 'game'
? 'Game scope'
: ''
]
.filter(Boolean)
.join(' · ');
return [
context,
record.reason,
typeof record.note === 'string' && record.note ? `Private note: ${record.note}` : ''
]
.filter(Boolean)
.join('\n');
} catch {
return detail;
}
}
+15
View File
@@ -16,6 +16,21 @@ export const statusShapes: Record<StatusTone, StatusShape> = {
};
const states: Record<string, [string, StatusTone]> = {
'moderation-current': ['Policy current', 'online'],
'moderation-pending': ['Policy pending', 'warning'],
'moderation-offline': ['Offline', 'danger'],
'moderation-unavailable': ['Unavailable', 'warning'],
'moderation-active': ['Active', 'warning'],
'moderation-revoked': ['Revoked', 'neutral'],
'moderation-open': ['Open', 'info'],
'moderation-resolved': ['Resolved', 'online'],
'moderation-dismissed': ['Dismissed', 'neutral'],
'moderation-queued': ['Queued', 'neutral'],
'moderation-delivered': ['Delivered; awaiting confirmation', 'info'],
'moderation-confirmed': ['Confirmed', 'online'],
'moderation-failed': ['Failed', 'danger'],
'moderation-expired': ['Expired', 'neutral'],
'moderation-unknown': ['Outcome unknown', 'warning'],
provisioning: ['Provisioning', 'info'],
online: ['Online', 'online'],
offline: ['Offline', 'danger'],
+34
View File
@@ -0,0 +1,34 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { authBody, authError, requireUser } from '$lib/server/auth/http.js';
import { moderationOverview, moderationQuery } from '$lib/server/moderation/overview.js';
import { mutateModeration } from '$lib/server/moderation/actions.js';
import { configureModerationServer } from '$lib/server/moderation/configure.js';
import { moderationMutation, textField } from '$lib/server/moderation/core.js';
const headers = { 'Cache-Control': 'no-store' };
export const GET: RequestHandler = ({ locals, url }) => {
try {
return json(
moderationOverview(requireUser(locals.user).id, moderationQuery(url.searchParams)),
{ headers }
);
} catch (cause) {
return authError(cause);
}
};
export const POST: RequestHandler = async ({ locals, request }) => {
try {
const actor = requireUser(locals.user);
const body = await authBody(request, 16 * 1024);
if (body.op === 'configure')
return json(
moderationMutation(actor.id, body, () =>
configureModerationServer(actor.id, textField(body.server, 'Server', 255))
),
{ headers }
);
return json(mutateModeration(actor.id, body), { headers });
} catch (cause) {
return authError(cause);
}
};
@@ -0,0 +1,20 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { authBody, authError } from '$lib/server/auth/http.js';
import { authenticateModerationRuntime } from '$lib/server/moderation/credentials.js';
import { syncModerationRuntime } from '$lib/server/moderation/runtime.js';
export const POST: RequestHandler = async ({ request }) => {
const identity = authenticateModerationRuntime(request);
if (!identity)
return json(
{ error: 'Unauthorized moderation runtime.' },
{ status: 401, headers: { 'Cache-Control': 'no-store' } }
);
try {
return json(syncModerationRuntime(identity, await authBody(request, 256 * 1024)), {
headers: { 'Cache-Control': 'no-store' }
});
} catch (cause) {
return authError(cause);
}
};
@@ -7,6 +7,8 @@
import { onMount } from 'svelte';
import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
import { canReadModeration } from '$lib/auth.js';
import { moderationHref } from '$ui/moderation.js';
import { contextHref } from '$lib/navigation-context.js';
import {
leaderboardMetrics,
@@ -151,7 +153,17 @@
fallback={data.returnTo}
items={[{ label: 'Leaderboard', href: '/leaderboard' }, { label: profile.name }]}
/>{/snippet}
{#snippet actions()}<Freshness
{#snippet actions()}{#if canReadModeration($page.data?.user?.role)}<Button
href={moderationHref({
guid: profile.guid,
game:
scope.game ||
(profile.maps.length &&
profile.maps.every((map) => map.game === profile.maps[0].game)
? profile.maps[0].game
: undefined)
})}>Moderation</Button
>{/if}<Freshness
{updatedAt}
error={refreshError}
intervalLabel="every 30 s"
+1 -1
View File
@@ -183,7 +183,7 @@
{/if}
</section>
<Card padding="lg"><ChatPanel history={match.chat} /></Card>
<Card padding="lg"><ChatPanel history={match.chat} game={match.game} /></Card>
<details class="note">
<summary>About these results</summary>
+7
View File
@@ -0,0 +1,7 @@
import { requireUser } from '$lib/server/auth/http.js';
import { moderationOverview, moderationQuery } from '$lib/server/moderation/overview.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ locals, url }) => ({
moderation: moderationOverview(requireUser(locals.user).id, moderationQuery(url.searchParams))
});
+821
View File
@@ -0,0 +1,821 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { beforeNavigate, goto } from '$app/navigation';
import { page, navigating } from '$app/stores';
import { canModerate } from '$lib/auth.js';
import {
canRevokeModerationRestriction,
type ModerationOverview,
type ModerationQuery,
type ModerationGame
} from '$lib/moderation.js';
import { playerProfileHref } from '$lib/leaderboard.js';
import { contextHref } from '$lib/navigation-context.js';
import {
moderationActionLabels,
moderationGameName,
moderationEventDetail
} from '$ui/moderation.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { pollVisible } from '$ui/poll.js';
import { confirmAction } from '$ui/confirm.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import Freshness from '$ui/components/Freshness.svelte';
import CopyValue from '$ui/components/CopyValue.svelte';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import ModerationDialog from '$ui/components/moderation/ModerationDialog.svelte';
let { data }: { data: { moderation: ModerationOverview } } = $props();
let snapshot = $state<ModerationOverview | null>(null);
const report = $derived(snapshot ?? data.moderation);
const permitted = $derived(canModerate($page.data?.user?.role));
const admin = $derived($page.data?.user?.role === 'admin');
const busy = $derived(!!$navigating);
let filters = $state<ModerationQuery>(untrack(() => ({ ...data.moderation.query })));
let tabView = $state<string>(untrack(() => data.moderation.query.view));
let refreshing = $state(false);
let refreshError = $state('');
let navigationError = $state('');
let notice = $state('');
let configuring = $state('');
const configureKeys = new Map<string, string>();
let updatedAt = $state<number | null>(null);
let controller: AbortController | undefined;
let generation = 0;
let dialogOpen = $state(false);
let dialogKey = $state(0);
let mode = $state<
'restriction' | 'action' | 'case' | 'note' | 'revoke' | 'case-note' | 'resolve'
>('restriction');
let seed = $state<{
game?: ModerationGame;
guid?: string;
server?: string;
id?: string;
revision?: number;
matchId?: number;
message?: string;
}>({});
const tabs = [
{ id: 'players', label: 'Players' },
{ id: 'restrictions', label: 'Restrictions' },
{ id: 'cases', label: 'Cases' },
{ id: 'history', label: 'History' }
];
const currentCoverage = $derived(
report.coverage.filter((item) => item.state === 'current').length
);
$effect(() => {
const initial = data.moderation;
untrack(() => {
snapshot = null;
filters = { ...initial.query };
tabView = initial.query.view;
refreshError = '';
});
});
function cancelRefresh() {
generation++;
controller?.abort();
refreshing = false;
}
beforeNavigate(cancelRefresh);
async function refresh(signal?: AbortSignal) {
if (busy || refreshing) return;
const current = ++generation;
const request = new AbortController();
controller = request;
const abort = () => request.abort();
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) request.abort();
const timeout = setTimeout(abort, 30_000);
refreshing = true;
try {
const response = await fetch(`/api/moderation${$page.url.search}`, {
signal: request.signal
});
if (!response.ok) throw new Error('Refresh failed');
const next: ModerationOverview = await response.json();
if (current !== generation || request.signal.aborted) return;
snapshot = next;
updatedAt = Date.now();
refreshError = '';
} catch {
if (current === generation)
refreshError = 'Moderation updates paused. Showing the last loaded records.';
return false;
} finally {
clearTimeout(timeout);
signal?.removeEventListener('abort', abort);
if (current === generation) refreshing = false;
}
}
onMount(() => {
updatedAt = Date.now();
const stop = pollVisible(refresh, 15_000, false);
return () => {
stop();
cancelRefresh();
};
});
async function navigate(changes: Record<string, string | number | null>, refreshData = false) {
const params = new URLSearchParams($page.url.searchParams);
for (const [key, value] of Object.entries(changes)) {
if (value === null || value === '') params.delete(key);
else params.set(key, String(value));
}
navigationError = '';
try {
await goto(`/moderation?${params}`, {
keepFocus: true,
noScroll: true,
invalidateAll: refreshData
});
} catch {
tabView = report.query.view;
navigationError = 'Could not load this moderation view. Please try again.';
}
}
function openDialog(next: typeof mode, target: typeof seed = {}) {
mode = next;
const matchId = Number($page.url.searchParams.get('matchId'));
seed = {
...(report.query.game ? { game: report.query.game } : {}),
guid: report.query.guid,
server: $page.url.searchParams.get('server') ?? '',
...(next === 'case' && matchId > 0 && $page.url.searchParams.get('message')
? { matchId, message: $page.url.searchParams.get('message')! }
: {}),
...target
};
dialogKey++;
dialogOpen = true;
}
function submitted(target: { game?: ModerationGame; guid?: string | null }) {
notice = 'Saved. Enforcement and delivery status are shown below.';
cancelRefresh();
const view =
mode === 'action' || mode === 'note'
? 'history'
: mode === 'restriction' || mode === 'revoke'
? 'restrictions'
: 'cases';
void navigate(
{
view,
offset: null,
q: null,
matchId: null,
message: null,
...(target.game ? { game: target.game } : {}),
...(target.guid !== undefined ? { guid: target.guid } : {})
},
true
);
}
async function configure(server: string) {
if (configuring) return;
if (
!(await confirmAction({
title: 'Enable moderation integration',
message: `Enable moderation on ${server}? Install the matching moderation scripts and plugins first. Capabilities appear only after the game connects successfully.`,
confirmLabel: 'Enable integration'
}))
)
return;
configuring = server;
navigationError = '';
const idempotencyKey = configureKeys.get(server) ?? crypto.randomUUID();
configureKeys.set(server, idempotencyKey);
try {
const response = await fetch('/api/moderation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ op: 'configure', server, idempotencyKey })
});
const result = await response.json().catch(() => null);
if (!response.ok || !result?.ok)
throw new Error(
result?.error ||
'Could not confirm the integration request. Retry uses the same request.'
);
configureKeys.delete(server);
notice = 'Integration configuration queued. Coverage updates when the game connects.';
cancelRefresh();
void refresh();
} catch (cause) {
navigationError =
cause instanceof Error
? cause.message
: 'Could not confirm the integration request.';
} finally {
configuring = '';
}
}
</script>
<svelte:head><title>Moderation · Electric Cherry</title></svelte:head>
<div class="moderation-page">
<SectionHeader
title="Moderation"
subtitle="Player restrictions, staff cases, and enforcement across your servers."
>
{#snippet actions()}
<Freshness
{updatedAt}
intervalLabel="every 15 s"
error={refreshError ? 'Updates paused' : ''}
/>
<Button onclick={() => refresh()} loading={refreshing}>Refresh</Button>
{#if permitted}<Button variant="primary" onclick={() => openDialog('restriction')}
>Restrict player</Button
><Button onclick={() => openDialog('action')}>Live action</Button>{/if}
{/snippet}
</SectionHeader>
{#if !permitted}<p class="notice">
Read-only moderation access. You can inspect private records and enforcement status.
</p>{/if}
{#if notice}<p class="notice" role="status">{notice}</p>{/if}
{#if refreshError}<div class="ui-error" role="alert">
{refreshError}<Button size="sm" onclick={() => refresh()} loading={refreshing}
>Retry</Button
>
</div>{/if}
{#if navigationError}<p class="ui-error" role="alert">{navigationError}</p>{/if}
<details class="coverage">
<summary
>Enforcement coverage · {currentCoverage} of {report.coverage.length} servers current</summary
>
<p class="hint">
Current means the server acknowledged the latest policy. Pending or unavailable servers
may not apply a new restriction or revocation yet. Cached restrictions continue during
an outage.
</p>
{#if report.coverage.length}
<div class="table-scroll">
<table>
<thead
><tr
><th>Server</th><th>Policy status</th><th>Revision</th><th
>Last connected</th
><th>Capabilities</th>{#if admin}<th>Setup</th>{/if}</tr
></thead
><tbody>
{#each report.coverage as item (item.server)}<tr>
<th
><a href={`/servers/${encodeURIComponent(item.server)}`}
><ServerIdentity name={item.server} /></a
></th
>
<td><StatusBadge state={`moderation-${item.state}`} /></td>
<td>{item.revision ?? '—'} / {item.desiredRevision}</td>
<td
>{#if item.connectedAt}<RelativeTime
value={item.connectedAt}
/>{:else}Not recorded{/if}</td
>
<td
>{item.capabilities.length
? item.capabilities.join(', ')
: 'Not reported'}</td
>
{#if admin}<td
>{#if item.state === 'unavailable'}<Button
size="sm"
disabled={!!configuring}
loading={configuring === item.server}
onclick={() => configure(item.server)}
>Enable integration</Button
>{/if}</td
>{/if}
</tr>{/each}
</tbody>
</table>
</div>
{:else}<p class="hint">No server enforcement reports are available yet.</p>{/if}
</details>
<Tabs
{tabs}
bind:value={tabView}
onchange={(view) => {
void navigate({ view, offset: null });
}}
ariaLabel="Moderation views"
panelId="moderation-results"
idPrefix="moderation-tab"
/>
<form
class="filters"
onsubmit={(event) => {
event.preventDefault();
void navigate({ q: filters.q, game: filters.game, guid: filters.guid, offset: null });
}}
>
<Field label="Search moderation" class="search"
><Input
name="q"
type="search"
bind:value={filters.q}
placeholder="Name, alias, GUID, or reason"
/></Field
>
<Field label="Game"
><Select
name="game"
bind:value={filters.game}
options={[
{ value: '', label: 'All games' },
{ value: 't5', label: 'Black Ops I' },
{ value: 't6', label: 'Black Ops II' }
]}
/></Field
>
<Field label="Player GUID"><Input name="guid" bind:value={filters.guid} mono /></Field>
<Button type="submit" disabled={busy}>Apply</Button>
<Button
disabled={busy}
onclick={() =>
navigate({
q: null,
game: null,
guid: null,
offset: null,
server: null,
message: null,
matchId: null
})}>Reset</Button
>
</form>
{#if $page.url.searchParams.get('matchId') && $page.url.searchParams.get('message')}<p
class="notice"
>
A chat message is selected as case evidence. Review a player with a verified GUID, then
choose Open case. The original message must match that player in the archive.
</p>{/if}
{#if report.query.guid}
<section class="selected-player" aria-label="Selected player">
<div>
<strong>Selected player</strong><CopyValue
value={report.query.guid}
label="Player GUID"
/><a href={contextHref(playerProfileHref(report.query.guid), $page.url)}
>View player profile</a
>
</div>
{#if permitted}<div class="row-actions">
<Button size="sm" onclick={() => openDialog('case')}>Open case</Button><Button
size="sm"
onclick={() => openDialog('note')}>Add private note</Button
>
</div>{/if}
</section>
{/if}
<div
id="moderation-results"
role="tabpanel"
aria-labelledby={`moderation-tab-${report.query.view}`}
aria-busy={busy}
>
{#if report.query.view === 'players'}
{#if report.players.length}<div class="table-scroll">
<table>
<thead
><tr
><th>Player</th><th>Aliases</th><th>Last seen</th><th>Servers</th
><th>Restrictions</th><th>Actions</th></tr
></thead
><tbody>
{#each report.players as player (`${player.game}:${player.guid}`)}<tr>
<th
><a
href={contextHref(
playerProfileHref(player.guid),
$page.url
)}>{player.name}</a
><small>{moderationGameName(player.game)}</small><CopyValue
value={player.guid}
label="Player GUID"
/></th
>
<td>{player.aliases.join(', ') || 'None recorded'}</td>
<td
><RelativeTime
value={player.lastSeen}
missing="Not recorded"
/></td
>
<td
>{#each player.servers as server}<div>
<ServerIdentity name={server} />
</div>{:else}Not currently observed{/each}</td
>
<td>{player.restrictions}</td>
<td
><div class="row-actions">
<Button
size="sm"
onclick={() =>
navigate({
game: player.game,
guid: player.guid,
q: null,
offset: null,
server: null,
message:
$page.url.searchParams.get('message'),
matchId:
$page.url.searchParams.get('matchId')
})}>Review player</Button
>{#if permitted}<Button
size="sm"
onclick={() =>
openDialog('restriction', {
game: player.game,
guid: player.guid,
server: player.servers[0] ?? ''
})}>Restrict</Button
>{/if}
</div></td
>
</tr>{/each}
</tbody>
</table>
</div>{:else}<EmptyState
title="No players found"
description="Search another name or GUID. You can restrict a recorded identity even when the player is offline."
/>{/if}
{:else if report.query.view === 'restrictions'}
{#if report.sanctions.length}<div class="record-list">
{#each report.sanctions as sanction (sanction.id)}<article class="record">
<div class="record-heading">
<h2>
{sanction.kind === 'ban' ? 'Ban' : 'Mute'} ·
<a
href={contextHref(
playerProfileHref(sanction.guid),
$page.url
)}>{sanction.guid}</a
>
</h2>
<StatusBadge
state={sanction.revokedAt
? 'moderation-revoked'
: sanction.active
? 'moderation-active'
: 'expired'}
/>
</div>
<p>{sanction.reason}</p>
<dl>
<div>
<dt>Scope</dt>
<dd>
{#if sanction.server}<ServerIdentity
name={sanction.server}
/>{:else}{sanction.scope === 'fleet'
? 'Entire fleet'
: moderationGameName(sanction.game)}{/if}
</dd>
</div>
<div>
<dt>Expires</dt>
<dd>
{sanction.expiresAt
? $timeFormat.dateTime(sanction.expiresAt)
: 'Permanent'}
</dd>
</div>
<div>
<dt>Created by</dt>
<dd>
{sanction.actor} · <RelativeTime
value={sanction.createdAt}
/>
</dd>
</div>
</dl>
{#if sanction.note}<p class="private-note">
<strong>Private note</strong>
{sanction.note}
</p>{/if}
<div class="record-heading">
<CopyValue
value={sanction.id}
label="Restriction ID"
/>{#if sanction.active && canRevokeModerationRestriction($page.data?.user, sanction)}<Button
size="sm"
onclick={() =>
openDialog('revoke', {
id: sanction.id,
revision: sanction.revision
})}>Revoke restriction</Button
>{/if}
</div>
</article>{/each}
</div>{:else}<EmptyState
title="No restrictions found"
description="Restrictions matching these filters will appear here, including expiry and revocation history."
/>{/if}
{:else if report.query.view === 'cases'}
{#if permitted}<div class="view-actions">
<Button onclick={() => openDialog('case')}>Open case</Button>
</div>{/if}
{#if report.cases.length}<div class="record-list">
{#each report.cases as item (item.id)}<article class="record">
<div class="record-heading">
<h2>{item.title}</h2>
<StatusBadge state={`moderation-${item.status}`} />
</div>
<p>
<a href={contextHref(playerProfileHref(item.guid), $page.url)}
>{item.guid}</a
>
· {moderationGameName(item.game)} · Opened by {item.actor} · <RelativeTime
value={item.createdAt}
/>
</p>
<details>
<summary>Evidence and staff notes ({item.evidence.length})</summary>
<ol class="evidence-list">
{#each item.evidence as evidence (evidence.id)}<li>
<p>{evidence.text}</p>
<small
>{evidence.source} · {evidence.actor} · <RelativeTime
value={evidence.createdAt}
/></small
>
</li>{:else}<li>No evidence or notes recorded.</li>{/each}
</ol>
</details>
<div class="record-heading">
<CopyValue value={item.id} label="Case ID" />{#if permitted}<div
class="row-actions"
>
<Button
size="sm"
onclick={() => openDialog('case-note', { id: item.id })}
>Add case note</Button
>{#if item.status === 'open'}<Button
size="sm"
onclick={() =>
openDialog('resolve', {
id: item.id,
revision: item.revision
})}>Review case</Button
>{/if}
</div>{/if}
</div>
</article>{/each}
</div>{:else}<EmptyState
title="No cases found"
description="Open a case to preserve evidence, record an appeal, and document a staff decision."
/>{/if}
{:else}
<h2>Recent live actions</h2>
<p class="hint">
Queued and delivered actions are awaiting confirmation. An unknown outcome requires
review before issuing another action.
</p>
{#if report.actions.length}<div class="table-scroll">
<table>
<thead
><tr
><th>Action</th><th>Target</th><th>Result</th><th
>Reason / message</th
><th>Moderator</th><th>Requested</th></tr
></thead
><tbody>
{#each report.actions as action (action.id)}<tr
><th>{moderationActionLabels[action.kind]}</th><td
>{action.guid ?? 'All players'}
<div><ServerIdentity name={action.server} /></div></td
><td
><StatusBadge
state={`moderation-${action.state}`}
/>{#if action.detail}<small>{action.detail}</small>{/if}</td
><td>{action.reason}</td><td>{action.actor}</td><td
><RelativeTime value={action.createdAt} /></td
></tr
>{/each}
</tbody>
</table>
</div>{:else}<p class="hint">No recent live actions match these filters.</p>{/if}
<h2>Audit history</h2>
{#if report.events.length}<ol class="record-list audit-list">
{#each report.events as event (event.id)}<li class="record">
<div class="record-heading">
<strong
>{moderationActionLabels[event.action] ?? event.action}</strong
><RelativeTime value={event.createdAt} />
</div>
<p>{moderationEventDetail(event.detail, event.action)}</p>
<small
>{event.actor}{event.guid
? ` · Player ${event.guid}`
: ''}{event.game
? ` · ${moderationGameName(event.game)}`
: ''}</small
>{#if event.subjectId}<CopyValue
value={event.subjectId}
label="Record ID"
/>{/if}
</li>{/each}
</ol>{:else}<EmptyState
title="No history found"
description="Moderation decisions and private notes matching these filters will appear here."
/>{/if}
{/if}
<nav class="pagination" aria-label="Moderation results pagination">
<span role="status"
>{report.total ? report.query.offset + 1 : 0}–{Math.min(
report.query.offset + report.limit,
report.total
)} of {report.total.toLocaleString()} results</span
>
{#if report.total > report.limit || report.query.offset > 0}<div class="row-actions">
<Button
size="sm"
disabled={busy || report.query.offset === 0}
onclick={() =>
navigate({ offset: Math.max(0, report.query.offset - report.limit) })}
>Previous</Button
><Button
size="sm"
disabled={busy || report.query.offset + report.limit >= report.total}
onclick={() => navigate({ offset: report.query.offset + report.limit })}
>Next</Button
>
</div>{/if}
</nav>
</div>
</div>
{#if permitted}{#key dialogKey}<ModerationDialog
bind:open={dialogOpen}
{mode}
{seed}
{admin}
coverage={report.coverage}
onsubmitted={submitted}
/>{/key}{/if}
<style>
.moderation-page,
.record-list,
.record {
display: grid;
gap: var(--space-5);
min-width: 0;
}
.filters,
.row-actions,
.record-heading,
.selected-player,
.pagination {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
.filters {
align-items: flex-end;
}
.filters :global(.search) {
flex: 1 1 240px;
}
.record-heading,
.selected-player,
.pagination {
justify-content: space-between;
}
.coverage,
.selected-player,
.record {
padding: var(--space-4);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface);
}
.record {
gap: var(--space-3);
overflow-wrap: anywhere;
}
.table-scroll {
width: 100%;
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: var(--text-sm);
}
th,
td {
padding: var(--space-3);
border-bottom: 1px solid var(--border-subtle);
vertical-align: top;
}
thead th {
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
}
tbody th {
font-weight: var(--weight-medium);
}
small,
.hint,
.notice,
.pagination {
color: var(--text-secondary);
font-size: var(--text-sm);
}
td small,
th small {
display: block;
margin-top: var(--space-2);
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
p {
margin: 0;
line-height: var(--leading-body);
white-space: pre-wrap;
}
.hint {
margin-block: var(--space-3);
}
.notice {
padding: var(--space-3);
border-left: 3px solid var(--border-strong);
}
dl {
display: flex;
flex-wrap: wrap;
gap: var(--space-3) var(--space-6);
margin: 0;
font-size: var(--text-sm);
}
dt {
color: var(--text-secondary);
font-size: var(--text-xs);
margin-bottom: var(--space-1);
}
dd {
margin: 0;
}
summary {
cursor: pointer;
color: var(--text-secondary);
font-size: var(--text-sm);
}
.private-note {
font-size: var(--text-sm);
}
.evidence-list {
padding-left: var(--space-5);
}
.evidence-list li {
margin-top: var(--space-4);
}
.evidence-list small {
font-size: var(--text-xs);
}
.audit-list {
list-style: none;
padding: 0;
}
.view-actions {
margin-bottom: var(--space-4);
}
.pagination {
margin-top: var(--space-4);
}
#moderation-results > h2 {
margin-top: var(--space-5);
}
@media (max-width: 640px) {
.filters {
align-items: stretch;
}
.filters :global(.field) {
flex: 1 1 100%;
}
}
</style>