Add moderation permissions and durable player records
This commit is contained in:
+12
-1
@@ -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. */
|
/** Reserved identity for work requested by the controller. */
|
||||||
export const systemUsername = 'Maxis';
|
export const systemUsername = 'Maxis';
|
||||||
@@ -19,6 +21,7 @@ export const roleLabels: Record<UserRole, string> = {
|
|||||||
admin: 'Administrator',
|
admin: 'Administrator',
|
||||||
super_viewer: 'Super Viewer',
|
super_viewer: 'Super Viewer',
|
||||||
operator: 'Operator',
|
operator: 'Operator',
|
||||||
|
moderator: 'Moderator',
|
||||||
viewer: 'Viewer'
|
viewer: 'Viewer'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,3 +36,11 @@ export function canReadAdmin(role: UserRole | null | undefined): boolean {
|
|||||||
export function canReadSensitive(role: UserRole | null | undefined): boolean {
|
export function canReadSensitive(role: UserRole | null | undefined): boolean {
|
||||||
return canOperate(role) || role === 'super_viewer';
|
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';
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { UserRole } from '../../auth.js';
|
import { canModerate, canReadModeration, type UserRole } from '../../auth.js';
|
||||||
|
|
||||||
export type RoutePermission =
|
export type RoutePermission =
|
||||||
| 'public'
|
| 'public'
|
||||||
@@ -7,6 +7,8 @@ export type RoutePermission =
|
|||||||
| 'viewer'
|
| 'viewer'
|
||||||
| 'operator-read'
|
| 'operator-read'
|
||||||
| 'admin-read'
|
| 'admin-read'
|
||||||
|
| 'moderation-read'
|
||||||
|
| 'moderation'
|
||||||
| 'operator'
|
| 'operator'
|
||||||
| 'admin';
|
| '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 (routeId === '/api/settings/retention/preview' && method === 'POST') return 'admin-read';
|
||||||
if (within(routeId, '/api/auth')) return 'admin';
|
if (within(routeId, '/api/auth')) return 'admin';
|
||||||
|
|
||||||
|
if (within(routeId, '/moderation') || within(routeId, '/api/moderation'))
|
||||||
|
return reading ? 'moderation-read' : 'moderation';
|
||||||
|
|
||||||
if (
|
if (
|
||||||
within(routeId, '/api/users') ||
|
within(routeId, '/api/users') ||
|
||||||
within(routeId, '/settings') ||
|
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. */
|
/** A browser role never substitutes for a machine credential. */
|
||||||
export function roleAllows(role: UserRole, permission: RoutePermission): boolean {
|
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 === 'machine') return false;
|
||||||
if (permission === 'public' || permission === 'authenticated' || permission === 'viewer')
|
if (permission === 'public' || permission === 'authenticated' || permission === 'viewer')
|
||||||
return true;
|
return true;
|
||||||
if (permission === 'operator') return role === 'operator' || role === 'admin';
|
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')
|
if (permission === 'operator-read')
|
||||||
return role === 'super_viewer' || role === 'operator' || role === 'admin';
|
return role === 'super_viewer' || role === 'operator' || role === 'admin';
|
||||||
if (permission === 'admin-read') return role === 'super_viewer' || role === 'admin';
|
if (permission === 'admin-read') return role === 'super_viewer' || role === 'admin';
|
||||||
|
|||||||
@@ -167,9 +167,13 @@ export const authenticationHandle: Handle = async ({ event, resolve }) => {
|
|||||||
? 'Administrator access is required.'
|
? 'Administrator access is required.'
|
||||||
: permission === 'admin-read'
|
: permission === 'admin-read'
|
||||||
? 'Administrator or Super Viewer access is required.'
|
? 'Administrator or Super Viewer access is required.'
|
||||||
: permission === 'operator-read'
|
: permission === 'moderation'
|
||||||
? 'Operator, Administrator or Super Viewer access is required.'
|
? 'Moderator or Administrator access is required.'
|
||||||
: 'Operator access is required for this action.';
|
: 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)
|
if (api)
|
||||||
return json(
|
return json(
|
||||||
{ error: message },
|
{ error: message },
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ function displayName(value: unknown, fallback: string): string {
|
|||||||
return name.trim();
|
return name.trim();
|
||||||
}
|
}
|
||||||
function role(value: unknown): UserRole {
|
function role(value: unknown): UserRole {
|
||||||
if (!['admin', 'super_viewer', 'operator', 'viewer'].includes(value as string))
|
if (!['admin', 'super_viewer', 'operator', 'moderator', 'viewer'].includes(value as string))
|
||||||
throw new AuthError('Choose administrator, super viewer, operator or viewer.');
|
throw new AuthError('Choose administrator, super viewer, operator, moderator or viewer.');
|
||||||
return value as UserRole;
|
return value as UserRole;
|
||||||
}
|
}
|
||||||
export function recordAudit(
|
export function recordAudit(
|
||||||
|
|||||||
@@ -61,3 +61,32 @@ export function migrateSuperViewerRole(handle: Database.Database): void {
|
|||||||
ALTER TABLE auth_users_super_viewer RENAME TO auth_users;
|
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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import { migrateHostSchema } from '../hosts/schema.js';
|
|||||||
import {
|
import {
|
||||||
migrateAuthSchema,
|
migrateAuthSchema,
|
||||||
migrateRememberedSessions,
|
migrateRememberedSessions,
|
||||||
migrateSuperViewerRole
|
migrateSuperViewerRole,
|
||||||
|
migrateModeratorRole
|
||||||
} from '../auth/schema.js';
|
} from '../auth/schema.js';
|
||||||
import { migrateGameFileSchema } from '../game-files/schema.js';
|
import { migrateGameFileSchema } from '../game-files/schema.js';
|
||||||
import { migrateLeaderboardReports } from './leaderboard-reports.js';
|
import { migrateLeaderboardReports } from './leaderboard-reports.js';
|
||||||
@@ -25,6 +26,7 @@ import { migratePlayerScoreTotal } from './player-score-total.js';
|
|||||||
import { migrateBank } from './bank.js';
|
import { migrateBank } from './bank.js';
|
||||||
import { migrateBankCredentials } from './bank-credentials.js';
|
import { migrateBankCredentials } from './bank-credentials.js';
|
||||||
import { migrateTemperatures } from '../hosts/temperatures.js';
|
import { migrateTemperatures } from '../hosts/temperatures.js';
|
||||||
|
import { migrateModeration } from '../moderation/schema.js';
|
||||||
import type { Migration } from './migration-runner.js';
|
import type { Migration } from './migration-runner.js';
|
||||||
|
|
||||||
// Append only: never renumber entries or change an applied migration to add an
|
// 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'
|
'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;
|
export const databaseVersion = migrations.length;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { GameTelemetry, ServerTiming } from '../../telemetry.js';
|
|||||||
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
||||||
import { recordLeaderboardTelemetry } from './leaderboard-ingest.js';
|
import { recordLeaderboardTelemetry } from './leaderboard-ingest.js';
|
||||||
import { latestGameSubmission } from './status-evidence.js';
|
import { latestGameSubmission } from './status-evidence.js';
|
||||||
|
import { observeModerationPlayers } from '../moderation/core.js';
|
||||||
|
|
||||||
export function listTelemetry(): GameTelemetry[] {
|
export function listTelemetry(): GameTelemetry[] {
|
||||||
const rows = db()
|
const rows = db()
|
||||||
@@ -33,6 +34,13 @@ export function saveTelemetryWithOutcome(report: GameTelemetry): {
|
|||||||
? JSON.parse(row.payload)
|
? JSON.parse(row.payload)
|
||||||
: undefined;
|
: undefined;
|
||||||
const recorded = recordLeaderboardTelemetry(handle, report);
|
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) {
|
if (!recorded.current) {
|
||||||
return {
|
return {
|
||||||
telemetry: { ...report, timing: { matchId: recorded.matchId, players: {} } },
|
telemetry: { ...report, timing: { matchId: recorded.matchId, players: {} } },
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,11 @@
|
|||||||
label: 'Operator',
|
label: 'Operator',
|
||||||
description: 'Operate servers, campaigns, scripts, and configs.'
|
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',
|
value: 'admin',
|
||||||
label: 'Administrator',
|
label: 'Administrator',
|
||||||
|
|||||||
@@ -1,18 +1,61 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { readdirSync } from 'node:fs';
|
import { readdirSync } from 'node:fs';
|
||||||
import { relative, resolve } from 'node:path';
|
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 {
|
import {
|
||||||
roleAllows,
|
roleAllows,
|
||||||
routePermission,
|
routePermission,
|
||||||
type RoutePermission
|
type RoutePermission
|
||||||
} from '../../src/lib/server/auth/access.js';
|
} 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') =>
|
const allows = (role: UserRole, route: string | null, method = 'GET') =>
|
||||||
roleAllows(role, routePermission(route, method));
|
roleAllows(role, routePermission(route, method));
|
||||||
|
|
||||||
describe('management route permissions', () => {
|
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', () => {
|
it('denies Super Viewer writes across every registered management route', () => {
|
||||||
const routes = resolve('src/routes');
|
const routes = resolve('src/routes');
|
||||||
const entries = readdirSync(routes, { recursive: true, withFileTypes: true });
|
const entries = readdirSync(routes, { recursive: true, withFileTypes: true });
|
||||||
@@ -285,6 +328,8 @@ describe('authentication and machine boundaries', () => {
|
|||||||
'operator',
|
'operator',
|
||||||
'operator-read',
|
'operator-read',
|
||||||
'admin-read',
|
'admin-read',
|
||||||
|
'moderation',
|
||||||
|
'moderation-read',
|
||||||
'admin',
|
'admin',
|
||||||
'machine'
|
'machine'
|
||||||
] as RoutePermission[]) {
|
] as RoutePermission[]) {
|
||||||
|
|||||||
@@ -235,6 +235,18 @@ describe('browser authentication guard', () => {
|
|||||||
['operator', '/api/users', 'GET', 403],
|
['operator', '/api/users', 'GET', 403],
|
||||||
['admin', '/api/hosts/[id]', 'PATCH', 200],
|
['admin', '/api/hosts/[id]', 'PATCH', 200],
|
||||||
['admin', '/api/users', 'GET', 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', 'GET', 200],
|
||||||
['super_viewer', '/api/users/audit', 'GET', 200],
|
['super_viewer', '/api/users/audit', 'GET', 200],
|
||||||
['super_viewer', '/api/server-keys', 'HEAD', 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'])(
|
it.each(['GET', 'HEAD', 'POST', 'PATCH', 'DELETE'])(
|
||||||
'permits only administrators to write settings and Super Viewers to read them',
|
'permits only administrators to write settings and Super Viewers to read them',
|
||||||
async (method) => {
|
async (method) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
migrateModeratorRole,
|
||||||
migrateRememberedSessions,
|
migrateRememberedSessions,
|
||||||
migrateSuperViewerRole
|
migrateSuperViewerRole
|
||||||
} from '../../src/lib/server/auth/schema.js';
|
} from '../../src/lib/server/auth/schema.js';
|
||||||
@@ -153,3 +154,84 @@ describe('remembered session migration', () => {
|
|||||||
expect(handle.pragma('foreign_key_check')).toEqual([]);
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ describe('password storage and verification', () => {
|
|||||||
describe('administrator user management', () => {
|
describe('administrator user management', () => {
|
||||||
it('rejects the reserved system username for every role while allowing distinct usernames', async () => {
|
it('rejects the reserved system username for every role while allowing distinct usernames', async () => {
|
||||||
const admin = seedUser();
|
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']) {
|
for (const username of ['Maxis', 'maxis', 'MAXIS', 'mAxIs']) {
|
||||||
await expect(
|
await expect(
|
||||||
auth.createUser(admin.id, { username, role, password })
|
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 () => {
|
it('creates each supported role with a forced initial password change and rejects case-insensitive duplicate names', async () => {
|
||||||
const admin = seedUser();
|
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, {
|
const user = await auth.createUser(admin.id, {
|
||||||
username: role + 'User',
|
username: role + 'User',
|
||||||
displayName: ' ' + role + ' user ',
|
displayName: ' ' + role + ' user ',
|
||||||
@@ -295,7 +295,7 @@ describe('administrator user management', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
auth.createUser(admin.id, { username: 'OPERATORUSER', role: 'viewer', password })
|
auth.createUser(admin.id, { username: 'OPERATORUSER', role: 'viewer', password })
|
||||||
).rejects.toMatchObject({ status: 409 });
|
).rejects.toMatchObject({ status: 409 });
|
||||||
expect(auth.listUsers()).toHaveLength(5);
|
expect(auth.listUsers()).toHaveLength(6);
|
||||||
expect(JSON.stringify(auth.listAuthAudit())).not.toContain(password);
|
expect(JSON.stringify(auth.listAuthAudit())).not.toContain(password);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -304,6 +304,7 @@ describe('administrator user management', () => {
|
|||||||
const actors = [
|
const actors = [
|
||||||
seedUser('superviewer', 'super_viewer'),
|
seedUser('superviewer', 'super_viewer'),
|
||||||
seedUser('operator', 'operator'),
|
seedUser('operator', 'operator'),
|
||||||
|
seedUser('moderator', 'moderator'),
|
||||||
seedUser('viewer', 'viewer'),
|
seedUser('viewer', 'viewer'),
|
||||||
seedUser('disabledadmin', 'admin', { disabled: true }),
|
seedUser('disabledadmin', 'admin', { disabled: true }),
|
||||||
seedUser('temporaryadmin', 'admin', { mustChangePassword: true })
|
seedUser('temporaryadmin', 'admin', { mustChangePassword: true })
|
||||||
@@ -333,6 +334,9 @@ describe('administrator user management', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
auth.updateUser(first.id, first.id, { role: 'operator' })
|
auth.updateUser(first.id, first.id, { role: 'operator' })
|
||||||
).rejects.toMatchObject({ status: 409 });
|
).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(
|
await expect(auth.updateUser(first.id, first.id, { disabled: true })).rejects.toMatchObject(
|
||||||
{ status: 409 }
|
{ status: 409 }
|
||||||
);
|
);
|
||||||
@@ -347,6 +351,18 @@ describe('administrator user management', () => {
|
|||||||
expect(auth.getUser(second.id)?.role).toBe('admin');
|
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])(
|
it.each([false, true])(
|
||||||
'revokes every target session on role, enabled-state and password changes (remembered=%s)',
|
'revokes every target session on role, enabled-state and password changes (remembered=%s)',
|
||||||
async (remembered) => {
|
async (remembered) => {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -45,12 +45,15 @@ describe('administrator pages', () => {
|
|||||||
expect(body).not.toContain('You do not have access');
|
expect(body).not.toContain('You do not have access');
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['viewer', 'operator'] as const)('keeps user accounts unavailable to %s', (role) => {
|
it.each(['viewer', 'operator', 'moderator'] as const)(
|
||||||
asRole(role);
|
'keeps user accounts unavailable to %s',
|
||||||
const body = render(UsersPage).body;
|
(role) => {
|
||||||
expect(body).toContain('You do not have access');
|
asRole(role);
|
||||||
expect(body).not.toContain('Account activity');
|
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', () => {
|
it('retains administrator user creation and describes the assignable Super Viewer role', () => {
|
||||||
asRole('admin');
|
asRole('admin');
|
||||||
@@ -58,6 +61,10 @@ describe('administrator pages', () => {
|
|||||||
expect(body).toContain('Add user');
|
expect(body).toContain('Add user');
|
||||||
expect(body).toContain('Super Viewer');
|
expect(body).toContain('Super Viewer');
|
||||||
expect(body).toContain('Read all fleet and administrator pages without making changes.');
|
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', () => {
|
it('keeps key searches available while hiding key creation from Super Viewers', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user