Add role-scoped administrator action history

This commit is contained in:
Quintine committed 2026-09-29 20:02:03 +10:00
1 parent 54cbf4d06f
commit 6df505f003
14 files changed
+611 -5

No files matched your search

+2
View File
@@ -61,6 +61,8 @@ game while preserving their seat and takeback control.
[Discussion controls](docs/ADMIN_DISCUSSION.md) mute or unmute new messages from [Discussion controls](docs/ADMIN_DISCUSSION.md) mute or unmute new messages from
a human seat while preserving history and gameplay. Permanent deletion, further a human seat while preserving history and gameplay. Permanent deletion, further
participant support and backup tools are still being built. participant support and backup tools are still being built.
[Action history](docs/ADMIN_ACTION_HISTORY.md) searches recorded admin operations,
with role-limited reasons and public before/after settings.
## Saved seats and reconnecting ## Saved seats and reconnecting
+15
View File
@@ -60,3 +60,18 @@
.admin-room-table td:nth-child(3)::before { content: 'Progress: '; color: var(--primary); } .admin-room-table td:nth-child(3)::before { content: 'Progress: '; color: var(--primary); }
.admin-room-table td:nth-child(4)::before { content: 'Last game change: '; color: var(--primary); } .admin-room-table td:nth-child(4)::before { content: 'Last game change: '; color: var(--primary); }
} }
.admin-audit-list { list-style: none; padding: 0; display: grid; gap: 20px; }
.admin-audit-list article { padding: 20px; border: 1px solid var(--border); background: #171c16; overflow-wrap: anywhere; }
.admin-audit-list h3 { margin: 0; }
.admin-audit-list time { color: #b7bbaf; }
.admin-audit-envelope { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 18px; }
.admin-audit-envelope dt, .admin-audit-changes dt { color: #b7bbaf; }
.admin-audit-envelope dd, .admin-audit-changes dd { margin: 0; }
.admin-audit-list summary { cursor: pointer; min-height: 44px; padding: 10px 0; color: var(--primary); }
.admin-audit-changes { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 16px; }
@media (max-width: 700px) {
.admin-audit-list article { padding: 16px; }
.admin-audit-envelope { grid-template-columns: minmax(0, 1fr); }
.admin-audit-envelope dd { margin-bottom: 10px; }
}
+85
View File
@@ -0,0 +1,85 @@
'use client';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ClientRequestError, requestJson } from '@/lib/client-request';
import { AUDIT_ACTIONS, AUDIT_CATEGORIES, adminAuditResponse, type AdminAuditPage } from '@/lib/admin-audit';
import '../admin.css';
type Filters = { q: string; category: string; from: string; to: string };
const empty: Filters = { q:'',category:'all',from:'',to:'' };
export default function ActionHistory() {
const [filters,setFilters] = useState(empty);
const [applied,setApplied] = useState(empty);
const [result,setResult] = useState<AdminAuditPage | null>(null);
const [loading,setLoading] = useState(true);
const [denied,setDenied] = useState(false);
const [notice,setNotice] = useState('');
const [reload,setReload] = useState(0);
useEffect(() => {
let canceled = false;
const query = new URLSearchParams(window.location.search);
const selected = { q:query.get('q') ?? '',category:query.get('category') || 'all',from:query.get('from') ?? '',to:query.get('to') ?? '' };
// oxlint-disable-next-line react/react-compiler -- Restore shareable, non-secret history filters after SSR.
setFilters(selected); setApplied(selected); setLoading(true); setResult(null); setDenied(false); setNotice('');
requestJson<{ admin: { id: string } }>('/api/admin/session',{cache:'no-store'})
.then(async session => adminAuditResponse(await requestJson<unknown>('/api/admin/audit?'+query,{cache:'no-store',headers:{'X-Dune-Admin-Id':session.admin.id}})))
.then(page => { if (!canceled) setResult(page); })
.catch(error => { if (!canceled) {
setDenied(error instanceof ClientRequestError && [401,403].includes(error.status ?? 0));
setNotice(error instanceof Error ? error.message : 'Action history could not be loaded.');
} })
.finally(() => { if (!canceled) setLoading(false); });
return () => { canceled = true; };
},[reload]);
useEffect(() => {
const refresh = () => { if (document.visibilityState !== 'hidden') { setResult(null); setReload(value => value+1); } };
window.addEventListener('focus',refresh); window.addEventListener('pageshow',refresh); document.addEventListener('visibilitychange',refresh);
return () => { window.removeEventListener('focus',refresh); window.removeEventListener('pageshow',refresh); document.removeEventListener('visibilitychange',refresh); };
},[]);
const link = (page: number) => '/admin/audit?'+new URLSearchParams({...applied,page:String(page)});
return <main className="admin-shell">
<header className="admin-header"><div><a className="admin-return" href="/admin">Administration</a><h1>Action history</h1></div>
<Button variant="outline" disabled={loading} onClick={() => setReload(value => value+1)}>Refresh history</Button></header>
<p>Recorded administrator actions, newest first. This history describes what was applied; later actions may have changed the room again.</p>
{notice && <p role="alert" className="admin-notice">{notice}</p>}
{denied ? <p><a className="admin-room-link" href="/admin">Sign in to administration</a></p> : <>
<form method="get" action="/admin/audit" className="admin-filters" aria-label="Filter action history">
<label className="admin-search" htmlFor="audit-query">Room, seat or administrator<Input id="audit-query" name="q" maxLength={80} value={filters.q} onChange={event => setFilters({...filters,q:event.target.value})} /></label>
<label htmlFor="audit-category">Action group<select id="audit-category" name="category" value={filters.category} onChange={event => setFilters({...filters,category:event.target.value})}>
<option value="all">All actions</option>{Object.entries(AUDIT_CATEGORIES).map(([id,label]) => <option key={id} value={id}>{label}</option>)}
</select></label>
<label htmlFor="audit-from">From date (UTC)<Input id="audit-from" name="from" type="date" value={filters.from} onChange={event => setFilters({...filters,from:event.target.value})} /></label>
<label htmlFor="audit-to">Through date (UTC)<Input id="audit-to" name="to" type="date" value={filters.to} onChange={event => setFilters({...filters,to:event.target.value})} /></label>
<Button type="submit" disabled={loading}>Apply history filters</Button><a className="admin-room-link" href="/admin/audit">Clear filters</a>
</form>
{loading && <output>Loading action history…</output>}
{result && <section aria-labelledby="audit-results-title">
<h2 id="audit-results-title" className="admin-results">{result.total.toLocaleString()} recorded {result.total === 1 ? 'action' : 'actions'}</h2>
<p className="admin-secondary">{result.reasonsVisible ? 'Operational reasons are visible to owners and operators.' : 'Your viewer role hides operational reasons.'} Search matches room/seat/account identifiers and current administrator names; it does not search reasons or private game contents.</p>
{result.events.length === 0 ? <p>No records on this page. Adjust the filters or return to the first page.</p> : <ol className="admin-audit-list">{result.events.map(event => <li key={event.id}>
<article aria-labelledby={'event-'+event.id}>
<div className="admin-section-heading"><h3 id={'event-'+event.id}>{AUDIT_ACTIONS[event.action]}</h3>{event.createdAt === null ? <span>Time unavailable</span> : <time dateTime={new Date(event.createdAt).toISOString()}>{new Date(event.createdAt).toLocaleString()}</time>}</div>
<p><span className="admin-tag">Recorded</span> {AUDIT_CATEGORIES[event.category]}{event.roomCode && <> · Room <strong className="admin-code">{event.roomCode}</strong></>}</p>
<dl className="admin-audit-envelope">
<dt>Administrator</dt><dd>{event.actorId ? <>{event.actorName ?? 'Account no longer available'} <span className="admin-secondary">({event.actorId})</span></> : 'External account operation; no administrator attribution was recorded'}</dd>
{event.targetAdminId && <><dt>Account affected</dt><dd>{event.targetAdminName ?? 'Account no longer available'} <span className="admin-secondary">({event.targetAdminId})</span></dd></>}
{event.targetSeatId && <><dt>Seat affected</dt><dd>{event.targetSeatId}</dd></>}
{event.reason !== null && <><dt>Reason</dt><dd>{event.reason}</dd></>}
</dl>
{event.changes.length > 0 && <details><summary>Recorded settings</summary><dl className="admin-audit-changes">{event.changes.map(change => <div key={change.field}>
<dt>{change.field}</dt><dd>{change.before !== null ? <><span className="admin-secondary">Before:</span> {change.before}<br /></> : null}{change.after !== null ? <><span className="admin-secondary">After:</span> {change.after}</> : null}</dd>
</div>)}</dl></details>}
</article>
</li>)}</ol>}
<nav className="admin-pagination" aria-label="Action history pages">
{result.page > 1 && <><a className="admin-room-link" href={link(1)}>First page</a><a className="admin-room-link" href={link(result.page-1)}>Previous page</a></>}
<span>Page {result.page} of {Math.max(1,Math.ceil(result.total/result.pageSize))}</span>
{result.page*result.pageSize < result.total && <a className="admin-room-link" href={link(result.page+1)}>Next page</a>}
</nav>
<p className="admin-secondary">Times use your device’s time zone; date filters use UTC. Names reflect current administrator accounts. External provisioning and revocation records do not identify the person who ran the database operation. Rejected attempts are not yet recorded here. New actions may move records between pages.</p>
</section>}
</>}
</main>;
}
+1 -1
View File
@@ -123,7 +123,7 @@ export default function Administration() {
</section> : <> </section> : <>
<section aria-labelledby="admin-rooms-title"> <section aria-labelledby="admin-rooms-title">
<div className="admin-section-heading"><div><h2 id="admin-rooms-title">Rooms</h2><p className="admin-secondary">Public room details. Cards, sealed choices and private discussion stay hidden.</p></div> <div className="admin-section-heading"><div><h2 id="admin-rooms-title">Rooms</h2><p className="admin-secondary">Public room details. Cards, sealed choices and private discussion stay hidden.</p></div>
<div className="admin-room-actions">{account.role !== 'viewer' && <Button disabled={controlsBusy} onClick={() => { setSelectedDiscussion(null); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setCreatingRoom(true); }}>Create a room</Button>} <div className="admin-room-actions"><a className="admin-room-link" href="/admin/audit">Action history</a>{account.role !== 'viewer' && <Button disabled={controlsBusy} onClick={() => { setSelectedDiscussion(null); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setCreatingRoom(true); }}>Create a room</Button>}
{account.role === 'owner' && <a className="admin-room-link" href="/admin/backups">Room backups</a>} {account.role === 'owner' && <a className="admin-room-link" href="/admin/backups">Room backups</a>}
{account.role === 'owner' && <a className="admin-room-link" href="/admin/operations">Operations</a>} {account.role === 'owner' && <a className="admin-room-link" href="/admin/operations">Operations</a>}
<Button variant="outline" disabled={controlsBusy} onClick={() => void load(applied, directory?.page ?? 1)}><RefreshCw aria-hidden="true" /> Refresh</Button></div></div> <Button variant="outline" disabled={controlsBusy} onClick={() => void load(applied, directory?.page ?? 1)}><RefreshCw aria-hidden="true" /> Refresh</Button></div></div>
+13
View File
@@ -0,0 +1,13 @@
import { env } from 'cloudflare:workers';
import { AdminError, requireAdmin } from '@/db/admin-access';
import { readAdminAudit } from '@/db/admin-audit';
import { adminFailure, adminResponse, adminToken } from '@/lib/admin-http';
export async function GET(request: Request) {
try {
const identity = await requireAdmin(env.DB,adminToken(request),['owner','operator','viewer']);
const expected = request.headers.get('X-Dune-Admin-Id');
if (expected && expected !== identity.id) throw new AdminError('The signed-in administrator changed. Reload action history.',401);
return adminResponse(await readAdminAudit(env.DB,identity,new URL(request.url).searchParams));
} catch (error) { return adminFailure(error); }
}
+134
View File
@@ -0,0 +1,134 @@
import { AdminError, type AdminIdentity } from './admin-access';
import { AUDIT_CATEGORIES, AUDIT_ACTIONS, type AdminAuditEvent, type AdminAuditPage, type AuditCategory, type AuditChange } from '../lib/admin-audit';
import { FACTIONS } from '../game/catalog';
import { DIFFICULTIES } from '../game/bot-profiles';
const authority = `SELECT a.role FROM admin_sessions s JOIN admin_accounts a ON a.id=s.admin_id
WHERE s.token_hash=? AND a.id=? AND a.enabled=1 AND s.revoked_at IS NULL
AND s.expires_at>? AND s.generation=a.session_generation AND a.role IN ('owner','operator','viewer')`;
const json = (column: string) => `CASE WHEN json_valid(${column}) THEN ${column} ELSE '{}' END`;
const actionType = `json_extract(${json('action')},'$.type')`;
// These are receipt tables only. Never join private games, messages or credential tables.
// Raw JSON is bounded then freshly projected; credential/hash columns are never selected.
// Workerd permits at most five terms in one compound SELECT. Materialize two
// bounded groups so SQLite cannot flatten the nine sources back into one union.
const rawEvents = `WITH first_events AS MATERIALIZED (
SELECT 'account' category,rowid row_key,id receipt_id,actor_admin_id actor_id,NULL room_code,target_admin_id target_admin_id,NULL target_seat_id,
CASE WHEN action IN ('provision','revoke','login','logout','logout_all') THEN action ELSE 'unknown' END action,
NULL reason,NULL before_json,substr(detail,1,8192) after_json,created_at FROM admin_audit
UNION ALL SELECT 'control',rowid,operation_id,actor_admin_id,room_code,NULL,NULL,'room_control',reason,
json_object('paused',before_paused,'joinLocked',before_join_locked,'revision',expected_revision),
json_object('paused',after_paused,'joinLocked',after_join_locked,'revision',expected_revision+1),created_at FROM admin_room_audit
UNION ALL SELECT 'creation',rowid,operation_id,actor_admin_id,room_code,NULL,host_id,'create',reason,NULL,substr(configuration,1,8192),created_at FROM admin_room_creations
UNION ALL SELECT 'lobby',rowid,operation_id,actor_admin_id,room_code,NULL,
CASE WHEN ${actionType} IN ('removeBot','assignHost','configureBot') THEN json_extract(${json('action')},'$.target') ELSE NULL END,
CASE WHEN ${actionType} IN ('rules','techTokens','strongholdCards','addBot','removeBot','assignHost','configureBot') THEN ${actionType} ELSE 'unknown' END,
reason,substr(before_configuration,1,8192),substr(after_configuration,1,8192),created_at FROM admin_lobby_operations
UNION ALL SELECT 'removal',rowid,operation_id,actor_admin_id,room_code,NULL,NULL,CASE removed WHEN 1 THEN 'remove' WHEN 0 THEN 'restore' ELSE 'unknown' END,
reason,substr(before_metadata,1,8192),substr(after_metadata,1,8192),created_at FROM admin_room_removals
), last_events AS MATERIALIZED (
SELECT 'closure',rowid,operation_id,actor_admin_id,room_code,NULL,NULL,CASE closed WHEN 1 THEN 'close' WHEN 0 THEN 'reopen' ELSE 'unknown' END,
reason,substr(before_metadata,1,8192),substr(after_metadata,1,8192),created_at FROM admin_room_closures
UNION ALL SELECT 'archive',rowid,operation_id,actor_admin_id,room_code,NULL,NULL,CASE archived WHEN 1 THEN 'archive' WHEN 0 THEN 'unarchive' ELSE 'unknown' END,
reason,substr(before_metadata,1,8192),substr(after_metadata,1,8192),created_at FROM admin_room_archives
UNION ALL SELECT 'participant_ai',rowid,operation_id,actor_admin_id,room_code,NULL,target,'participant_ai',reason,
json_object('version',expected_version),json_object('version',applied_version,'difficulty',difficulty),created_at FROM admin_seat_ai_operations
UNION ALL SELECT 'discussion',rowid,operation_id,actor_admin_id,room_code,NULL,target,CASE muted WHEN 1 THEN 'mute' WHEN 0 THEN 'unmute' ELSE 'unknown' END,reason,
json_object('revision',expected_revision),json_object('revision',applied_revision,'muted',muted),created_at FROM admin_discussion_operations
), raw_events AS (
SELECT * FROM first_events UNION ALL SELECT * FROM last_events
)`;
// Search exactly the normalized public columns, never values rejected by projection.
const sqlId = (column: string) => `CASE WHEN typeof(${column})='text' AND instr(${column},char(0))=0 AND length(${column})=36
AND substr(${column},9,1)='-' AND substr(${column},14,1)='-' AND substr(${column},19,1)='-' AND substr(${column},24,1)='-'
AND length(replace(${column},'-',''))=32 AND replace(${column},'-','') NOT GLOB '*[^0-9a-f]*' THEN ${column} ELSE NULL END`;
const sqlName = (column: string) => `CASE WHEN typeof(${column})='text' AND length(${column})<=80
AND instr(${column},char(0))=0 AND ${column} NOT GLOB '*['||char(${Array.from({length:31},(_,i)=>i+1).join(',')},127)||']*' THEN ${column} ELSE NULL END`;
const events = `${rawEvents}, events AS (SELECT e.category,e.row_key,e.receipt_id,${sqlId('e.actor_id')} actor_id,
${sqlName('a.name')} actor_name,${sqlId('e.target_admin_id')} target_admin_id,${sqlName('t.name')} target_admin_name,
${sqlId('e.target_seat_id')} target_seat_id,
CASE WHEN typeof(e.room_code)='text' AND instr(e.room_code,char(0))=0 AND length(e.room_code)=8 AND e.room_code NOT GLOB '*[^A-Z2-9]*' THEN e.room_code ELSE NULL END room_code,
e.action,e.reason,e.before_json,e.after_json,e.created_at
FROM raw_events e LEFT JOIN admin_accounts a ON a.id=${sqlId('e.actor_id')} LEFT JOIN admin_accounts t ON t.id=${sqlId('e.target_admin_id')})`;
type Row = { category: AuditCategory; row_key: number; receipt_id: string; actor_id: unknown; actor_name: unknown; target_admin_name: unknown; target_admin_id: unknown; room_code: unknown; target_seat_id: unknown; action: string; reason: unknown; before_json: string | null; after_json: string | null; created_at: unknown };
const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
const hasControl = (v: string) => Array.from(v).some(c => c.charCodeAt(0) <= 31 || c.charCodeAt(0) === 127);
const safeText = (v: unknown, max: number) => typeof v === 'string' && v.length <= max && !hasControl(v) ? v : null;
const id = (v: unknown) => typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(v) ? v : null;
const number = (v: unknown) => Number.isSafeInteger(v) && Number(v) >= 0 && Number(v) <= 8640000000000000 ? String(v) : null;
const flag = (v: unknown) => v === true || v === 1 ? 'Yes' : v === false || v === 0 ? 'No' : null;
const role = (v: unknown) => typeof v === 'string' && ['owner','operator','viewer'].includes(v) ? v : null;
function parse(raw: string | null) { try { const v: unknown = raw === null ? null : JSON.parse(raw); return object(v) ? v : {}; } catch { return {}; } }
function configuration(value: Record<string, unknown>): Record<string, string | null> {
const bots = Array.isArray(value.bots) ? value.bots : Array.isArray(value.players) ? value.players.filter(p => object(p) && p.bot) : null;
return { 'Rules': value.advanced === true ? 'Advanced preview' : value.advanced === false ? 'Basic' : null,
'Tech Tokens': typeof value.techTokens === 'boolean' ? flag(value.techTokens) : null,
'Stronghold Cards': typeof value.strongholdCards === 'boolean' ? flag(value.strongholdCards) : null,
'Host seat': id(value.host), 'Host faction': FACTIONS.find(f => f.id === value.faction)?.name ?? null,
'Player count': Array.isArray(value.players) && value.players.length <= 6 ? String(value.players.length) : null,
'AI seats': bots && bots.length <= 5 && bots.every(b => object(b) && FACTIONS.some(f => f.id === b.faction) && DIFFICULTIES.some(d => d === (b.difficulty ?? b.bot))) ?
bots.map(b => `${FACTIONS.find(f => f.id === b.faction)!.name} (${b.difficulty ?? b.bot})`).join(', ') || 'None' : null };
}
export function projectAdminAudit(row: Row, reasonsVisible: boolean): AdminAuditEvent {
const before = parse(row.before_json), after = parse(row.after_json), changes: AuditChange[] = [];
const add = (field: AuditChange['field'], a: string | null, b: string | null) => { if (a !== null || b !== null) changes.push({ field,before:a,after:b }); };
if (row.category === 'account') add('Role',role(after.previousRole),role(after.role));
if (row.category === 'lobby' || row.category === 'creation') {
const a = configuration(before), b = configuration(after);
for (const field of ['Rules','Tech Tokens','Stronghold Cards','Host seat','Host faction','Player count','AI seats'] as const) add(field,a[field],b[field]);
}
if (row.category === 'lobby' && row.action === 'configureBot') {
const target = id(row.target_seat_id);
const player = (config: Record<string,unknown>) => target && Array.isArray(config.players) ? config.players.find(p => object(p) && p.id === target) : null;
const a = player(before), b = player(after);
const position = (v: unknown) => Number.isInteger(v) && Number(v) >= 1 && Number(v) <= 6 ? String(v) : null;
add('Player circle',position(a?.position),position(b?.position));
}
for (const [field,key] of [['Paused','paused'],['Joining locked','joinLocked'],['Removed','removed'],['Closed','closed'],['Archived','archived'],['Discussion muted','muted']] as const)
if (['control','removal','closure','archive','discussion'].includes(row.category)) add(field,flag(before[key]),flag(after[key]));
if (['control','removal','closure','archive','discussion'].includes(row.category)) add('Setting revision',number(before.revision),number(after.revision));
if (['removal','closure','archive','participant_ai'].includes(row.category)) add('Game version',number(before.version),number(after.version));
if (row.category === 'participant_ai') add('Difficulty',null,DIFFICULTIES.find(d => d === after.difficulty) ?? null);
return { id: `${row.category}:${row.row_key}`,category:row.category,action:Object.hasOwn(AUDIT_ACTIONS,row.action) ? row.action as AdminAuditEvent['action'] : 'unknown',
createdAt:number(row.created_at) === null ? null : Number(row.created_at), actorId:id(row.actor_id),actorName:safeText(row.actor_name,160),targetAdminId:id(row.target_admin_id),targetAdminName:safeText(row.target_admin_name,160),
roomCode: typeof row.room_code === 'string' && /^[A-Z2-9]{8}$/.test(row.room_code) ? row.room_code : null,targetSeatId:id(row.target_seat_id),
reason:reasonsVisible ? safeText(row.reason,300) : null,outcome:'recorded',changes };
}
function date(value: string | null): number | null {
if (!value) return null;
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new AdminError('Use calendar dates for the history range.',400);
const time = Date.parse(value+'T00:00:00Z');
if (!Number.isFinite(time) || new Date(time).toISOString().slice(0,10) !== value) throw new AdminError('Use valid calendar dates for the history range.',400);
return time;
}
export async function readAdminAudit(database: D1Database, identity: AdminIdentity, search: URLSearchParams, now = Date.now()): Promise<AdminAuditPage> {
if (!identity || typeof identity.id !== 'string' || !/^[0-9a-f]{64}$/.test(identity.sessionHash)) throw new AdminError('Administrator sign-in required.',401);
const allowed = ['q','category','from','to','page'];
if ([...search.keys()].some(k => !allowed.includes(k) || search.getAll(k).length !== 1)) throw new AdminError('Invalid action history filters.',400);
const q = (search.get('q') ?? '').trim(), category = search.get('category') || 'all', rawPage = search.get('page') ?? '1';
const from = date(search.get('from')), to = date(search.get('to'));
if (q.length > 80 || hasControl(q) || category !== 'all' && !Object.hasOwn(AUDIT_CATEGORIES,category) || !/^[1-9][0-9]{0,5}$/.test(rawPage) || from !== null && to !== null && from > to)
throw new AdminError('Check the action history filters and date range.',400);
const credentials = [identity.sessionHash,identity.id,now], conditions = [`EXISTS (${authority})`], args: (string | number)[] = [...credentials];
if (q) {
const pattern = '%'+q.replace(/[\\%_]/g,'\\$&')+'%';
conditions.push(`(e.room_code LIKE ? ESCAPE '\\' OR e.actor_id LIKE ? ESCAPE '\\' OR e.actor_name LIKE ? ESCAPE '\\' OR e.target_admin_id LIKE ? ESCAPE '\\' OR e.target_seat_id LIKE ? ESCAPE '\\' OR e.target_admin_name LIKE ? ESCAPE '\\')`);
args.push(pattern,pattern,pattern,pattern,pattern,pattern);
}
if (category !== 'all') { conditions.push('e.category=?'); args.push(category); }
if (from !== null) { conditions.push('e.created_at>=?'); args.push(from); }
if (to !== null) { conditions.push('e.created_at<?'); args.push(to+86400000); }
const source = `FROM events e WHERE ${conditions.join(' AND ')}`;
const results = await database.batch([
database.prepare(authority).bind(...credentials),
database.prepare(`${events} SELECT COUNT(*) AS total ${source}`).bind(...args),
database.prepare(`${events} SELECT e.category,e.row_key,e.receipt_id,e.actor_id,e.actor_name,e.room_code,e.target_admin_id,e.target_admin_name,e.target_seat_id,e.action,
CASE WHEN (${authority}) IN ('owner','operator') THEN substr(e.reason,1,301) ELSE NULL END reason,
e.before_json,e.after_json,e.created_at ${source} ORDER BY e.created_at DESC,e.category ASC,e.receipt_id ASC LIMIT 25 OFFSET ?`)
.bind(...credentials,...args,(Number(rawPage)-1)*25),
]);
const liveRole = (results[0].results[0] as { role: string } | undefined)?.role;
if (!liveRole) throw new AdminError('Administrator sign-in required.',401);
const reasonsVisible = liveRole !== 'viewer';
return { events:(results[2].results as Row[]).map(row => projectAdminAudit(row,reasonsVisible)),total:Number((results[1].results[0] as { total: number }).total),page:Number(rawPage),pageSize:25,reasonsVisible };
}
+77
View File
@@ -0,0 +1,77 @@
# Administrative action history
Open **Administration → Action history** to review recorded operations. The
read-only page unifies the nine existing durable receipt sources: administrator
access, pause/join controls, room creation, lobby configuration, removal,
close/reopen, archive, participant AI and discussion moderation. It does not
create another audit store or change saved games.
Search room/seat/account identifiers or current acting/affected administrator
names. Filter by action group and inclusive UTC calendar dates, then browse
25 records per page. Events sort newest first with deterministic source/receipt
ordering for matching timestamps. New operations can move records between pages;
refresh to fetch current history. Filters and page number live in the URL and
survive refresh and browser navigation. No credentials or reasons enter that URL.
Each record identifies the action, time, room/seat/account where recorded, actor,
operational reason when permitted, and bounded before/after settings. Expand
**Recorded settings** for the public configuration, lifecycle flags, revisions,
AI difficulty or changed player circle. Names come from current administrator
accounts; deleted accounts retain their recorded identifiers. External account
provisioning/revocation records have no human actor attribution, and the page
explicitly says so. A **Recorded** outcome means the operation was applied; a
room-control operation may successfully save identical settings, and later
operations may supersede it. It does not assert the room's current state.
## Access and privacy
All administrator roles may read the safe event envelope and public operational
settings. Only a live owner/operator receives reasons; viewers receive null.
Reasons are excluded from search for every role, preventing a hidden-reason
match/count oracle. Search uses the same validated identifiers and names that
can be displayed, rather than raw historical JSON. Unknown actions and malformed
metadata retain a generic record with unavailable details.
Count and page reads repeat live session, enabled-account, generation, expiry and
role checks inside one database transaction. Reason visibility derives from that
live role, not the role captured during earlier authentication. The browser binds
its read to the signed-in account, clears old results on refresh/error, and
revalidates when focus/visibility or page restoration returns. Already disclosed
information cannot be withdrawn from a browser or copied records.
The query reads durable receipts directly and joins only current administrator
names. It does not join saved games, messages or player credentials. Hashes,
access/session/recovery secrets, session IDs, game state, cards and private
messages are excluded. Raw metadata never reaches the response. Ordinary room
hosts and unauthenticated callers receive no audit history. The route is GET-only
and uses the existing no-store, cookie-varying administration boundary.
## Evidence and remaining scope
Implementation: [query/projection](../db/admin-audit.ts),
[response contract](../lib/admin-audit.ts),
[route](../app/api/admin/audit/route.ts), [page](../app/admin/audit/page.tsx).
No schema migration is needed.
```sh
npm test -- admin-audit
npm run test:integration -- admin-boundary
```
Focused checks cover nine sources, shared operation IDs, deleted targets,
malformed/secret-shaped metadata, reason visibility and search exclusion,
commit-time demotion/revocation/expiry, UTC dates, deterministic pagination and
strict client allowlists. A real room-creation/configuration transaction verifies
the one-based player-circle display. The dedicated operator HTTP verifier reads
its own newly created moderation receipts and checks account binding.
Independent review corrected malformed role coercion, rejected-field search
(including embedded NULs), affected-account name lookup and position-only
configuration evidence. An isolated workerd/D1 regression exercises all nine
sources under its five-term compound-query limit, with live role demotion and
filtered reads. Materialized groups keep the unified query within that limit.
Existing records cover successful operations only. Failed/rejected attempts,
additional account-management actions, full operational health/diagnostics,
backup/import/restore and the remaining participant support are still required.
Local/browser/source-bound evidence is separate from deployed acceptance, which
must identify the actual running revision and use authorized QA access.
+3 -2
View File
@@ -15,7 +15,7 @@ recovery and persistence systems where suitable.
| Lifecycle and removal | [Pause/resume and joining locks](ADMIN_ROOM_CONTROLS.md), [administrative close/reopen](ADMIN_ROOM_CLOSURE.md) and [recoverable removal/restoration](ADMIN_ROOM_REMOVAL.md) have controls, durable audit, exact retries and player/AI enforcement. [Archive/unarchive](ADMIN_ROOM_ARCHIVE.md) has separate directory filters and exact retries. Permanent deletion and bulk actions remain missing. | Prototyped, partial | | Lifecycle and removal | [Pause/resume and joining locks](ADMIN_ROOM_CONTROLS.md), [administrative close/reopen](ADMIN_ROOM_CLOSURE.md) and [recoverable removal/restoration](ADMIN_ROOM_REMOVAL.md) have controls, durable audit, exact retries and player/AI enforcement. [Archive/unarchive](ADMIN_ROOM_ARCHIVE.md) has separate directory filters and exact retries. Permanent deletion and bulk actions remain missing. | Prototyped, partial |
| Participant support | [Participant AI](ADMIN_PARTICIPANT_AI.md) enables an existing difficulty on an eligible human seat in a paused game, retaining access/takeback and private custody. [Discussion controls](ADMIN_DISCUSSION.md) mute/unmute new sends while retaining history and gameplay. Participant removal, access revocation, assisted recovery and further replacement remain. Existing lobby controls can reassign the host. | Prototyped, partial | | Participant support | [Participant AI](ADMIN_PARTICIPANT_AI.md) enables an existing difficulty on an eligible human seat in a paused game, retaining access/takeback and private custody. [Discussion controls](ADMIN_DISCUSSION.md) mute/unmute new sends while retaining history and gameplay. Participant removal, access revocation, assisted recovery and further replacement remain. Existing lobby controls can reassign the host. | Prototyped, partial |
| Saved-game operations | [Owner-only room backups](ADMIN_BACKUPS.md) capture/list/download a size-limited, version-fenced room snapshot without changing play or access; import validation, safe checkpoint restore and interrupted-work diagnosis/resume remain missing. | Prototyped, partial | | Saved-game operations | [Owner-only room backups](ADMIN_BACKUPS.md) capture/list/download a size-limited, version-fenced room snapshot without changing play or access; import validation, safe checkpoint restore and interrupted-work diagnosis/resume remain missing. | Prototyped, partial |
| Operations and audit | The owner-only [operations page](../app/admin/operations/page.tsx) samples build revision, room/seat flags, JSON syntax failures and backup/export counts without exposing game contents. Searchable action history, failed-attempt records, integrity/disk checks, stalled-decision diagnosis and maintenance controls remain. | Prototyped, partial | | Operations and audit | [Action history](ADMIN_ACTION_HISTORY.md) searches existing durable operations with safe settings and role-limited reasons. The owner-only [operations page](../app/admin/operations/page.tsx) samples build revision, room/seat flags, JSON syntax failures and backup/export counts without exposing game contents. Failed-attempt records, integrity/disk checks, stalled-decision diagnosis and maintenance controls remain. | Prototyped, partial |
Track each area through Missing, Prototyped, Integrated, Verified and Polished. Track each area through Missing, Prototyped, Integrated, Verified and Polished.
As implementation begins, link its controls, server actions, persistence, As implementation begins, link its controls, server actions, persistence,
@@ -85,7 +85,8 @@ Implementation: [panel](../app/admin/page.tsx), [access](../db/admin-access.ts),
[additive migration](../drizzle/0008_admin_access.sql), and [additive migration](../drizzle/0008_admin_access.sql), and
[operator CLI](../tools/admin-access.mjs). The migration's explicit SQLite triggers [operator CLI](../tools/admin-access.mjs). The migration's explicit SQLite triggers
enforce final-owner protection and account revocation/provisioning audit; retain enforce final-owner protection and account revocation/provisioning audit; retain
them when generating future schema changes. Audit browsing is still pending. them when generating future schema changes. [Action history](ADMIN_ACTION_HISTORY.md)
unifies the existing successful-operation records; broader audit coverage remains.
### Initial owner and access recovery ### Initial owner and access recovery
+13
View File
@@ -92,6 +92,19 @@ authorization and desktop/mobile local UI checks passed. It does not inspect
SQLite integrity, disk space, stalled turns or restore safety; no live NAS SQLite integrity, disk space, stalled turns or restore safety; no live NAS
deployment or production administrator acceptance is claimed. deployment or production administrator acceptance is claimed.
Current work adds [action history](ADMIN_ACTION_HISTORY.md): a searchable,
paginated view of nine durable admin receipt sources with public before/after
settings. Owners/operators receive operational reasons; viewers do not. Queries
preserve live authority and reject private or malformed metadata from search.
Failed-attempt coverage, operational health and the remaining full admin scope
are still unfinished.
Discussion checkpoint `160fdc1` is pushed. Types/lint, 5,431 offline tests,
52 HTTP tests, build, twenty-four administrator HTTP groups and desktop/mobile
browser checks passed; all 2,230 baseline games and 4,746 seats were preserved.
Its container workflow `36218058095` succeeded; the later NAS Apps deployment
and current live revision are recorded above.
The bounded [Fremen Nexus Cunning](NEXUS_FREMEN_RULES.md#connected-native-cunning-path) The bounded [Fremen Nexus Cunning](NEXUS_FREMEN_RULES.md#connected-native-cunning-path)
prototype connects an initially empty worm's public offer, one spent private prototype connects an initially empty worm's public offer, one spent private
card, a Karama response, a typed remote ride after the Nexus, human controls card, a Karama response, a typed remote ride after the Nexus, human controls
+25
View File
@@ -1,5 +1,30 @@
# Dune implementation status # Dune implementation status
## 29 September 2026 — administrator action history prototype
The existing [Action history](ADMIN_ACTION_HISTORY.md) draft now has a bounded
source-bound checkpoint. Its read-only endpoint joins nine durable receipt
families, projects only public operational settings and identifier/name fields,
and returns reasons only to a live owner/operator role. Search excludes raw
JSON, messages, saved games, secrets and reasons; pagination/count and role
rechecks share the same database batch. Viewer and anonymous access stay
restricted. No game row is read or changed by the history query.
Ten focused offline audit/D1 cases pass, including a workerd compound-query
regression, malformed metadata, deleted accounts, filtered pagination and
commit-time demotion/revocation. The administrator HTTP boundary passes two
focused cases (54/54 in the last full local HTTP suite). An isolated non-test
in-memory query returned only the two account-event envelopes, without
credential contents. Browser inspection showed signed-out denial and read-only
operator/viewer fixtures at desktop/mobile widths; viewer reasons stayed
hidden and mobile had no horizontal overflow. Independent read-only security
review found no concrete authorization or privacy defect. Typecheck, lint,
5,756/5,756 offline tests and the app build passed with this draft present.
The dedicated signed-in QA operator HTTP verifier was not run: it revokes
all sessions for its account and requires a separate authorized QA key.
This is not deployed acceptance or complete administration; failed-attempt
history, broader operations and backup/restore remain pending.
## 29 September 2026 — Fremen Nexus Betrayal pre-blow worm suppression ## 29 September 2026 — Fremen Nexus Betrayal pre-blow worm suppression
The second printed Betrayal alternative now spends the physical Fremen Nexus The second printed Betrayal alternative now spends the physical Fremen Nexus
+39
View File
@@ -0,0 +1,39 @@
export const AUDIT_CATEGORIES = {
account: 'Administrator access', control: 'Pause and joining', creation: 'Room creation', lobby: 'Lobby configuration',
removal: 'Room removal', closure: 'Close and reopen', archive: 'Room archive', participant_ai: 'Participant AI', discussion: 'Discussion moderation',
} as const;
export type AuditCategory = keyof typeof AUDIT_CATEGORIES;
export const AUDIT_ACTIONS = {
provision: 'Provision administrator', revoke: 'Revoke administrator', login: 'Sign in', logout: 'Sign out', logout_all: 'Sign out everywhere',
room_control: 'Apply room controls', create: 'Create room', rules: 'Change rules', techTokens: 'Change Tech Tokens', strongholdCards: 'Change Stronghold Cards',
addBot: 'Add AI player', removeBot: 'Remove AI player', assignHost: 'Assign host', configureBot: 'Configure AI player',
remove: 'Remove room', restore: 'Restore room', close: 'Close room', reopen: 'Reopen room', archive: 'Archive room', unarchive: 'Unarchive room',
participant_ai: 'Enable participant AI', mute: 'Mute discussion', unmute: 'Unmute discussion', unknown: 'Unrecognized recorded action',
} as const;
export type AuditAction = keyof typeof AUDIT_ACTIONS;
export const AUDIT_FIELDS = ['Role','Rules','Tech Tokens','Stronghold Cards','Host seat','Host faction','AI seats','Player count','Paused','Joining locked','Removed','Closed','Archived','Game version','Setting revision','Difficulty','Discussion muted','Player circle'] as const;
export type AuditChange = { field: typeof AUDIT_FIELDS[number]; before: string | null; after: string | null };
export type AdminAuditEvent = {
id: string; category: AuditCategory; action: AuditAction; createdAt: number | null;
actorId: string | null; actorName: string | null; targetAdminId: string | null; targetAdminName: string | null;
roomCode: string | null; targetSeatId: string | null;
reason: string | null; outcome: 'recorded'; changes: AuditChange[];
};
export type AdminAuditPage = { events: AdminAuditEvent[]; total: number; page: number; pageSize: 25; reasonsVisible: boolean };
const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
const keys = (v: Record<string, unknown>, list: string[]) => Object.keys(v).length === list.length && list.every(k => Object.hasOwn(v,k));
const nullableText = (v: unknown, max: number) => v === null || typeof v === 'string' && v.length <= max;
export function adminAuditResponse(value: unknown): AdminAuditPage {
if (!object(value) || !keys(value,['events','total','page','pageSize','reasonsVisible']) || value.pageSize !== 25 ||
!Number.isSafeInteger(value.total) || Number(value.total) < 0 || !Number.isSafeInteger(value.page) || Number(value.page) < 1 ||
typeof value.reasonsVisible !== 'boolean' || !Array.isArray(value.events) || value.events.length > 25 || !value.events.every(e =>
object(e) && keys(e,['id','category','action','createdAt','actorId','actorName','targetAdminId','targetAdminName','roomCode','targetSeatId','reason','outcome','changes']) &&
typeof e.id === 'string' && e.id.length <= 100 && typeof e.category === 'string' && Object.hasOwn(AUDIT_CATEGORIES,e.category) &&
typeof e.action === 'string' && Object.hasOwn(AUDIT_ACTIONS,e.action) && e.outcome === 'recorded' &&
(e.createdAt === null || Number.isSafeInteger(e.createdAt) && Number(e.createdAt) >= 0 && Number(e.createdAt) <= 8640000000000000) &&
['actorId','targetAdminId','targetSeatId'].every(k => nullableText(e[k],80)) && nullableText(e.actorName,160) && nullableText(e.targetAdminName,160) && nullableText(e.roomCode,8) &&
nullableText(e.reason,300) && (value.reasonsVisible || e.reason === null) && Array.isArray(e.changes) && e.changes.length <= AUDIT_FIELDS.length &&
e.changes.every(c => object(c) && keys(c,['field','before','after']) && AUDIT_FIELDS.some(f => f === c.field) && nullableText(c.before,240) && nullableText(c.after,240))))
throw new Error('The server returned unreadable action history. Refresh to try again.');
return value as AdminAuditPage;
}
+189
View File
@@ -0,0 +1,189 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { DatabaseSync } from 'node:sqlite';
import { adminStore } from './admin-access-fixture';
import { adminLogin, requireAdmin, AdminError, type AdminRole } from '../db/admin-access';
import { createAdminRoom } from '../db/admin-room-creation';
import { readAdminLobby, configureAdminLobby } from '../db/admin-lobby-configuration';
import { readAdminAudit } from '../db/admin-audit';
import { adminAuditResponse, AUDIT_CATEGORIES } from '../lib/admin-audit';
const now = Date.parse('2026-09-26T12:00:00Z'), room = 'AUDITQAA';
const privateValue = 'PRIVATE_GAME_OR_CREDENTIAL_SENTINEL', reason = 'Operational reason sentinel';
async function fixture(role: AdminRole = 'operator') {
const f = adminStore(); assert.ok(f.sqlite instanceof DatabaseSync);
const account = f.provision(role, 'QA _operator'), target = f.provision('viewer','Target account');
const login = await adminLogin(f.database,account.key,now);
const identity = await requireAdmin(f.database,login.token,undefined,now);
f.sqlite.exec('DELETE FROM admin_audit');
const seat = randomUUID(), operation = randomUUID();
function insert(table: string, row: Record<string,string | number | null>) {
const keys=Object.keys(row); f.sqlite.prepare(`INSERT INTO ${table}(${keys.join(',')}) VALUES(${keys.map(()=>'?').join(',')})`).run(...Object.values(row));
}
const core = { operation_id:operation,actor_admin_id:account.id,room_code:room,reason,created_at:now-1000 };
const snapshot = JSON.stringify({version:7,revision:2,paused:true,joinLocked:false,removed:false,closed:false,archived:false,private:privateValue});
const config = JSON.stringify({host:seat,advanced:false,techTokens:false,strongholdCards:false,players:[{id:seat,name:'Public player',faction:'atreides',bot:null}],private:privateValue});
insert('admin_audit',{id:'a'.repeat(32),actor_admin_id:null,target_admin_id:target.id,action:'provision',created_at:now-1000,detail:JSON.stringify({role:'viewer',sessionId:privateValue,private:privateValue})});
insert('admin_room_audit',{...core,action:'room_control',expected_revision:1,before_paused:0,before_join_locked:0,after_paused:1,after_join_locked:0});
insert('admin_room_creations',{...core,host_id:seat,request_hash:privateValue,session_hash:privateValue,configuration:JSON.stringify({name:'Public player',faction:'atreides',advanced:false,techTokens:false,strongholdCards:false,bots:[],private:privateValue})});
insert('admin_lobby_operations',{...core,request_hash:privateValue,expected_version:6,applied_version:7,action:JSON.stringify({type:'assignHost',target:seat,private:privateValue}),before_configuration:config,after_configuration:config});
for (const [table,flag] of [['admin_room_removals','removed'],['admin_room_closures','closed'],['admin_room_archives','archived']])
insert(table,{...core,request_hash:privateValue,expected_version:6,expected_revision:1,applied_version:7,applied_revision:2,[flag]:0,before_metadata:snapshot,after_metadata:snapshot});
insert('admin_seat_ai_operations',{...core,request_hash:privateValue,expected_version:6,expected_control_revision:1,applied_version:7,target:seat,difficulty:'Hard'});
insert('admin_discussion_operations',{...core,request_hash:privateValue,expected_version:7,expected_revision:1,applied_revision:2,target:seat,muted:1});
const read = (search='') => readAdminAudit(f.database,identity,new URLSearchParams(search),now);
return {...f,account,target,identity,seat,operation,insert,core,read};
}
void test('unified audit covers all nine sources without reading saved games or exposing private JSON, sessions or hashes',async () => {
const f = await fixture();
try {
const page = await f.read(); adminAuditResponse(page);
assert.equal(page.total,9); assert.equal(page.events.length,9);
assert.deepEqual(page.events.map(e=>e.category).sort(),Object.keys(AUDIT_CATEGORIES).sort());
assert.equal(new Set(page.events.map(e=>e.id)).size,9,'same operation UUID in different tables must remain distinct');
assert.doesNotMatch(JSON.stringify(page),new RegExp(privateValue+'|sessionId|request_hash|session_hash|key_hash|before_json|after_json'));
assert.equal(page.reasonsVisible,true); assert.equal(page.events.filter(e=>e.reason===reason).length,8);
const external=page.events.find(e=>e.category==='account')!;
assert.equal(external.actorId,null); assert.equal(external.action,'provision'); assert.equal(external.targetAdminId,f.target.id);
assert.deepEqual(external.changes,[{field:'Role',before:null,after:'viewer'}]);
const lobby=page.events.find(e=>e.category==='lobby')!;
assert.equal(lobby.action,'assignHost'); assert.equal(lobby.targetSeatId,f.seat);
assert.ok(lobby.changes.some(c=>c.field==='Host seat' && c.after===f.seat));
assert.equal(f.sqlite.prepare('SELECT COUNT(*) n FROM rooms').get()!.n,0,'historical events exist independently of rooms');
} finally {f.sqlite.close();}
});
void test('nine-source audit executes within workerd D1 compound-query limits',async () => {
const { Miniflare } = await import('miniflare');
const f = await fixture();
const worker = new Miniflare({ modules:true,script:'export default {fetch(){return new Response("ok")}}',compatibilityDate:'2026-05-15',d1Databases:['DB'] });
try {
const db = await worker.getD1Database('DB');
// Copy the isolated fixture, never the running server's database. Install
// its triggers after copying rows so provisioning does not add new receipts.
const schema = f.sqlite.prepare("SELECT type,name,sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 ELSE 2 END").all() as {type:string;name:string;sql:string}[];
for (const entry of schema.filter(e=>e.type!=='trigger')) await db.prepare(entry.sql).run();
for (const entry of schema.filter(e=>e.type==='table')) {
for (const row of f.sqlite.prepare(`SELECT * FROM "${entry.name}"`).all()) {
const columns=Object.keys(row);
await db.prepare(`INSERT INTO "${entry.name}" (${columns.map(c=>'"'+c+'"').join(',')}) VALUES (${columns.map(()=>'?').join(',')})`).bind(...Object.values(row)).run();
}
}
for (const entry of schema.filter(e=>e.type==='trigger')) await db.prepare(entry.sql).run();
const read = (query='') => readAdminAudit(db as unknown as D1Database,f.identity,new URLSearchParams(query),now);
const result=await read(); adminAuditResponse(result);
assert.equal(result.total,9); assert.deepEqual(result,await f.read());
assert.equal((await read('q='+room+'&category=discussion')).total,1);
await db.prepare("UPDATE admin_accounts SET role='viewer' WHERE id=?").bind(f.account.id).run();
const viewer=await read(); assert.equal(viewer.reasonsVisible,false); assert.ok(viewer.events.every(e=>e.reason===null));
} finally { await worker.dispose(); f.sqlite.close(); }
});
void test('viewer hides reasons and reasons never influence searches or counts for any role',async () => {
for (const role of ['viewer','operator'] as const) {
const f = await fixture(role);
try {
const result=await f.read();adminAuditResponse(result);
assert.equal(result.reasonsVisible,role==='operator');
if(role==='viewer')assert.ok(result.events.every(e=>e.reason===null));
for (const q of [reason,privateValue,'%','\\']) assert.equal((await f.read('q='+encodeURIComponent(q))).total,0);
assert.equal((await f.read('q='+encodeURIComponent('QA _operator'))).total,8);
assert.equal((await f.read('q='+f.seat)).total,4); // creation, host assignment, AI and discussion
} finally {f.sqlite.close();}
}
});
void test('live demotion hides reasons even when the prior identity claims operator; revocation/expiry fails closed',async () => {
const f=await fixture();
try {
f.hooks.beforeBatch=async()=>{delete f.hooks.beforeBatch;f.sqlite.prepare("UPDATE admin_accounts SET role='viewer' WHERE id=?").run(f.account.id);};
const result=await f.read();assert.equal(result.reasonsVisible,false);assert.ok(result.events.every(e=>e.reason===null));
for(const sql of ['UPDATE admin_sessions SET revoked_at=1','UPDATE admin_sessions SET revoked_at=NULL,expires_at=1','UPDATE admin_sessions SET expires_at=9999999999999,generation=generation+1']) {
f.sqlite.exec(sql);await assert.rejects(f.read,(error:unknown)=>error instanceof AdminError && error.status===401);
}
} finally {f.sqlite.close();}
});
void test('history survives account deletion and malformed metadata without revealing raw contents',async () => {
const f=await fixture();
try {
f.sqlite.prepare('DELETE FROM admin_accounts WHERE id=?').run(f.target.id);
f.sqlite.prepare('UPDATE admin_lobby_operations SET action=?,before_configuration=?,after_configuration=?').run('{broken',JSON.stringify({private:privateValue}),JSON.stringify([privateValue]));
f.sqlite.prepare('UPDATE admin_room_creations SET configuration=?').run('"'+privateValue+'"');
f.sqlite.prepare('UPDATE admin_audit SET action=?,detail=?').run(privateValue,JSON.stringify({role:privateValue,sessionId:privateValue}));
const page=await f.read();adminAuditResponse(page);assert.equal(page.total,9);
assert.doesNotMatch(JSON.stringify(page),new RegExp(privateValue));
assert.equal(page.events.find(e=>e.category==='account')!.action,'unknown');
assert.equal(page.events.find(e=>e.category==='lobby')!.action,'unknown');
} finally {f.sqlite.close();}
});
void test('equal-time events page deterministically and filters apply before pagination',async () => {
const f=await fixture();
try {
for(let i=0;i<58;i++)f.insert('admin_discussion_operations',{...f.core,operation_id:randomUUID(),request_hash:privateValue,expected_version:7,expected_revision:2,applied_revision:3,target:f.seat,muted:0});
const ids=[];
for(let page=1;page<=3;page++) {
const result=await f.read('category=discussion&page='+page);assert.equal(result.total,59);
ids.push(...result.events.map(e=>e.id));
assert.deepEqual(await f.read('category=discussion&page='+page),result);
}
assert.equal(ids.length,59);assert.equal(new Set(ids).size,59);
assert.equal((await f.read('category=discussion&from=2026-09-26&to=2026-09-26')).total,59);
assert.equal((await f.read('from=2026-09-27')).total,0);
assert.equal((await f.read('to=2026-09-25')).total,0);
for(const bad of ['q=x&q=y','category=private','page=0','page=1e3','page=9999999','from=2026-02-30','from=2026-09-27&to=2026-09-26','secret=x'])
await assert.rejects(()=>f.read(bad),(error:unknown)=>error instanceof AdminError && error.status===400);
} finally {f.sqlite.close();}
});
void test('client rejects private or malformed audit fields rather than rendering unknown server data',async () => {
const f=await fixture();
try {
const page=await f.read();
for(const bad of [{...page,state:{}},{...page,reasonsVisible:false},{...page,events:[{...page.events[0],sessionHash:privateValue}]},
{...page,events:[{...page.events[0],changes:[{field:'Private hand',before:null,after:privateValue}]}]},
{...page,events:[{...page.events[0],createdAt:Infinity}]}])assert.throws(()=>adminAuditResponse(bad));
} finally {f.sqlite.close();}
});
void test('malformed roles and rejected target metadata cannot crash history or become a viewer search oracle',async () => {
const f=await fixture('viewer');
try {
for(const invalid of [{toString:null},['owner'],{nested:privateValue}]) {
f.sqlite.prepare('UPDATE admin_audit SET detail=?').run(JSON.stringify({role:invalid,previousRole:invalid}));
const page=await f.read();adminAuditResponse(page);assert.deepEqual(page.events.find(e=>e.category==='account')!.changes,[]);
}
for(const target of [{private:privateValue},[privateValue],privateValue,7]) {
f.sqlite.prepare('UPDATE admin_lobby_operations SET action=?').run(JSON.stringify({type:'configureBot',target}));
const page=await f.read('category=lobby');assert.equal(page.events[0].targetSeatId,null);
assert.equal((await f.read('q='+encodeURIComponent(privateValue))).total,0);
}
f.sqlite.prepare('UPDATE admin_room_audit SET room_code=?,actor_admin_id=?').run(privateValue,privateValue);
assert.equal((await f.read('q='+encodeURIComponent(privateValue))).total,0);
f.sqlite.prepare('UPDATE admin_room_audit SET room_code=?,actor_admin_id=?').run('SECRETAA\0hidden',f.account.id+'\0hidden');
assert.equal((await f.read('q=SECRETAA')).total,0);
assert.equal((await f.read('category=control&q='+f.account.id)).total,0);
f.sqlite.prepare('UPDATE admin_accounts SET name=? WHERE id=?').run(privateValue+'\n',f.account.id);
assert.equal((await f.read('q='+encodeURIComponent(privateValue))).total,0);
} finally {f.sqlite.close();}
});
void test('external account operations match affected account names and position-only AI changes show the actual circles',async () => {
const f=await fixture();
try {
const found=await f.read('q='+encodeURIComponent('Target account'));
assert.equal(found.total,1);assert.equal(found.events[0].actorId,null);assert.equal(found.events[0].targetAdminName,'Target account');
const config=(position:number)=>JSON.stringify({advanced:false,techTokens:false,strongholdCards:false,host:f.seat,players:[{id:f.seat,faction:'atreides',bot:'Hard',position}]});
f.sqlite.prepare('UPDATE admin_lobby_operations SET action=?,before_configuration=?,after_configuration=?').run(JSON.stringify({type:'configureBot',target:f.seat}),config(1),config(6));
const page=await f.read('category=lobby');adminAuditResponse(page);
assert.deepEqual(page.events[0].changes.find(c=>c.field==='Player circle'),{field:'Player circle',before:'1',after:'6'});
} finally {f.sqlite.close();}
});
void test('real room creation and position-only configuration receipts display the authoritative player circles',async () => {
const f=await fixture();
try {
const created=await createAdminRoom(f.database,f.identity,{operationId:randomUUID(),sessionToken:'b'.repeat(64),name:'Audit host',faction:'atreides',advanced:false,techTokens:false,strongholdCards:false,bots:[{faction:'emperor',difficulty:'Hard'}],reason:'Audit genuine operation'},now);
const lobby=await readAdminLobby(f.database,f.identity,created.code,now), bot=lobby.players.find(p=>p.bot)!;
assert.equal(bot.position,2);
await configureAdminLobby(f.database,f.identity,created.code,{operationId:randomUUID(),expectedVersion:lobby.version,action:{type:'configureBot',target:bot.id,faction:'emperor',difficulty:'Hard',position:6},reason:'Move the player circle'},now);
const page=await f.read('category=lobby&q='+created.code);adminAuditResponse(page);
assert.equal(page.events.length,1);
assert.deepEqual(page.events[0].changes.find(c=>c.field==='Player circle'),{field:'Player circle',before:'2',after:'6'});
} finally {f.sqlite.close();}
});
+1 -1
View File
@@ -13,7 +13,7 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden
assert.ok(cookie); assert.ok(cookie);
const room = (await created.json() as { code: string }).code; const room = (await created.json() as { code: string }).code;
for (const headers of [{}, { cookie }, { cookie: 'dune_admin_session=' + 'a'.repeat(64) }, { 'oai-authenticated-user-id': 'owner', 'oai-authenticated-user-email': 'owner@example.test' }] as Record<string, string>[]) { for (const headers of [{}, { cookie }, { cookie: 'dune_admin_session=' + 'a'.repeat(64) }, { 'oai-authenticated-user-id': 'owner', 'oai-authenticated-user-email': 'owner@example.test' }] as Record<string, string>[]) {
for (const path of ['/api/admin/session', '/api/admin/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`, `/api/admin/rooms/${room}/removal`, `/api/admin/rooms/${room}/closure`, `/api/admin/rooms/${room}/archive`, `/api/admin/rooms/${room}/seat-ai`, `/api/admin/rooms/${room}/discussion`]) { for (const path of ['/api/admin/session', '/api/admin/audit', '/api/admin/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`, `/api/admin/rooms/${room}/removal`, `/api/admin/rooms/${room}/closure`, `/api/admin/rooms/${room}/archive`, `/api/admin/rooms/${room}/seat-ai`, `/api/admin/rooms/${room}/discussion`]) {
const result = await fetch(base + path, { headers, signal: AbortSignal.timeout(15000) }); const result = await fetch(base + path, { headers, signal: AbortSignal.timeout(15000) });
assert.equal(result.status, 401, path); assert.equal(result.status, 401, path);
assert.equal(result.headers.get('cache-control'), 'no-store'); assert.equal(result.headers.get('cache-control'), 'no-store');
+14 -1
View File
@@ -37,7 +37,7 @@ async function request(path, body, cookie, origin = base, expectedAdminId = admi
const response = await fetch(base + path, { const response = await fetch(base + path, {
method: body === undefined ? 'GET' : 'POST', method: body === undefined ? 'GET' : 'POST',
headers: { ...(body === undefined ? {} : { 'content-type': 'application/json', origin }), ...(cookie ? { cookie } : {}), headers: { ...(body === undefined ? {} : { 'content-type': 'application/json', origin }), ...(cookie ? { cookie } : {}),
...(expectedAdminId && (path === '/api/admin/rooms' && body !== undefined || /^\/api\/admin\/rooms\/[^/]+\/(control|lobby|removal|closure|archive|seat-ai|discussion)$/.test(path)) ? { 'X-Dune-Admin-Id': expectedAdminId } : {}) }, ...(expectedAdminId && (path === '/api/admin/audit' || path.startsWith('/api/admin/audit?') || path === '/api/admin/rooms' && body !== undefined || /^\/api\/admin\/rooms\/[^/]+\/(control|lobby|removal|closure|archive|seat-ai|discussion)$/.test(path)) ? { 'X-Dune-Admin-Id': expectedAdminId } : {}) },
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15000), ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15000),
}); });
report.lastRequest = { path, status: response.status }; report.lastRequest = { path, status: response.status };
@@ -98,6 +98,19 @@ try {
await verifyParticipantAi(request, session1, report); await verifyParticipantAi(request, session1, report);
await verifyDiscussionModeration(request, session1, report); await verifyDiscussionModeration(request, session1, report);
} }
if (report.discussionRoom) {
const history = await request('/api/admin/audit?q=' + report.discussionRoom + '&category=discussion', undefined, session1);
assert.equal(history.status, 200); assert.equal(history.data.total, 2);
assert.equal(history.data.reasonsVisible, true);
assert.deepEqual(history.data.events.map(event => event.action).sort(), ['mute','unmute']);
for (const event of history.data.events) {
assert.equal(event.roomCode, report.discussionRoom); assert.equal(event.actorId, adminId);
assert.equal(event.reason, 'Discussion moderation QA');
for (const field of ['state','sessionHash','request_hash','session_hash','messages','before_json','after_json']) assert.equal(Object.hasOwn(event, field), false);
}
assert.equal((await request('/api/admin/audit', undefined, session1, undefined, crypto.randomUUID())).status, 401);
report.checks.push('Unified action history returns durable moderation events, safe settings and operator reasons; account mismatch is denied');
}
report.passed = true; report.passed = true;
} catch (error) { } catch (error) {
// Do not serialize assertion payloads: failed authentication responses might contain credentials. // Do not serialize assertion payloads: failed authentication responses might contain credentials.