import { randomUUID } from 'node:crypto'; import { db } from '../db/index.js'; import { archivedEvidenceSnapshots } from './evidence.js'; import { caseActionReference, recordedCaseAction, isCaseActionLinked } from './case-actions.js'; import { assessmentDraft } from './assessments.js'; import { linkCases, unlinkCases } from './relationships.js'; import { notifyCase, caseReviewSchedule, readCaseUpdates } from './followups.js'; import { AuthError } from '../auth/index.js'; import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js'; import { canRevokeModerationRestriction, isModerationCaseOpen, moderationCaseOutcomes, moderationCaseStatuses, moderationCaseStatusLabels, type ModerationCaseStatus, type ModerationCasePriority, type ModerationGame, type ModerationScope } from '../../moderation.js'; import { advancePolicy, ensureSubject, moderationEvent, moderationMutation, subject, textField } from './core.js'; export const runtimeFreshMs = 15_000; function gameText(value: unknown, label: string): string { const text = textField(value, label, 240); if (Buffer.byteLength(text, 'utf8') > 240) throw new AuthError(`${label} must fit within 240 UTF-8 bytes for the game server.`); return text; } type SanctionRow = { id: string; game: ModerationGame; guid: string; actor_id: string; scope: ModerationScope; created_at: string; expires_at: string | null; revision: number; revoked_at: string | null; case_id: string | null; }; type CaseRow = { id: string; game: ModerationGame; guid: string; revision: number; status: ModerationCaseStatus; title: string; priority: ModerationCasePriority; assignee_id: string | null; review_at: string | null; review_reason: string; review_sanction_id: string | null; }; function serverTarget(name: unknown, game?: ModerationGame) { const server = textField(name, 'Server', 255); const row = db() .prepare( `SELECT s.name,s.game,s.created_at,n.id AS instance_id,n.generation FROM servers s LEFT JOIN native_instances n ON n.server=s.name AND n.retired_at IS NULL WHERE s.name=? AND s.destroyed_at IS NULL` ) .get(server) as | { name: string; game: ModerationGame; created_at: string; instance_id: string | null; generation: number | null; } | undefined; if (!row || (game && row.game !== game)) throw new AuthError('Choose an existing server for this game.', 404); return row; } function createSanction(actor: AuthUser, input: Record, now: string) { const target = subject(input); const linkedCase = input.caseId === undefined || input.caseId === null ? null : caseRow(input.caseId); if (linkedCase) { checkCaseRevision(linkedCase, input.caseRevision); if (!isModerationCaseOpen(linkedCase.status)) throw new AuthError('Reopen this case before applying a restriction.', 409); if (linkedCase.game !== target.game || linkedCase.guid !== target.guid) throw new AuthError('A case restriction must target the case’s game and player.'); } if (input.kind !== 'ban' && input.kind !== 'mute') throw new AuthError('Choose ban or mute.'); if (typeof input.scope !== 'string' || !['server', 'game', 'fleet'].includes(input.scope)) throw new AuthError('Choose a restriction scope.'); const duration = input.durationMinutes; if ( duration !== null && (!Number.isSafeInteger(duration) || (duration as number) < 1 || (duration as number) > 5256000) ) throw new AuthError('Choose a duration between 1 minute and 10 years, or permanent.'); if ( actor.role !== 'admin' && (input.scope === 'fleet' || duration === null || (duration as number) * 60_000 > moderatorMaximumRestrictionMs) ) throw new AuthError( 'Moderators can apply server or game restrictions lasting up to 7 days.', 403 ); // Fleet identity is only shared where both games have reported the same numeric account ID. if ( input.scope === 'fleet' && (!/^\d+$/.test(target.guid) || ( db() .prepare( 'SELECT COUNT(DISTINCT game) AS count FROM moderation_aliases WHERE guid=?' ) .get(target.guid) as { count: number } ).count !== 2) ) throw new AuthError( 'Fleet restrictions require this account ID to have been observed in both games. Use game scope until its identity is established.' ); const reason = gameText(input.reason, 'Player-visible reason'); const note = textField(input.note, 'Private note', 4000, true); const server = input.scope === 'server' ? serverTarget(input.server, target.game) : null; const activeCount = ( db() .prepare( 'SELECT COUNT(*) AS count FROM moderation_sanctions WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)' ) .get(now) as { count: number } ).count; if (activeCount >= 1000) throw new AuthError('The limit of 1,000 active restrictions has been reached.', 409); const id = randomUUID(); const expires = duration === null ? null : new Date(Date.parse(now) + (duration as number) * 60_000).toISOString(); ensureSubject(target.game, target.guid); db() .prepare( `INSERT INTO moderation_sanctions(id,game,guid,kind,scope,server,server_created_at,reason,note,actor_id,actor,created_at,expires_at,case_id) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)` ) .run( id, target.game, target.guid, input.kind, input.scope, server?.name ?? null, server?.created_at ?? null, reason, note, actor.id, actor.username, now, expires, linkedCase?.id ?? null ); advancePolicy(); moderationEvent( actor, 'restriction.created', target, id, JSON.stringify({ kind: input.kind, scope: input.scope, server: server?.name ?? null, reason, expiresAt: expires, note, caseId: linkedCase?.id ?? null }), now ); if (linkedCase) { touchCase(linkedCase.id, now); moderationEvent( actor, 'case.restriction', linkedCase, linkedCase.id, JSON.stringify({ restrictionId: id, kind: input.kind, scope: input.scope, reason }), now ); } return { id }; } function revokeSanction(actor: AuthUser, input: Record, now: string) { const id = textField(input.id, 'Restriction ID', 128); const row = db().prepare('SELECT * FROM moderation_sanctions WHERE id=?').get(id) as SanctionRow | undefined; if (!row) throw new AuthError('Restriction not found.', 404); if (actor.role !== 'admin' && row.actor_id !== actor.id) throw new AuthError( 'Only an administrator can revoke another moderator’s restriction.', 403 ); if ( !canRevokeModerationRestriction(actor, { actorId: row.actor_id, scope: row.scope, createdAt: row.created_at, expiresAt: row.expires_at }) ) throw new AuthError( 'Moderators can only revoke their own server or game restrictions originally lasting up to 7 days.', 403 ); if (row.revoked_at || row.revision !== input.revision) throw new AuthError('This restriction changed. Refresh before trying again.', 409); const reason = textField(input.reason, 'Revocation reason', 1000); db() .prepare('UPDATE moderation_sanctions SET revoked_at=?,revision=revision+1 WHERE id=?') .run(now, id); advancePolicy(); moderationEvent(actor, 'restriction.revoked', row, id, reason, now); db() .prepare( `UPDATE moderation_cases SET updated_at=?,revision=revision+1 WHERE id=? OR id IN (SELECT case_id FROM moderation_case_action_links WHERE action_type='restriction' AND action_id=?)` ) .run(now, row.case_id, id); return { id }; } function liveAction(actor: AuthUser, input: Record, now: string) { if ( typeof input.kind !== 'string' || !['warn', 'message', 'kick', 'announce'].includes(input.kind) ) throw new AuthError('Choose a supported live action.'); const target = input.kind === 'announce' ? null : subject(input); const server = serverTarget(input.server, target?.game); const runtime = db() .prepare('SELECT * FROM moderation_runtimes WHERE instance_id=? AND generation=?') .get(server.instance_id, server.generation) as | { session: string; players: string; capabilities: string; connected_at: string; } | undefined; if (!runtime || Date.parse(now) - Date.parse(runtime.connected_at) > runtimeFreshMs) throw new AuthError( 'The moderation integration is not connected to this server. Wait for a fresh connection.', 409 ); if (!(JSON.parse(runtime.capabilities) as string[]).includes(String(input.kind))) throw new AuthError('This server does not support that action.', 409); const matches = (JSON.parse(runtime.players) as { guid: string; connection: string }[]).filter( (p) => p.guid === target?.guid ); if (target && matches.length !== 1) throw new AuthError( 'This player’s current connection could not be identified. Refresh the roster.', 409 ); const recent = ( db() .prepare( 'SELECT COUNT(*) AS count FROM moderation_actions WHERE actor_id=? AND created_at>?' ) .get(actor.id, new Date(Date.parse(now) - 60_000).toISOString()) as { count: number } ).count; if (recent >= 30) throw new AuthError('Too many moderation actions. Wait a minute before continuing.', 429); const reason = gameText(input.reason, input.kind === 'kick' ? 'Kick reason' : 'Message'); const id = randomUUID(); db() .prepare( `INSERT INTO moderation_actions(id,game,guid,server,instance_id,generation,session,connection,kind,reason,actor_id,actor,created_at,expires_at,state) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'queued')` ) .run( id, server.game, target?.guid ?? null, server.name, server.instance_id, server.generation, runtime.session, target ? matches[0].connection : null, input.kind, reason, actor.id, actor.username, now, new Date(Date.parse(now) + 30_000).toISOString() ); moderationEvent( actor, 'action.queued', { game: server.game, guid: target?.guid }, id, JSON.stringify({ kind: input.kind, server: server.name, reason }), now ); return { id }; } function caseRow(value: unknown): CaseRow { const row = db() .prepare('SELECT * FROM moderation_cases WHERE id=?') .get(textField(value, 'Case ID', 128)) as CaseRow | undefined; if (!row) throw new AuthError('Case not found.', 404); return row; } function checkCaseRevision(row: CaseRow, revision: unknown) { if (row.revision !== revision) throw new AuthError('This case changed. Refresh before trying again.', 409); } function touchCase(id: string, now: string) { db() .prepare('UPDATE moderation_cases SET updated_at=?,revision=revision+1 WHERE id=?') .run(now, id); } function casePriority(value: unknown): ModerationCasePriority { if (value !== 'low' && value !== 'normal' && value !== 'high' && value !== 'urgent') throw new AuthError('Choose low, normal, high, or urgent priority.'); return value; } function caseAssignee(value: unknown): { id: string | null; username: string | null } { if (value === null) return { id: null, username: null }; const id = textField(value, 'Assignee', 128); const user = db() .prepare( `SELECT id,username FROM auth_users WHERE id=? AND role IN ('admin','moderator') AND disabled=0 AND must_change_password=0` ) .get(id) as { id: string; username: string } | undefined; if (!user) throw new AuthError('Choose an active administrator or moderator to own this case.'); return user; } function evidence( actor: AuthUser, id: string, text: string, source: string, snapshot: string | null, now: string, decision = false ) { if ( !decision && ( db() .prepare('SELECT COUNT(*) AS count FROM moderation_evidence WHERE case_id=?') .get(id) as { count: number } ).count >= 100 ) throw new AuthError('This case already contains 100 evidence entries.', 409); db() .prepare('INSERT INTO moderation_evidence VALUES(?,?,?,?,?,?,?,?)') .run(randomUUID(), id, text, source, snapshot, actor.id, actor.username, now); } function createCase(actor: AuthUser, input: Record, now: string) { const target = subject(input); const title = textField(input.title, 'Case title', 160); const note = textField(input.note, 'Case note', 4000, true); const priority = input.priority === undefined ? 'normal' : casePriority(input.priority); const assignee = input.assigneeId === undefined ? { id: null, username: null } : caseAssignee(input.assigneeId); const id = randomUUID(); ensureSubject(target.game, target.guid); db() .prepare( `INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at,priority,assignee_id) VALUES(?,?,?,?,'open',?,?,?,?,?,?)` ) .run( id, target.game, target.guid, title, actor.id, actor.username, now, now, priority, assignee.id ); if (note) evidence(actor, id, note, 'Staff note', null, now); const attached = input.evidence === undefined ? 0 : archivedEvidence(actor, id, target, input.evidence, now); moderationEvent(actor, 'case.opened', target, id, title, now); if (priority !== 'normal' || assignee.id) moderationEvent( actor, 'case.updated', target, id, JSON.stringify({ priority: { from: 'normal', to: priority }, assignee: { from: null, to: assignee.username } }), now ); if (note || attached) notifyCase(actor.id, id, 'evidence', now); if (assignee.id) notifyCase(actor.id, id, 'assignment', now); return { id }; } function archivedEvidence( actor: AuthUser, id: string, target: { game: ModerationGame; guid: string }, value: unknown, now: string ): number { const snapshots = archivedEvidenceSnapshots(id, target, value); for (const snapshot of snapshots) evidence(actor, id, snapshot.text, snapshot.source, snapshot.snapshot, now); return snapshots.length; } function updateCase(actor: AuthUser, input: Record, now: string) { const row = caseRow(input.id); checkCaseRevision(row, input.revision); const title = input.title === undefined ? row.title : textField(input.title, 'Case title', 160); const priority = input.priority === undefined ? row.priority : casePriority(input.priority); const assignee = input.assigneeId === undefined || input.assigneeId === row.assignee_id ? null : caseAssignee(input.assigneeId); const assigneeId = assignee ? assignee.id : row.assignee_id; const status = input.status === undefined ? row.status : input.status; if ( input.status !== undefined && !moderationCaseStatuses.includes(status as ModerationCaseStatus) ) throw new AuthError('Choose Open, In progress, or Closed.'); const reason = textField(input.reason, 'Status change reason', 4000, status === row.status); const changes: Record = {}; if (title !== row.title) changes.title = { from: row.title, to: title }; if (priority !== row.priority) changes.priority = { from: row.priority, to: priority }; if (status !== row.status) changes.status = { from: row.status, to: status }; if (assigneeId !== row.assignee_id) { const previous = row.assignee_id ? (db().prepare('SELECT username FROM auth_users WHERE id=?').get(row.assignee_id) as { username: string } | undefined) : null; changes.assignee = { from: previous?.username ?? null, to: assignee?.username ?? null }; changes.assigneeId = { from: row.assignee_id, to: assigneeId }; } if (!Object.keys(changes).length) throw new AuthError('Change the case details before saving.'); db() .prepare( `UPDATE moderation_cases SET title=?,priority=?,assignee_id=?,status=?,updated_at=?,revision=revision+1 WHERE id=?` ) .run(title, priority, assigneeId, status, now, row.id); moderationEvent( actor, 'case.updated', row, row.id, JSON.stringify({ ...changes, ...(reason ? { reason } : {}) }), now ); if (assigneeId !== row.assignee_id) notifyCase(actor.id, row.id, 'assignment', now, row.assignee_id); return { id: row.id }; } export function mutateModeration(actorId: string, input: Record) { return moderationMutation(actorId, input, (actor, now) => { if (input.op === 'create-sanction') return createSanction(actor, input, now); if (input.op === 'revoke-sanction') return revokeSanction(actor, input, now); if (input.op === 'action') return liveAction(actor, input, now); if (input.op === 'create-case') return createCase(actor, input, now); if (input.op === 'update-case') return updateCase(actor, input, now); if (input.op === 'schedule-case-review') { const row = caseRow(input.id); checkCaseRevision(row, input.revision); const schedule = caseReviewSchedule(row, input, now); if ( row.review_at === schedule.reviewAt && row.review_reason === schedule.reason && row.review_sanction_id === schedule.restrictionId ) return { id: row.id }; db() .prepare( `UPDATE moderation_cases SET review_at=?,review_reason=?,review_sanction_id=?, updated_at=?,revision=revision+1 WHERE id=?` ) .run(schedule.reviewAt, schedule.reason, schedule.restrictionId, now, row.id); moderationEvent( actor, 'case.review_scheduled', row, row.id, JSON.stringify(schedule), now ); return { id: row.id }; } if (input.op === 'read-case-updates') { const row = caseRow(input.id); readCaseUpdates(actor.id, row.id, input.throughId, now); return { id: row.id }; } if (input.op === 'link-case' || input.op === 'unlink-case') { const row = caseRow(input.id); checkCaseRevision(row, input.revision); if (input.op === 'link-case') linkCases( actor, row, caseRow(input.relatedCaseId), input.relation, input.reason, now ); else unlinkCases(actor, row, input.relationshipId, input.reason, now); return { id: row.id }; } if (input.op === 'assess-case') { const row = caseRow(input.id); checkCaseRevision(row, input.revision); const draft = assessmentDraft(input); const data = JSON.stringify(draft); const prior = db() .prepare( 'SELECT data FROM moderation_case_assessments WHERE case_id=? ORDER BY revision DESC LIMIT 1' ) .get(row.id) as { data: string } | undefined; if (prior?.data === data) throw new AuthError('Change the assessment before saving.'); db() .prepare('INSERT INTO moderation_case_assessments VALUES(?,?,?,?,?,?)') .run(row.id, row.revision + 1, data, actor.id, actor.username, now); touchCase(row.id, now); moderationEvent( actor, 'case.assessed', row, row.id, JSON.stringify({ assessmentRevision: row.revision + 1, summary: draft.summary }), now ); return { id: row.id }; } if (input.op === 'link-case-action') { const row = caseRow(input.id); checkCaseRevision(row, input.revision); const reference = caseActionReference({ type: input.actionType, id: input.actionId }); const reason = textField(input.reason, 'Link reason', 4000); recordedCaseAction(row, reference, now); if (isCaseActionLinked(row.id, reference)) return { id: row.id }; db() .prepare('INSERT INTO moderation_case_action_links VALUES(?,?,?,?,?)') .run(row.id, reference.type, reference.id, actor.username, now); touchCase(row.id, now); moderationEvent( actor, 'case.action_linked', row, row.id, JSON.stringify({ actionReference: reference, reason }), now ); return { id: row.id }; } if (input.op === 'case-note') { const row = caseRow(input.id); if (input.revision !== undefined) checkCaseRevision(row, input.revision); const text = textField(input.text, 'Note', 4000); evidence(actor, row.id, text, 'Staff note', null, now); touchCase(row.id, now); moderationEvent(actor, 'case.note', row, row.id, text, now); notifyCase(actor.id, row.id, 'evidence', now); return { id: row.id }; } // Older clients may still record decisions, but every closure now stores only Closed. if (input.op === 'resolve-case') { const row = caseRow(input.id); if (row.revision !== input.revision || !isModerationCaseOpen(row.status)) throw new AuthError('This case changed. Refresh before resolving it.', 409); const outcome = moderationCaseOutcomes.find((status) => status === input.status); if (!outcome) throw new AuthError('Choose a case outcome.'); const reason = textField(input.reason, 'Decision reason', 4000); const reference = input.actionReference === undefined || input.actionReference === null ? null : caseActionReference(input.actionReference); if (reference) { const action = recordedCaseAction(row, reference, now); if (!isCaseActionLinked(row.id, reference)) throw new AuthError( 'Link the recorded action to this case before citing it.', 409 ); if ( outcome === 'warning_issued' && (action.type !== 'action' || action.kind !== 'warn' || action.state !== 'confirmed') ) throw new AuthError('Warning issued must reference a confirmed warning.'); if (outcome === 'restriction_applied' && action.type !== 'restriction') throw new AuthError( 'Restriction applied must reference a recorded restriction.' ); } evidence(actor, row.id, reason, moderationCaseStatusLabels[outcome], null, now, true); db() .prepare( 'UPDATE moderation_cases SET status=?,updated_at=?,revision=revision+1 WHERE id=?' ) .run('closed', now, row.id); moderationEvent( actor, `case.${outcome}`, row, row.id, reference ? JSON.stringify({ reason, actionReference: reference }) : reason, now ); return { id: row.id }; } if (input.op === 'reopen-case') { const row = caseRow(input.id); checkCaseRevision(row, input.revision); if (isModerationCaseOpen(row.status)) throw new AuthError('This case is already active.', 409); const reason = textField(input.reason, 'Reopening reason', 4000); evidence(actor, row.id, reason, 'Reopened', null, now, true); db() .prepare( "UPDATE moderation_cases SET status='open',updated_at=?,revision=revision+1 WHERE id=?" ) .run(now, row.id); moderationEvent( actor, 'case.reopened', row, row.id, JSON.stringify({ from: row.status, reason }), now ); return { id: row.id }; } if (input.op === 'attach-evidence') { const row = caseRow(input.id); checkCaseRevision(row, input.revision); const count = archivedEvidence(actor, row.id, row, input.evidence, now); if (!count) return { id: row.id }; touchCase(row.id, now); moderationEvent( actor, 'case.evidence_attached', row, row.id, JSON.stringify({ count, matchId: (input.evidence as { matchId: number }).matchId }), now ); notifyCase(actor.id, row.id, 'evidence', now); return { id: row.id }; } if (input.op === 'note') { const target = subject(input); const text = textField(input.text, 'Private note', 4000); ensureSubject(target.game, target.guid); moderationEvent(actor, 'player.note', target, null, text, now); return { id: String( (db().prepare('SELECT last_insert_rowid() AS id').get() as { id: number }).id ) }; } throw new AuthError('Unknown moderation action.'); }); }