diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 781868d..581ee95 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,4 +1,6 @@ -export type UserRole = 'admin' | 'super_viewer' | 'operator' | 'viewer'; +export type UserRole = 'admin' | 'super_viewer' | 'operator' | 'moderator' | 'viewer'; + +export const moderatorMaximumRestrictionMs = 7 * 24 * 60 * 60 * 1000; /** Reserved identity for work requested by the controller. */ export const systemUsername = 'Maxis'; @@ -19,6 +21,7 @@ export const roleLabels: Record = { admin: 'Administrator', super_viewer: 'Super Viewer', operator: 'Operator', + moderator: 'Moderator', viewer: 'Viewer' }; @@ -33,3 +36,11 @@ export function canReadAdmin(role: UserRole | null | undefined): boolean { export function canReadSensitive(role: UserRole | null | undefined): boolean { return canOperate(role) || role === 'super_viewer'; } + +export function canModerate(role: UserRole | null | undefined): boolean { + return role === 'admin' || role === 'moderator'; +} + +export function canReadModeration(role: UserRole | null | undefined): boolean { + return canModerate(role) || role === 'super_viewer'; +} diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts new file mode 100644 index 0000000..d85f051 --- /dev/null +++ b/src/lib/moderation.ts @@ -0,0 +1,109 @@ +export type ModerationGame = 't5' | 't6'; +export type ModerationScope = 'server' | 'game' | 'fleet'; +export type ModerationActionKind = 'warn' | 'message' | 'kick' | 'announce'; +export type ModerationState = + 'queued' | 'delivered' | 'confirmed' | 'failed' | 'expired' | 'unknown'; + +export interface ModerationPlayer { + game: ModerationGame; + guid: string; + name: string; + aliases: string[]; + firstSeen: string; + lastSeen: string; + servers: string[]; + restrictions: number; +} +export interface ModerationSanction { + id: string; + game: ModerationGame; + guid: string; + kind: 'ban' | 'mute'; + scope: ModerationScope; + server: string | null; + reason: string; + note: string; + createdAt: string; + expiresAt: string | null; + revokedAt: string | null; + actorId: string; + actor: string; + revision: number; + active: boolean; +} +export interface ModerationAction { + id: string; + game: ModerationGame; + guid: string | null; + server: string; + kind: ModerationActionKind; + reason: string; + actor: string; + createdAt: string; + expiresAt: string; + state: ModerationState; + detail: string | null; +} +export interface ModerationCase { + id: string; + game: ModerationGame; + guid: string; + title: string; + status: 'open' | 'resolved' | 'dismissed'; + actor: string; + createdAt: string; + updatedAt: string; + revision: number; + evidence: { id: string; text: string; source: string; createdAt: string; actor: string }[]; +} +export interface ModerationEvent { + id: number; + game: ModerationGame | null; + guid: string | null; + action: string; + subjectId: string | null; + actor: string; + detail: string; + createdAt: string; +} +export interface ModerationCoverage { + server: string; + game: ModerationGame; + connectedAt: string | null; + revision: number | null; + desiredRevision: number; + state: 'current' | 'pending' | 'offline' | 'unavailable'; + capabilities: string[]; +} +export interface ModerationQuery { + view: 'players' | 'restrictions' | 'cases' | 'history'; + q: string; + game: '' | ModerationGame; + guid: string; + offset: number; +} +export interface ModerationOverview { + query: ModerationQuery; + players: ModerationPlayer[]; + sanctions: ModerationSanction[]; + cases: ModerationCase[]; + events: ModerationEvent[]; + actions: ModerationAction[]; + coverage: ModerationCoverage[]; + total: number; + limit: number; + checkedAt: string; +} + +/** RCON T6 IDs are hexadecimal, while the game scripts send decimal strings. */ +export function moderationGuid( + game: ModerationGame, + guid: string, + source: 'game' | 'rcon' = 'game' +): string { + const value = guid.trim(); + if (game === 't6' && source === 'rcon' && /^(?:0x)?[0-9a-f]+$/i.test(value)) + return BigInt(`0x${value.replace(/^0x/i, '')}`).toString(); + if (/^\d+$/.test(value)) return BigInt(value).toString(); + return value; +} diff --git a/src/lib/server/auth/access.ts b/src/lib/server/auth/access.ts index a3803ab..0e3b4d7 100644 --- a/src/lib/server/auth/access.ts +++ b/src/lib/server/auth/access.ts @@ -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'; diff --git a/src/lib/server/auth/guard.ts b/src/lib/server/auth/guard.ts index 81fa984..9670ae0 100644 --- a/src/lib/server/auth/guard.ts +++ b/src/lib/server/auth/guard.ts @@ -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 }, diff --git a/src/lib/server/auth/index.ts b/src/lib/server/auth/index.ts index fe08d88..e22f31f 100644 --- a/src/lib/server/auth/index.ts +++ b/src/lib/server/auth/index.ts @@ -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( diff --git a/src/lib/server/auth/schema.ts b/src/lib/server/auth/schema.ts index 46c3446..7c6c412 100644 --- a/src/lib/server/auth/schema.ts +++ b/src/lib/server/auth/schema.ts @@ -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; + `); +} diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index 1d3cdc3..c92b50e 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -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; diff --git a/src/lib/server/fleet/telemetry.ts b/src/lib/server/fleet/telemetry.ts index 3570ae0..31f2df0 100644 --- a/src/lib/server/fleet/telemetry.ts +++ b/src/lib/server/fleet/telemetry.ts @@ -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: {} } }, diff --git a/src/lib/server/moderation/core.ts b/src/lib/server/moderation/core.ts new file mode 100644 index 0000000..70446af --- /dev/null +++ b/src/lib/server/moderation/core.ts @@ -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): { 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 | 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, + 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(); +} diff --git a/src/lib/server/moderation/schema.ts b/src/lib/server/moderation/schema.ts new file mode 100644 index 0000000..08097c6 --- /dev/null +++ b/src/lib/server/moderation/schema.ts @@ -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); + } +} diff --git a/src/routes/settings/users/+page.svelte b/src/routes/settings/users/+page.svelte index dd02806..8322e57 100644 --- a/src/routes/settings/users/+page.svelte +++ b/src/routes/settings/users/+page.svelte @@ -55,6 +55,11 @@ label: 'Operator', description: 'Operate servers, campaigns, scripts, and configs.' }, + { + value: 'moderator', + label: 'Moderator', + description: 'Moderate players with server or game restrictions lasting up to 7 days.' + }, { value: 'admin', label: 'Administrator', diff --git a/tests/unit/auth-access.test.ts b/tests/unit/auth-access.test.ts index 5efbf02..6bc22d1 100644 --- a/tests/unit/auth-access.test.ts +++ b/tests/unit/auth-access.test.ts @@ -1,18 +1,61 @@ import { describe, expect, it } from 'vitest'; import { readdirSync } from 'node:fs'; import { relative, resolve } from 'node:path'; -import type { UserRole } from '../../src/lib/auth.js'; +import { + canModerate, + canReadModeration, + moderatorMaximumRestrictionMs, + type UserRole +} from '../../src/lib/auth.js'; import { roleAllows, routePermission, type RoutePermission } from '../../src/lib/server/auth/access.js'; -const roles: UserRole[] = ['viewer', 'super_viewer', 'operator', 'admin']; +const roles: UserRole[] = ['viewer', 'super_viewer', 'operator', 'moderator', 'admin']; const allows = (role: UserRole, route: string | null, method = 'GET') => roleAllows(role, routePermission(route, method)); describe('management route permissions', () => { + it.each([ + '/moderation', + '/moderation/players/[guid]', + '/api/moderation', + '/api/moderation/players/[guid]', + '/api/moderation/sanctions/[id]/revoke' + ])('limits private moderation access at %s without changing fleet privileges', (route) => { + for (const method of ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) { + const reading = ['GET', 'HEAD'].includes(method); + expect(routePermission(route, method)).toBe(reading ? 'moderation-read' : 'moderation'); + for (const role of roles) { + expect(allows(role, route, method)).toBe( + role === 'admin' || role === 'moderator' || (reading && role === 'super_viewer') + ); + } + } + }); + + it('keeps moderators within safe summary reads and explicit moderation operations', () => { + expect(allows('moderator', '/api/fleet')).toBe(true); + expect(allows('moderator', '/api/servers/[name]/chat')).toBe(true); + expect(allows('moderator', '/api/servers', 'POST')).toBe(false); + expect(allows('moderator', '/api/users')).toBe(false); + expect(allows('moderator', '/api/configs/[id]')).toBe(false); + expect(allows('moderator', '/api/drift')).toBe(false); + expect(allows('moderator', '/api/moderation-extra', 'POST')).toBe(false); + expect(allows('moderator', '/api/auth/password', 'POST')).toBe(true); + for (const role of roles) { + expect(canModerate(role)).toBe(role === 'moderator' || role === 'admin'); + expect(canReadModeration(role)).toBe( + role === 'moderator' || role === 'admin' || role === 'super_viewer' + ); + } + expect(canModerate(null)).toBe(false); + expect(canReadModeration(undefined)).toBe(false); + expect(moderatorMaximumRestrictionMs).toBe(604_800_000); + }); + it('denies Super Viewer writes across every registered management route', () => { const routes = resolve('src/routes'); const entries = readdirSync(routes, { recursive: true, withFileTypes: true }); @@ -285,6 +328,8 @@ describe('authentication and machine boundaries', () => { 'operator', 'operator-read', 'admin-read', + 'moderation', + 'moderation-read', 'admin', 'machine' ] as RoutePermission[]) { diff --git a/tests/unit/auth-guard.test.ts b/tests/unit/auth-guard.test.ts index 59d0740..b0d02b0 100644 --- a/tests/unit/auth-guard.test.ts +++ b/tests/unit/auth-guard.test.ts @@ -235,6 +235,18 @@ describe('browser authentication guard', () => { ['operator', '/api/users', 'GET', 403], ['admin', '/api/hosts/[id]', 'PATCH', 200], ['admin', '/api/users', 'GET', 200], + ['moderator', '/api/moderation', 'GET', 200], + ['moderator', '/api/moderation/sanctions', 'POST', 200], + ['moderator', '/api/moderation/sanctions/[id]/revoke', 'POST', 200], + ['admin', '/api/moderation/sanctions', 'POST', 200], + ['super_viewer', '/api/moderation', 'HEAD', 200], + ['super_viewer', '/api/moderation/sanctions', 'POST', 403], + ['operator', '/api/moderation', 'GET', 403], + ['operator', '/api/moderation/sanctions', 'POST', 403], + ['viewer', '/api/moderation', 'GET', 403], + ['moderator', '/api/servers', 'POST', 403], + ['moderator', '/api/configs/[id]', 'GET', 403], + ['moderator', '/api/users', 'GET', 403], ['super_viewer', '/api/users', 'GET', 200], ['super_viewer', '/api/users/audit', 'GET', 200], ['super_viewer', '/api/server-keys', 'HEAD', 200], @@ -278,6 +290,21 @@ describe('browser authentication guard', () => { } } ); + it('protects moderation pages and Svelte page-data requests with the same role checks', async () => { + for (const role of ['viewer', 'super_viewer', 'operator', 'moderator', 'admin'] as const) { + signIn(role); + for (const path of ['/moderation', '/moderation/__data.json']) { + const call = requestFor('/moderation', { path }); + if (role === 'viewer' || role === 'operator') { + await expect(call.invoke()).rejects.toMatchObject({ status: 403 }); + expect(call.resolve).not.toHaveBeenCalled(); + } else { + expect((await call.invoke()).status).toBe(200); + expect(call.resolve).toHaveBeenCalledOnce(); + } + } + } + }); it.each(['GET', 'HEAD', 'POST', 'PATCH', 'DELETE'])( 'permits only administrators to write settings and Super Viewers to read them', async (method) => { diff --git a/tests/unit/auth-schema.test.ts b/tests/unit/auth-schema.test.ts index 26b1697..a30c615 100644 --- a/tests/unit/auth-schema.test.ts +++ b/tests/unit/auth-schema.test.ts @@ -1,6 +1,7 @@ import Database from 'better-sqlite3'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + migrateModeratorRole, migrateRememberedSessions, migrateSuperViewerRole } from '../../src/lib/server/auth/schema.js'; @@ -153,3 +154,84 @@ describe('remembered session migration', () => { expect(handle.pragma('foreign_key_check')).toEqual([]); }); }); + +describe('moderator role migration', () => { + it('preserves all account roles and remembered sessions while extending the role constraint', () => { + const upgrade = migrations.find((entry) => entry.name === 'moderator authentication role')!; + runMigrations(handle, migrations.slice(0, upgrade.version - 1)); + handle.exec(` + UPDATE auth_sessions SET remembered=1 WHERE user_id='admin'; + INSERT INTO auth_users VALUES + ('super','Super','Super','super-password-hash','super_viewer',0,0,10,20,NULL); + INSERT INTO auth_sessions VALUES ('super-token-hash','super',10,1000,100,1); + `); + const tables = ['auth_users', 'auth_audit', 'auth_rate_limits', 'servers']; + const original = tables.map(rows); + const sessions = handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all(); + expect(() => + handle.exec("UPDATE auth_users SET role='moderator' WHERE id='viewer'") + ).toThrow('CHECK constraint failed'); + + runMigrations(handle, migrations); + + expect(tables.map(rows)).toEqual(original); + expect(handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all()).toEqual( + sessions + ); + expect(handle.pragma('foreign_keys', { simple: true })).toBe(1); + expect(handle.pragma('foreign_key_check')).toEqual([]); + expect(handle.pragma('foreign_key_list(auth_sessions)')).toEqual([ + expect.objectContaining({ table: 'auth_users', from: 'user_id', on_delete: 'CASCADE' }) + ]); + handle.exec("UPDATE auth_users SET role='moderator' WHERE id='viewer'"); + const users = rows('auth_users'); + const schemaVersion = handle.pragma('schema_version', { simple: true }); + migrateModeratorRole(handle); + expect(rows('auth_users')).toEqual(users); + expect(handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all()).toEqual( + sessions + ); + expect(handle.pragma('schema_version', { simple: true })).toBe(schemaVersion); + expect(() => handle.exec("UPDATE auth_users SET role='unknown' WHERE id='viewer'")).toThrow( + 'CHECK constraint failed' + ); + expect(() => + handle.exec("UPDATE auth_users SET username='ADMINISTRATOR' WHERE id='viewer'") + ).toThrow('UNIQUE constraint failed'); + handle.exec("DELETE FROM auth_users WHERE id='super'"); + expect(handle.prepare("SELECT * FROM auth_sessions WHERE user_id='super'").all()).toEqual( + [] + ); + }); + + it('rejects an unsafe rebuild and rolls back the upgrade if existing references are broken', () => { + const upgrade = migrations.find((entry) => entry.name === 'moderator authentication role')!; + runMigrations(handle, migrations.slice(0, upgrade.version - 1)); + const users = rows('auth_users'); + const sessions = handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all(); + expect(() => migrateModeratorRole(handle)).toThrow('foreign keys to be disabled'); + expect(rows('auth_users')).toEqual(users); + expect(handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all()).toEqual( + sessions + ); + handle.pragma('foreign_keys = OFF'); + handle.exec("INSERT INTO auth_sessions VALUES ('orphan','missing',10,1000,100,1)"); + handle.pragma('foreign_keys = ON'); + + expect(() => runMigrations(handle, migrations)).toThrow('left broken references'); + + expect(handle.pragma('user_version', { simple: true })).toBe(upgrade.version - 1); + expect(handle.pragma('foreign_keys', { simple: true })).toBe(1); + expect(rows('auth_users')).toEqual(users); + expect( + handle.prepare("SELECT name FROM sqlite_master WHERE name='auth_users_moderator'").get() + ).toBeUndefined(); + handle.exec("DELETE FROM auth_sessions WHERE token_hash='orphan'"); + runMigrations(handle, migrations); + expect(rows('auth_users')).toEqual(users); + expect(handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all()).toEqual( + sessions + ); + expect(handle.pragma('foreign_key_check')).toEqual([]); + }); +}); diff --git a/tests/unit/auth-users.test.ts b/tests/unit/auth-users.test.ts index 9f65d70..a550fe6 100644 --- a/tests/unit/auth-users.test.ts +++ b/tests/unit/auth-users.test.ts @@ -256,7 +256,7 @@ describe('password storage and verification', () => { describe('administrator user management', () => { it('rejects the reserved system username for every role while allowing distinct usernames', async () => { const admin = seedUser(); - for (const role of ['admin', 'super_viewer', 'operator', 'viewer'] as const) { + for (const role of ['admin', 'super_viewer', 'operator', 'moderator', 'viewer'] as const) { for (const username of ['Maxis', 'maxis', 'MAXIS', 'mAxIs']) { await expect( auth.createUser(admin.id, { username, role, password }) @@ -275,7 +275,7 @@ describe('administrator user management', () => { it('creates each supported role with a forced initial password change and rejects case-insensitive duplicate names', async () => { const admin = seedUser(); - for (const role of ['admin', 'super_viewer', 'operator', 'viewer'] as const) { + for (const role of ['admin', 'super_viewer', 'operator', 'moderator', 'viewer'] as const) { const user = await auth.createUser(admin.id, { username: role + 'User', displayName: ' ' + role + ' user ', @@ -295,7 +295,7 @@ describe('administrator user management', () => { await expect( auth.createUser(admin.id, { username: 'OPERATORUSER', role: 'viewer', password }) ).rejects.toMatchObject({ status: 409 }); - expect(auth.listUsers()).toHaveLength(5); + expect(auth.listUsers()).toHaveLength(6); expect(JSON.stringify(auth.listAuthAudit())).not.toContain(password); }); @@ -304,6 +304,7 @@ describe('administrator user management', () => { const actors = [ seedUser('superviewer', 'super_viewer'), seedUser('operator', 'operator'), + seedUser('moderator', 'moderator'), seedUser('viewer', 'viewer'), seedUser('disabledadmin', 'admin', { disabled: true }), seedUser('temporaryadmin', 'admin', { mustChangePassword: true }) @@ -333,6 +334,9 @@ describe('administrator user management', () => { await expect( auth.updateUser(first.id, first.id, { role: 'operator' }) ).rejects.toMatchObject({ status: 409 }); + await expect( + auth.updateUser(first.id, first.id, { role: 'moderator' }) + ).rejects.toMatchObject({ status: 409 }); await expect(auth.updateUser(first.id, first.id, { disabled: true })).rejects.toMatchObject( { status: 409 } ); @@ -347,6 +351,18 @@ describe('administrator user management', () => { expect(auth.getUser(second.id)?.role).toBe('admin'); }); + it('revokes sessions when moderation access is granted or removed', async () => { + const admin = seedUser(); + const target = seedUser('target', 'viewer'); + const beforeGrant = auth.createSession(target.id, true); + await auth.updateUser(admin.id, target.id, { role: 'moderator' }); + expect(auth.getUser(target.id)?.role).toBe('moderator'); + expect(auth.readSession(beforeGrant.token)).toBeNull(); + const beforeRemoval = auth.createSession(target.id, true); + await auth.updateUser(admin.id, target.id, { role: 'viewer' }); + expect(auth.readSession(beforeRemoval.token)).toBeNull(); + }); + it.each([false, true])( 'revokes every target session on role, enabled-state and password changes (remembered=%s)', async (remembered) => { diff --git a/tests/unit/moderation-foundation.test.ts b/tests/unit/moderation-foundation.test.ts new file mode 100644 index 0000000..52a371d --- /dev/null +++ b/tests/unit/moderation-foundation.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { newFixture, type Fixture } from '../helpers/fixture.js'; +import { moderationGuid } from '../../src/lib/moderation.js'; + +let fixture: Fixture; +beforeEach(async () => { + vi.resetModules(); + fixture = await newFixture(); +}); +afterEach(async () => fixture.cleanup()); + +it('keeps large identities exact and normalizes only known representations', () => { + expect(moderationGuid('t6', '110000100000001', 'rcon')).toBe('76561197960265729'); + expect(moderationGuid('t6', '076561197960265729')).toBe('76561197960265729'); + expect(moderationGuid('t5', 'DEADbeef', 'rcon')).toBe('DEADbeef'); + expect(moderationGuid('t5', 'Some:opaque_ID')).toBe('Some:opaque_ID'); +}); + +it('preserves aliases and moderation identity independently of gameplay retention', async () => { + const { db } = await import('../../src/lib/server/db/index.js'); + const { observeModerationPlayers } = await import('../../src/lib/server/moderation/core.js'); + const handle = db(); + observeModerationPlayers( + handle, + 't6', + [{ guid: '000123', name: 'Old name' }], + '2026-10-01T00:00:00.000Z' + ); + observeModerationPlayers( + handle, + 't6', + [{ guid: '123', name: 'New name' }], + '2026-10-02T00:00:00.000Z' + ); + observeModerationPlayers( + handle, + 't6', + [{ guid: '123', name: 'Delayed name' }], + '2026-09-30T00:00:00.000Z' + ); + handle.prepare('DELETE FROM leaderboard_players').run(); + expect(handle.prepare('SELECT * FROM moderation_players').get()).toMatchObject({ + game: 't6', + guid: '123', + name: 'New name', + first_seen_at: '2026-09-30T00:00:00.000Z', + last_seen_at: '2026-10-02T00:00:00.000Z' + }); + expect(handle.prepare('SELECT name FROM moderation_aliases ORDER BY name').all()).toEqual([ + { name: 'Delayed name' }, + { name: 'New name' }, + { name: 'Old name' } + ]); +}); + +it('records mutation results atomically and rejects reused keys or revoked access', async () => { + const { db } = await import('../../src/lib/server/db/index.js'); + const { moderationMutation, moderationEvent } = + await import('../../src/lib/server/moderation/core.js'); + const handle = db(); + handle + .prepare( + `INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at) + VALUES('moderator','moderator','Moderator','unused','moderator',0,0)` + ) + .run(); + const body = { op: 'note', idempotencyKey: 'request-1', text: 'A note' }; + const work = vi.fn((actor, now) => { + moderationEvent(actor, 'note', { game: 't5', guid: '123' }, null, 'A note', now); + return { id: 'one' }; + }); + expect(moderationMutation('moderator', body, work)).toEqual({ ok: true, id: 'one' }); + expect(moderationMutation('moderator', body, work)).toEqual({ ok: true, id: 'one' }); + expect(work).toHaveBeenCalledTimes(1); + expect(() => moderationMutation('moderator', { ...body, text: 'Changed' }, work)).toThrow( + 'already used' + ); + expect(() => + moderationMutation('moderator', { ...body, idempotencyKey: 'failing' }, (actor, now) => { + moderationEvent(actor, 'note', {}, null, 'Rolled back', now); + throw new Error('Failed'); + }) + ).toThrow('Failed'); + expect(handle.prepare('SELECT COUNT(*) AS count FROM moderation_events').get()).toEqual({ + count: 1 + }); + handle.prepare("UPDATE auth_users SET role='viewer' WHERE id='moderator'").run(); + expect(() => moderationMutation('moderator', body, work)).toThrow('Moderation access'); +}); diff --git a/tests/unit/ui-admin-access.test.ts b/tests/unit/ui-admin-access.test.ts index ba62a81..040f195 100644 --- a/tests/unit/ui-admin-access.test.ts +++ b/tests/unit/ui-admin-access.test.ts @@ -45,12 +45,15 @@ describe('administrator pages', () => { expect(body).not.toContain('You do not have access'); }); - it.each(['viewer', 'operator'] as const)('keeps user accounts unavailable to %s', (role) => { - asRole(role); - const body = render(UsersPage).body; - expect(body).toContain('You do not have access'); - expect(body).not.toContain('Account activity'); - }); + it.each(['viewer', 'operator', 'moderator'] as const)( + 'keeps user accounts unavailable to %s', + (role) => { + asRole(role); + const body = render(UsersPage).body; + expect(body).toContain('You do not have access'); + expect(body).not.toContain('Account activity'); + } + ); it('retains administrator user creation and describes the assignable Super Viewer role', () => { asRole('admin'); @@ -58,6 +61,10 @@ describe('administrator pages', () => { expect(body).toContain('Add user'); expect(body).toContain('Super Viewer'); expect(body).toContain('Read all fleet and administrator pages without making changes.'); + expect(body).toContain('Moderator'); + expect(body).toContain( + 'Moderate players with server or game restrictions lasting up to 7 days.' + ); }); it('keeps key searches available while hiding key creation from Super Viewers', () => {