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