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
+9 -2
View File
@@ -1,4 +1,4 @@
import type { UserRole } from '../../auth.js';
import { canModerate, canReadModeration, type UserRole } from '../../auth.js';
export type RoutePermission =
| 'public'
@@ -7,6 +7,8 @@ export type RoutePermission =
| 'viewer'
| 'operator-read'
| 'admin-read'
| 'moderation-read'
| 'moderation'
| 'operator'
| 'admin';
@@ -53,6 +55,9 @@ export function routePermission(routeId: string | null, method: string): RoutePe
if (routeId === '/api/settings/retention/preview' && method === 'POST') return 'admin-read';
if (within(routeId, '/api/auth')) return 'admin';
if (within(routeId, '/moderation') || within(routeId, '/api/moderation'))
return reading ? 'moderation-read' : 'moderation';
if (
within(routeId, '/api/users') ||
within(routeId, '/settings') ||
@@ -94,11 +99,13 @@ export function routePermission(routeId: string | null, method: string): RoutePe
/** A browser role never substitutes for a machine credential. */
export function roleAllows(role: UserRole, permission: RoutePermission): boolean {
if (!['viewer', 'super_viewer', 'operator', 'admin'].includes(role)) return false;
if (!['viewer', 'super_viewer', 'operator', 'moderator', 'admin'].includes(role)) return false;
if (permission === 'machine') return false;
if (permission === 'public' || permission === 'authenticated' || permission === 'viewer')
return true;
if (permission === 'operator') return role === 'operator' || role === 'admin';
if (permission === 'moderation') return canModerate(role);
if (permission === 'moderation-read') return canReadModeration(role);
if (permission === 'operator-read')
return role === 'super_viewer' || role === 'operator' || role === 'admin';
if (permission === 'admin-read') return role === 'super_viewer' || role === 'admin';
+7 -3
View File
@@ -167,9 +167,13 @@ export const authenticationHandle: Handle = async ({ event, resolve }) => {
? 'Administrator access is required.'
: permission === 'admin-read'
? 'Administrator or Super Viewer access is required.'
: permission === 'operator-read'
? 'Operator, Administrator or Super Viewer access is required.'
: 'Operator access is required for this action.';
: permission === 'moderation'
? 'Moderator or Administrator access is required.'
: permission === 'moderation-read'
? 'Moderator, Administrator or Super Viewer access is required.'
: permission === 'operator-read'
? 'Operator, Administrator or Super Viewer access is required.'
: 'Operator access is required for this action.';
if (api)
return json(
{ error: message },
+2 -2
View File
@@ -82,8 +82,8 @@ function displayName(value: unknown, fallback: string): string {
return name.trim();
}
function role(value: unknown): UserRole {
if (!['admin', 'super_viewer', 'operator', 'viewer'].includes(value as string))
throw new AuthError('Choose administrator, super viewer, operator or viewer.');
if (!['admin', 'super_viewer', 'operator', 'moderator', 'viewer'].includes(value as string))
throw new AuthError('Choose administrator, super viewer, operator, moderator or viewer.');
return value as UserRole;
}
export function recordAudit(
+29
View File
@@ -61,3 +61,32 @@ export function migrateSuperViewerRole(handle: Database.Database): void {
ALTER TABLE auth_users_super_viewer RENAME TO auth_users;
`);
}
/** Run with foreign keys disabled by the migration runner before its transaction. */
export function migrateModeratorRole(handle: Database.Database): void {
const schema = handle
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='auth_users'")
.get() as { sql: string };
if (schema.sql.includes("'moderator'")) return;
if (handle.pragma('foreign_keys', { simple: true }))
throw new Error('The auth role migration requires foreign keys to be disabled');
// Rebuild without renaming the parent: existing session references must stay
// attached to auth_users, including remembered sessions added after v17.
handle.exec(`
CREATE TABLE auth_users_moderator (
id TEXT PRIMARY KEY, username TEXT NOT NULL COLLATE NOCASE UNIQUE,
display_name TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin','super_viewer','operator','moderator','viewer')),
disabled INTEGER NOT NULL DEFAULT 0, must_change_password INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER
);
INSERT INTO auth_users_moderator (
id,username,display_name,password_hash,role,disabled,must_change_password,
created_at,updated_at,last_login_at
) SELECT id,username,display_name,password_hash,role,disabled,must_change_password,
created_at,updated_at,last_login_at FROM auth_users;
DROP TABLE auth_users;
ALTER TABLE auth_users_moderator RENAME TO auth_users;
`);
}
+11 -2
View File
@@ -17,7 +17,8 @@ import { migrateHostSchema } from '../hosts/schema.js';
import {
migrateAuthSchema,
migrateRememberedSessions,
migrateSuperViewerRole
migrateSuperViewerRole,
migrateModeratorRole
} from '../auth/schema.js';
import { migrateGameFileSchema } from '../game-files/schema.js';
import { migrateLeaderboardReports } from './leaderboard-reports.js';
@@ -25,6 +26,7 @@ import { migratePlayerScoreTotal } from './player-score-total.js';
import { migrateBank } from './bank.js';
import { migrateBankCredentials } from './bank-credentials.js';
import { migrateTemperatures } from '../hosts/temperatures.js';
import { migrateModeration } from '../moderation/schema.js';
import type { Migration } from './migration-runner.js';
// Append only: never renumber entries or change an applied migration to add an
@@ -134,7 +136,14 @@ export const migrations: readonly Migration[] = [
'ALTER TABLE native_instances ADD COLUMN generation_reconciled_at TEXT'
);
}
}
},
{
version: 25,
name: 'moderator authentication role',
mode: 'foreign-keys-off',
run: migrateModeratorRole
},
{ version: 26, name: 'player moderation records and enforcement', run: migrateModeration }
];
export const databaseVersion = migrations.length;
+8
View File
@@ -3,6 +3,7 @@ import type { GameTelemetry, ServerTiming } from '../../telemetry.js';
import { MIN_RECORDED_ROUND } from '../../match-history.js';
import { recordLeaderboardTelemetry } from './leaderboard-ingest.js';
import { latestGameSubmission } from './status-evidence.js';
import { observeModerationPlayers } from '../moderation/core.js';
export function listTelemetry(): GameTelemetry[] {
const rows = db()
@@ -33,6 +34,13 @@ export function saveTelemetryWithOutcome(report: GameTelemetry): {
? JSON.parse(row.payload)
: undefined;
const recorded = recordLeaderboardTelemetry(handle, report);
const game = (
handle
.prepare('SELECT game FROM leaderboard_matches WHERE id=?')
.get(recorded.matchId) as { game: string }
).game;
if (game === 't5' || game === 't6')
observeModerationPlayers(handle, game, report.players, report.receivedAt);
if (!recorded.current) {
return {
telemetry: { ...report, timing: { matchId: recorded.matchId, players: {} } },
+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);
}
}