diff --git a/README.md b/README.md index efa0f482..6c13b5c6 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ game while preserving their seat and takeback control. [Discussion controls](docs/ADMIN_DISCUSSION.md) mute or unmute new messages from a human seat while preserving history and gameplay. Permanent deletion, further 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 diff --git a/app/admin/admin.css b/app/admin/admin.css index 57652119..4d1ab266 100644 --- a/app/admin/admin.css +++ b/app/admin/admin.css @@ -60,3 +60,18 @@ .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-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; } +} diff --git a/app/admin/audit/page.tsx b/app/admin/audit/page.tsx new file mode 100644 index 00000000..7dddd3c6 --- /dev/null +++ b/app/admin/audit/page.tsx @@ -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(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('/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
+
Administration

Action history

+
+

Recorded administrator actions, newest first. This history describes what was applied; later actions may have changed the room again.

+ {notice &&

{notice}

} + {denied ?

Sign in to administration

: <> +
+ + + + + Clear filters +
+ {loading && Loading action history…} + {result &&
+

{result.total.toLocaleString()} recorded {result.total === 1 ? 'action' : 'actions'}

+

{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.

+ {result.events.length === 0 ?

No records on this page. Adjust the filters or return to the first page.

:
    {result.events.map(event =>
  1. +
    +

    {AUDIT_ACTIONS[event.action]}

    {event.createdAt === null ? Time unavailable : }
    +

    Recorded {AUDIT_CATEGORIES[event.category]}{event.roomCode && <> · Room {event.roomCode}}

    +
    +
    Administrator
    {event.actorId ? <>{event.actorName ?? 'Account no longer available'} ({event.actorId}) : 'External account operation; no administrator attribution was recorded'}
    + {event.targetAdminId && <>
    Account affected
    {event.targetAdminName ?? 'Account no longer available'} ({event.targetAdminId})
    } + {event.targetSeatId && <>
    Seat affected
    {event.targetSeatId}
    } + {event.reason !== null && <>
    Reason
    {event.reason}
    } +
    + {event.changes.length > 0 &&
    Recorded settings
    {event.changes.map(change =>
    +
    {change.field}
    {change.before !== null ? <>Before: {change.before}
    : null}{change.after !== null ? <>After: {change.after} : null}
    +
    )}
    } +
    +
  2. )}
} + +

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.

+
} + } +
; +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 977b725a..72a2df80 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -123,7 +123,7 @@ export default function Administration() { : <>

Rooms

Public room details. Cards, sealed choices and private discussion stay hidden.

-
{account.role !== 'viewer' && } +
Action history{account.role !== 'viewer' && } {account.role === 'owner' && Room backups} {account.role === 'owner' && Operations}
diff --git a/app/api/admin/audit/route.ts b/app/api/admin/audit/route.ts new file mode 100644 index 00000000..36fb3613 --- /dev/null +++ b/app/api/admin/audit/route.ts @@ -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); } +} diff --git a/db/admin-audit.ts b/db/admin-audit.ts new file mode 100644 index 00000000..fe36b78a --- /dev/null +++ b/db/admin-audit.ts @@ -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 => !!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): Record { + 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) => 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 { + 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 projectAdminAudit(row,reasonsVisible)),total:Number((results[1].results[0] as { total: number }).total),page:Number(rawPage),pageSize:25,reasonsVisible }; +} diff --git a/docs/ADMIN_ACTION_HISTORY.md b/docs/ADMIN_ACTION_HISTORY.md new file mode 100644 index 00000000..ccdee233 --- /dev/null +++ b/docs/ADMIN_ACTION_HISTORY.md @@ -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. diff --git a/docs/ADMIN_PANEL.md b/docs/ADMIN_PANEL.md index 75e8d57e..f6eab8f0 100644 --- a/docs/ADMIN_PANEL.md +++ b/docs/ADMIN_PANEL.md @@ -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 | | 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 | -| 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. 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 [operator CLI](../tools/admin-access.mjs). The migration's explicit SQLite triggers 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 diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 6f8e9ad4..a7a3a962 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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 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) 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 diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 7344c915..e36aaea7 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,30 @@ # 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 The second printed Betrayal alternative now spends the physical Fremen Nexus diff --git a/lib/admin-audit.ts b/lib/admin-audit.ts new file mode 100644 index 00000000..2f4bbec1 --- /dev/null +++ b/lib/admin-audit.ts @@ -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 => !!v && typeof v === 'object' && !Array.isArray(v); +const keys = (v: Record, 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; +} diff --git a/tests/admin-audit.test.ts b/tests/admin-audit.test.ts new file mode 100644 index 00000000..da1c5ae2 --- /dev/null +++ b/tests/admin-audit.test.ts @@ -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) { + 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();} +}); diff --git a/tests/admin-boundary-http.test.ts b/tests/admin-boundary-http.test.ts index 02d6110e..a1293472 100644 --- a/tests/admin-boundary-http.test.ts +++ b/tests/admin-boundary-http.test.ts @@ -13,7 +13,7 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden assert.ok(cookie); 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[]) { - 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) }); assert.equal(result.status, 401, path); assert.equal(result.headers.get('cache-control'), 'no-store'); diff --git a/tools/verify-admin.mjs b/tools/verify-admin.mjs index dbbc2076..ac67eaea 100644 --- a/tools/verify-admin.mjs +++ b/tools/verify-admin.mjs @@ -37,7 +37,7 @@ async function request(path, body, cookie, origin = base, expectedAdminId = admi const response = await fetch(base + path, { method: body === undefined ? 'GET' : 'POST', 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), }); report.lastRequest = { path, status: response.status }; @@ -98,6 +98,19 @@ try { await verifyParticipantAi(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; } catch (error) { // Do not serialize assertion payloads: failed authentication responses might contain credentials.