314 lines
14 KiB
TypeScript
314 lines
14 KiB
TypeScript
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();
|
||
}
|