This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user