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 ) { 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(); }