Add moderation permissions and durable player records

This commit is contained in:
2026-10-15 21:06:08 -04:00
parent c1758cce25
commit c91d49c298
17 changed files with 699 additions and 21 deletions
+129
View File
@@ -0,0 +1,129 @@
import { createHash } from 'node:crypto';
import type Database from 'better-sqlite3';
import { db } from '../db/index.js';
import { getUser, AuthError } from '../auth/index.js';
import { canModerate, canReadModeration, type AuthUser } from '../../auth.js';
import { moderationGuid, type ModerationGame } from '../../moderation.js';
export const moderationNow = () => new Date().toISOString();
export function moderationActor(id: string, write = false): AuthUser {
const user = getUser(id);
if (
!user ||
user.disabled ||
user.mustChangePassword ||
!(write ? canModerate(user.role) : canReadModeration(user.role))
)
throw new AuthError('Moderation access is required.', 403);
return user;
}
export function textField(value: unknown, label: string, maximum = 500, optional = false): string {
if (optional && (value === undefined || value === null || value === '')) return '';
if (
typeof value !== 'string' ||
!value.trim() ||
value.length > maximum ||
// eslint-disable-next-line no-control-regex -- Bound all user-controlled moderation text.
/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value)
)
throw new AuthError(`${label} must contain 1–${maximum} printable characters.`);
return value.trim();
}
export function subject(input: Record<string, unknown>): { game: ModerationGame; guid: string } {
if (input.game !== 't5' && input.game !== 't6')
throw new AuthError('Choose Black Ops I or Black Ops II.');
const raw = textField(input.guid, 'Player GUID', 128);
if (!/^[A-Za-z0-9:_-]+$/.test(raw)) throw new AuthError('Invalid player GUID.');
return { game: input.game, guid: moderationGuid(input.game, raw) };
}
export function observeModerationPlayers(
handle: Database.Database,
game: ModerationGame,
players: { guid: string; name: string }[],
now: string
): 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)`);
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) {
const values = [game, moderationGuid(game, player.guid), player.name, now, now];
upsert.run(...values);
aliases.run(...values);
}
}
export function ensureSubject(game: ModerationGame, guid: string, now: string): void {
db()
.prepare('INSERT INTO moderation_players VALUES(?,?,?,?,?) ON CONFLICT DO NOTHING')
.run(game, guid, guid, now, now);
}
export function moderationEvent(
actor: Pick<AuthUser, 'id' | 'username'> | null,
action: string,
target: { game?: string | null; guid?: string | null },
subjectId: string | null,
detail: string,
now = moderationNow()
): void {
db()
.prepare(
`INSERT INTO moderation_events(game,guid,actor_id,actor,action,subject_id,detail,created_at) VALUES(?,?,?,?,?,?,?,?)`
)
.run(
target.game ?? null,
target.guid ?? null,
actor?.id ?? null,
actor?.username ?? 'Game server',
action,
subjectId,
detail,
now
);
}
export function policyRevision(): number {
return (
db().prepare('SELECT revision FROM moderation_policy WHERE id=1').get() as {
revision: number;
}
).revision;
}
export function advancePolicy(): void {
db().prepare('UPDATE moderation_policy SET revision=revision+1 WHERE id=1').run();
}
/** The request and its durable result commit atomically, including repeated browser submissions. */
export function moderationMutation(
actorId: string,
input: Record<string, unknown>,
work: (actor: AuthUser, now: string) => { id: string }
): { ok: true; id: string } {
const key = textField(input.idempotencyKey, 'Request identifier', 128);
const canonical = JSON.stringify(
Object.fromEntries(Object.entries(input).sort(([a], [b]) => a.localeCompare(b)))
);
const fingerprint = createHash('sha256').update(canonical).digest('hex');
return db()
.transaction(() => {
const actor = moderationActor(actorId, true);
const prior = db()
.prepare(
'SELECT fingerprint,result FROM moderation_requests WHERE actor_id=? AND request_key=?'
)
.get(actor.id, key) as { fingerprint: string; result: string } | undefined;
if (prior) {
if (prior.fingerprint !== fingerprint)
throw new AuthError(
'This request identifier was already used for a different action.',
409
);
return JSON.parse(prior.result) as { ok: true; id: string };
}
const now = moderationNow();
const result = { ok: true as const, ...work(actor, now) };
db()
.prepare('INSERT INTO moderation_requests VALUES(?,?,?,?,?)')
.run(actor.id, key, fingerprint, JSON.stringify(result), now);
return result;
})
.immediate();
}
+101
View File
@@ -0,0 +1,101 @@
import type Database from 'better-sqlite3';
import { moderationGuid, type ModerationGame } from '../../moderation.js';
export function migrateModeration(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS moderation_players (
game TEXT NOT NULL CHECK(game IN ('t5','t6')), guid TEXT NOT NULL,
name TEXT NOT NULL, first_seen_at TEXT NOT NULL, last_seen_at TEXT NOT NULL,
PRIMARY KEY(game,guid)
);
CREATE TABLE IF NOT EXISTS moderation_aliases (
game TEXT NOT NULL, guid TEXT NOT NULL, name TEXT NOT NULL,
first_seen_at TEXT NOT NULL, last_seen_at TEXT NOT NULL,
PRIMARY KEY(game,guid,name)
);
CREATE INDEX IF NOT EXISTS moderation_alias_name ON moderation_aliases(name COLLATE NOCASE);
CREATE TABLE IF NOT EXISTS moderation_policy (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL);
INSERT OR IGNORE INTO moderation_policy VALUES(1,1);
CREATE TABLE IF NOT EXISTS moderation_sanctions (
id TEXT PRIMARY KEY, game TEXT NOT NULL CHECK(game IN ('t5','t6')), guid TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('ban','mute')),
scope TEXT NOT NULL CHECK(scope IN ('server','game','fleet')),
server TEXT, server_created_at TEXT, reason TEXT NOT NULL, note TEXT NOT NULL,
actor_id TEXT NOT NULL, actor TEXT NOT NULL,
created_at TEXT NOT NULL, expires_at TEXT, revoked_at TEXT,
revision INTEGER NOT NULL DEFAULT 1,
CHECK((scope='server' AND server IS NOT NULL AND server_created_at IS NOT NULL)
OR (scope!='server' AND server IS NULL AND server_created_at IS NULL))
);
CREATE INDEX IF NOT EXISTS moderation_sanction_subject ON moderation_sanctions(guid,game,revoked_at,expires_at);
CREATE TABLE IF NOT EXISTS moderation_actions (
id TEXT PRIMARY KEY, game TEXT NOT NULL, guid TEXT, server TEXT NOT NULL,
instance_id TEXT NOT NULL, generation INTEGER NOT NULL,
session TEXT NOT NULL, connection TEXT,
kind TEXT NOT NULL CHECK(kind IN ('warn','message','kick','announce')),
reason TEXT NOT NULL, actor_id TEXT NOT NULL, actor TEXT NOT NULL,
created_at TEXT NOT NULL, expires_at TEXT NOT NULL,
state TEXT NOT NULL CHECK(state IN ('queued','delivered','confirmed','failed','expired','unknown')),
detail TEXT, delivered_at TEXT, finished_at TEXT
);
CREATE INDEX IF NOT EXISTS moderation_action_delivery ON moderation_actions(instance_id,generation,state,expires_at);
CREATE TABLE IF NOT EXISTS moderation_cases (
id TEXT PRIMARY KEY, game TEXT NOT NULL, guid TEXT NOT NULL, title TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('open','resolved','dismissed')),
actor_id TEXT NOT NULL, actor TEXT NOT NULL,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS moderation_evidence (
id TEXT PRIMARY KEY, case_id TEXT NOT NULL REFERENCES moderation_cases(id),
text TEXT NOT NULL, source TEXT NOT NULL, snapshot TEXT,
actor_id TEXT NOT NULL, actor TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS moderation_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, game TEXT, guid TEXT,
actor_id TEXT, actor TEXT NOT NULL, action TEXT NOT NULL,
subject_id TEXT, detail TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS moderation_event_subject ON moderation_events(game,guid,id);
CREATE TABLE IF NOT EXISTS moderation_requests (
actor_id TEXT NOT NULL, request_key TEXT NOT NULL,
fingerprint TEXT NOT NULL, result TEXT NOT NULL, created_at TEXT NOT NULL,
PRIMARY KEY(actor_id,request_key)
);
CREATE TABLE IF NOT EXISTS moderation_credentials (
instance_id TEXT NOT NULL, generation INTEGER NOT NULL,
token TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL,
PRIMARY KEY(instance_id,generation)
);
CREATE TABLE IF NOT EXISTS moderation_runtimes (
instance_id TEXT PRIMARY KEY, generation INTEGER NOT NULL,
session TEXT NOT NULL, capabilities TEXT NOT NULL, players TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 0, connected_at TEXT NOT NULL
);
`);
const player = 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)`);
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<{
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);
}
}