Add reversible administrator room archiving

Organize closed rooms with archive filters, live-authorized atomic receipts and exact saved retries. Preserve saved games, timestamps, credentials, discussion and AI deadlines. Unarchive leaves play closed; reopen requires unarchive, and removal/restoration preserves both flags.

Verified types, lint, 5379 offline tests, 52 HTTP tests and build. Twenty dedicated administrator HTTP groups and independent privacy/persistence review passed. Desktop/mobile browser QA confirmed a single archive after a lost response and refresh, readable recovery text, mobile unarchive and separate reopening, and saved-seat restoration. All 2,049 baseline rooms, 4,348 seats and related records preserved. Authenticated production admin acceptance awaits authorized QA access; full admin and rules gates remain open.
This commit is contained in:
Quintine committed 2026-09-22 12:55:44 +10:00
1 parent 98b1b3261b
commit e918e2e0c0
35 files changed
+3624 -38

No files matched your search

+4 -2
View File
@@ -50,8 +50,10 @@ seats and host assignment without private seat access.
[Remove or restore](docs/ADMIN_ROOM_REMOVAL.md) recoverably removes selected rooms;
use the Directory filter to find them for restoration.
[Close or reopen](docs/ADMIN_ROOM_CLOSURE.md) stops further play while preserving
readable tables and history, then resumes the same saved position. Archive,
permanent deletion, participant support and backup tools are still being built.
readable tables and history, then resumes the same saved position.
[Archive or unarchive](docs/ADMIN_ROOM_ARCHIVE.md) organizes closed rooms while
retaining their tables and history; unarchiving leaves play closed.
Permanent deletion, participant support and backup tools are still being built.
## Saved seats and reconnecting
+15 -7
View File
@@ -11,13 +11,14 @@ import { FACTIONS } from '@/game/catalog';
import { AdminRoomControls } from '@/components/admin-room-controls';
import { AdminRoomCreation } from '@/components/admin-room-creation';
import { AdminLobbyControls } from '@/components/admin-lobby-controls';
import { AdminRoomArchive } from '@/components/admin-room-archive';
import { AdminRoomClosure } from '@/components/admin-room-closure';
import { AdminRoomRemoval } from '@/components/admin-room-removal';
import './admin.css';
type Account = { id: string; name: string; role: 'owner' | 'operator' | 'viewer' };
type Filters = { q: string; status: string; rules: string; sort: string; availability: string; removal: string };
const initialFilters: Filters = { q: '', status: 'all', rules: 'all', sort: 'recent', availability: 'all', removal: 'active' };
type Filters = { q: string; status: string; rules: string; sort: string; availability: string; removal: string; archive: string };
const initialFilters: Filters = { q: '', status: 'all', rules: 'all', sort: 'recent', availability: 'all', removal: 'active', archive: 'unarchived' };
const errorMessage = (error: unknown) => error instanceof Error ? error.message : 'The request could not be completed.';
export default function Administration() {
@@ -32,6 +33,7 @@ export default function Administration() {
const [confirmAll, setConfirmAll] = useState(false);
const [selectedRoom, setSelectedRoom] = useState<string | null>(null);
const [selectedLobby, setSelectedLobby] = useState<string | null>(null);
const [selectedArchive, setSelectedArchive] = useState<string | null>(null);
const [selectedClosure, setSelectedClosure] = useState<string | null>(null);
const [selectedRemoval, setSelectedRemoval] = useState<string | null>(null);
const [creatingRoom, setCreatingRoom] = useState(false);
@@ -41,7 +43,7 @@ export default function Administration() {
const clearSession = useCallback(() => {
epoch.current++;
setAccount(null); setDirectory(null); setConfirmAll(false);
setBusy(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setCreatingRoom(false);
setBusy(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setCreatingRoom(false);
}, []);
const accessChanged = useCallback(() => {
clearSession();
@@ -117,7 +119,7 @@ export default function Administration() {
</section> : <>
<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-room-actions">{account.role !== 'viewer' && <Button disabled={controlsBusy} onClick={() => { setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setCreatingRoom(true); }}>Create a room</Button>}
<div className="admin-room-actions">{account.role !== 'viewer' && <Button disabled={controlsBusy} onClick={() => { setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setCreatingRoom(true); }}>Create a room</Button>}
<Button variant="outline" disabled={controlsBusy} onClick={() => void load(applied, directory?.page ?? 1)}><RefreshCw aria-hidden="true" /> Refresh</Button></div></div>
{creatingRoom && account.role !== 'viewer' && <AdminRoomCreation key={account.id} accountId={account.id}
onClose={() => setCreatingRoom(false)} onDenied={accessChanged} onBusyChange={setOperationBusy}
@@ -127,6 +129,9 @@ export default function Administration() {
<label>Directory<select disabled={controlsBusy} value={filters.removal} onChange={e => setFilters({ ...filters, removal: e.target.value })}>
<option value="active">Active rooms</option><option value="removed">Removed rooms</option><option value="all">All rooms</option>
</select></label>
<label>Archive<select disabled={controlsBusy} value={filters.archive} onChange={e => setFilters({ ...filters, archive: e.target.value })}>
<option value="unarchived">Unarchived rooms</option><option value="archived">Archived rooms</option><option value="all">All rooms</option>
</select></label>
<label>Status<select disabled={controlsBusy} value={filters.status} onChange={e => setFilters({ ...filters, status: e.target.value })}>
<option value="all">All statuses</option><option value="lobby">Lobby</option><option value="setup">Setup</option><option value="playing">Playing</option><option value="finished">Finished</option>
</select></label>
@@ -147,6 +152,9 @@ export default function Administration() {
{selectedLobby && <AdminLobbyControls key={`${account.id}:${selectedLobby}`} accountId={account.id} code={selectedLobby}
canManage={account.role !== 'viewer'} onClose={() => setSelectedLobby(null)} onDenied={accessChanged}
onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />}
{selectedArchive && <AdminRoomArchive key={`${account.id}:${selectedArchive}`} accountId={account.id} code={selectedArchive}
canManage={account.role !== 'viewer'} onClose={() => setSelectedArchive(null)} onDenied={accessChanged}
onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />}
{selectedClosure && <AdminRoomClosure key={`${account.id}:${selectedClosure}`} accountId={account.id} code={selectedClosure}
canManage={account.role !== 'viewer'} onClose={() => setSelectedClosure(null)} onDenied={accessChanged}
onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />}
@@ -161,7 +169,7 @@ export default function Administration() {
<TableHead>Room & players</TableHead><TableHead>Rules</TableHead><TableHead>Progress</TableHead><TableHead>Last game change</TableHead>
</TableRow></TableHeader><TableBody>{directory.rooms.map(room => <TableRow key={room.code}>
<TableCell><strong className="admin-code">{room.code}</strong> <span className="admin-tag">{room.status}</span>
{room.removed && <span className="admin-tag">Removed</span>}
{room.archived && <span className="admin-tag">Archived</span>} {room.removed && <span className="admin-tag">Removed</span>}
{room.control.closed && <span className="admin-tag">Closed</span>}
{room.control.paused && <span className="admin-tag">Paused</span>}
{room.control.joinLocked && <span className="admin-tag">Joins locked</span>}
@@ -175,7 +183,7 @@ export default function Administration() {
<TableCell>{room.turn !== null && room.status !== 'lobby' ? `Turn ${room.turn} · ` : ''}{room.phase}
<p>{room.pending.label}{room.pending.owners.length > 0 ? ': ' + room.pending.owners.map(id => room.players.find(p => p.id === id)?.name).filter(Boolean).join(', ') : ''}</p>
</TableCell>
<TableCell><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(room.code); }}>Close or reopen · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedRoom(room.code); }}>Room controls · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedRoom(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedLobby(room.code); }}>Configure lobby · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(room.code); setSelectedClosure(null); }}>Remove or restore · {room.code}</Button><p><time dateTime={new Date(room.updatedAt).toISOString()}>{new Date(room.updatedAt).toLocaleString()}</time></p><p className="admin-secondary">Version {room.version}</p></TableCell>
<TableCell><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(room.code); }}>Archive or unarchive · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedArchive(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(room.code); }}>Close or reopen · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setSelectedRoom(room.code); }}>Room controls · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedRoom(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setSelectedLobby(room.code); }}>Configure lobby · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(room.code); setSelectedClosure(null); setSelectedArchive(null); }}>Remove or restore · {room.code}</Button><p><time dateTime={new Date(room.updatedAt).toISOString()}>{new Date(room.updatedAt).toLocaleString()}</time></p><p className="admin-secondary">Version {room.version}</p></TableCell>
</TableRow>)}</TableBody></Table>}
<nav className="admin-pagination" aria-label="Room pages">
<Button variant="outline" disabled={controlsBusy || directory.page <= 1} onClick={() => void load(applied, 1)}>First</Button>
@@ -191,7 +199,7 @@ export default function Administration() {
{!confirmAll ? <Button variant="outline" disabled={controlsBusy} onClick={() => setConfirmAll(true)}>Sign out everywhere…</Button> :
<div className="admin-confirm"><p>End all your administrator sessions now?</p><Button disabled={controlsBusy} onClick={() => void sessionAction('logoutAll')}>Confirm sign out everywhere</Button><Button variant="outline" disabled={controlsBusy} onClick={() => setConfirmAll(false)}>Cancel</Button></div>}
</section>
<p className="admin-secondary admin-development">Administration preview: create and configure lobbies, assign hosts, pause/resume rooms, manage joining locks, close/reopen play, and recoverably remove or restore rooms. Archive, permanent removal, participant support and backup tools are still being implemented.</p>
<p className="admin-secondary admin-development">Administration preview: create and configure lobbies, assign hosts, pause/resume rooms, manage joining locks, close/reopen play, archive/unarchive closed rooms, and recoverably remove or restore rooms. Permanent removal, participant support and backup tools are still being implemented.</p>
</>}
</main>;
}
@@ -0,0 +1,33 @@
import { env } from 'cloudflare:workers';
import { AdminError, requireAdmin } from '@/db/admin-access';
import { applyAdminArchive, readAdminArchive } from '@/db/admin-archive';
import { validAdminArchiveInput } from '@/lib/admin-archive';
import { adminBody, adminFailure, adminResponse, adminToken } from '@/lib/admin-http';
function code(request: Request) {
const code = new URL(request.url).pathname.split('/').at(-2)?.toUpperCase() ?? '';
if (code.length !== 8 || !/^[A-Z2-9]{8}$/.test(code)) throw new AdminError('Enter the eight-character room code.', 400);
return code;
}
export async function GET(request: Request) {
try {
const identity = await requireAdmin(env.DB, adminToken(request));
if (request.headers.has('X-Dune-Admin-Id') && request.headers.get('X-Dune-Admin-Id') !== identity.id)
throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401);
return adminResponse(await readAdminArchive(env.DB, identity, code(request)));
} catch (error) { return adminFailure(error); }
}
export async function POST(request: Request) {
try {
const input = await adminBody(request, env.DUNE_PUBLIC_ORIGIN);
const identity = await requireAdmin(env.DB, adminToken(request), ['owner', 'operator']);
if (request.headers.get('X-Dune-Admin-Id') !== identity.id)
throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401);
if (!validAdminArchiveInput(input)) throw new AdminError('Choose archive or unarchive, current versions and an operational reason.', 400);
const room = code(request);
const result = await applyAdminArchive(env.DB, identity, room, input);
return adminResponse(result);
} catch (error) { return adminFailure(error); }
}
+147
View File
@@ -0,0 +1,147 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { ClientRequestError, requestJson, requestMayHaveCompleted } from '@/lib/client-request';
import { retainAdminRoomRequest } from '@/lib/admin-room-control-client';
import { adminArchiveResponse, adminArchiveConfirmation, clearAdminArchiveRequest, newAdminArchiveRequest, readAdminArchiveRequest, saveAdminArchiveRequest } from '@/lib/admin-archive-client';
import type { AdminArchiveInput, AdminArchiveView } from '@/lib/admin-archive';
type Props = {
accountId: string; code: string; canManage: boolean; onClose: () => void;
onUpdated: () => void; onDenied: () => void; onBusyChange: (busy: boolean) => void;
};
const message = (error: unknown) => error instanceof Error ? error.message : 'Room availability could not be loaded.';
export function AdminRoomArchive({ accountId, code, canManage, onClose, onUpdated, onDenied, onBusyChange }: Props) {
const [room, setRoom] = useState<AdminArchiveView | null>(null);
const [reason, setReason] = useState('');
const [confirmed, setConfirmed] = useState(false);
const [confirmationCode, setConfirmationCode] = useState('');
const [pending, setPending] = useState<AdminArchiveInput | null>(null);
const [storageProblem, setStorageProblem] = useState(false);
const [notice, setNotice] = useState('');
const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(true);
const alive = useRef(false), inFlight = useRef(false);
const title = useRef<HTMLHeadingElement>(null);
const url = `/api/admin/rooms/${code}/archive`;
useEffect(() => {
alive.current = true; title.current?.focus();
let canceled = false;
try {
const saved = readAdminArchiveRequest(window.sessionStorage, accountId, code);
// oxlint-disable-next-line react/react-compiler -- Restore the account/room's tab-scoped request after SSR.
setPending(saved);
if (saved) setNotice('A saved archive or unarchive request needs confirmation. Review it below, then retry the same request.');
} catch (error) { setStorageProblem(true); setNotice(message(error)); }
requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } })
.then(result => { const current = adminArchiveResponse(result, code); if (!canceled) setRoom(current); })
.catch(error => { if (!canceled) {
setNotice(message(error));
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
} })
.finally(() => { if (!canceled) setLoading(false); });
return () => { canceled = true; alive.current = false; };
}, [accountId, code, url, onDenied]);
async function refresh() {
if (inFlight.current) return;
inFlight.current = true; setBusy(true); onBusyChange(true);
try {
const current = adminArchiveResponse(await requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code);
if (alive.current) { setRoom(current); setConfirmed(false); setConfirmationCode(''); }
} catch (error) {
if (alive.current) {
setNotice(message(error));
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
}
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
}
async function submit(saved?: AdminArchiveInput) {
if (inFlight.current || loading || !canManage || storageProblem ||
(!saved && (!room || room.removed || !room.closed || pending || !confirmed || (!room.archived && confirmationCode !== code)))) return;
let input: AdminArchiveInput;
try {
input = saved ?? newAdminArchiveRequest(room!, !room!.archived, reason);
saveAdminArchiveRequest(window.sessionStorage, accountId, code, input);
} catch (error) { setNotice(message(error)); return; }
inFlight.current = true; setBusy(true); onBusyChange(true); setPending(input); setNotice(input.archived ? 'Archiving room…' : 'Unarchiving room…');
try {
const result = adminArchiveConfirmation(await requestJson<unknown>(url, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input),
}), code, input);
clearAdminArchiveRequest(window.sessionStorage, accountId, code);
if (alive.current) {
setPending(null); setRoom(result.room); setConfirmed(false); setConfirmationCode(''); setReason('');
setNotice(result.replayed ? 'The original request was already recorded. Current room availability is shown below.' :
input.archived ? 'Room archived. It remains closed and readable to existing players. Find it with the Archive filter.' : 'Room unarchived. It remains closed until an administrator separately reopens it.');
onUpdated();
}
} catch (error) {
let cleared = false;
if (!retainAdminRoomRequest(error)) {
try { clearAdminArchiveRequest(window.sessionStorage, accountId, code); cleared = true; } catch { /* Keep the exact retry after storage failure. */ }
}
if (alive.current) {
if (cleared) setPending(null);
setConfirmed(false); setConfirmationCode('');
setNotice(requestMayHaveCompleted(error) ? `${message(error)} The change may have been saved. Use Retry saved request to confirm.` : message(error));
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
else try {
const current = adminArchiveResponse(await requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code);
if (alive.current) setRoom(current);
} catch { /* Preserve the original outcome and saved retry. */ }
}
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
}
function discardUnreadableRecord() {
if (!canManage || !room || !confirmed || busy) return;
try {
clearAdminArchiveRequest(window.sessionStorage, accountId, code); setStorageProblem(false); setConfirmed(false);
setNotice('Local retry record removed. This did not undo any server change. Review current room availability before continuing.');
} catch (error) { setNotice(message(error)); }
}
return <section className="admin-room-control" aria-labelledby="admin-archive-title" aria-busy={loading || busy}>
<div className="admin-section-heading"><h2 id="admin-archive-title" ref={title} tabIndex={-1}>Archive or unarchive · {code}</h2><Button variant="outline" disabled={busy} onClick={onClose}>Close archive controls</Button></div>
{notice && <output className="admin-notice">{notice}</output>}
{loading ? <output>Loading room archive…</output> : <>
{room ? <>
<p>Archive status: <strong>{room.archived ? 'Archived' : 'Unarchived'}</strong>. Room availability: <strong>{room.removed ? 'Removed' : room.closed ? 'Closed' : 'Open'}</strong>.</p>
<p>Archiving organizes a closed room without changing its saved game, credentials or discussion. Existing players keep their readable table and history.</p>
{room.archivedAt !== null && <p className="admin-secondary">Archived at: <time dateTime={new Date(room.archivedAt).toISOString()}>{new Date(room.archivedAt).toLocaleString()}</time>.</p>}
<Button variant="outline" disabled={busy} onClick={() => void refresh()}>Refresh archive status</Button>
</> : <Button disabled={busy} onClick={() => void refresh()}>Retry loading archive status</Button>}
{!canManage ? <p>Your viewer role can inspect archive status. An owner or operator can archive or unarchive rooms.</p> : storageProblem ? <div className="admin-confirm">
<p>No new changes can be sent until this tab’s unreadable retry record is resolved. Discarding the record does not cancel a server change.</p>
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy || !room} onChange={e => setConfirmed(e.target.checked)} />I have inspected room {code} and want to remove its local retry record.</label>
<Button disabled={busy || !room || !confirmed} onClick={discardUnreadableRecord}>Discard unreadable local record</Button>
</div> : pending ? <div className="admin-confirm">
<p>Saved request: <strong>{pending.archived ? 'Archive' : 'Unarchive'} room {code}</strong>.</p><p>Reason: {pending.reason}</p>
<p>The exact request stays in this tab across refresh. Retrying confirms the original change without applying it again; the current archive status may differ after another administrator’s action.</p>
<p>After refresh or sign-in, choose All rooms in both the Directory and Archive filters to find the room and reopen these controls.</p>
<Button disabled={busy} onClick={() => void submit(pending)}>Retry saved request</Button>
</div> : room?.removed ? <p>Restore this removed room before changing its archive status. Any saved archive request can still be confirmed above.</p> : room && !room.closed ? <p>Use Close or reopen to close this room before archiving it. Archiving does not interrupt play or assign an outcome.</p> : room && <form onSubmit={event => { event.preventDefault(); void submit(); }}>
<label htmlFor="admin-archive-reason">Reason for the audit history<Input id="admin-archive-reason" value={reason} maxLength={300} required disabled={busy} onChange={e => { setReason(e.target.value); setConfirmed(false); }} /></label>
<p className="admin-secondary">Use a short operational reason. Do not include credentials or private game information.</p>
<div className="admin-confirm">
{room.archived ? <>
<p>Return room <strong>{code}</strong> to the unarchived directory. Its game, seats, history, pause and joining settings remain saved.</p>
<p>Unarchiving leaves the room closed. Reopen it separately when play should resume.</p>
</> : <>
<p>Archive closed room <strong>{code}</strong>. It leaves the default administrator list and remains available under Archived rooms or All rooms in the Archive filter.</p>
<p>Players can still read their saved table and history. The room stays closed to further play; archiving does not delete it or change its winner.</p>
<label htmlFor="admin-archive-code">Type {code} to confirm this room<Input id="admin-archive-code" value={confirmationCode} maxLength={8} autoComplete="off" autoCapitalize="characters" spellCheck={false} disabled={busy} onChange={e => { setConfirmationCode(e.target.value); setConfirmed(false); }} /></label>
</>}
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy} onChange={e => setConfirmed(e.target.checked)} />{room.archived ? `I have reviewed room ${code} and understand that unarchiving leaves it closed.` : `I have reviewed closed room ${code} and want to archive it.`}</label>
<Button type="submit" disabled={busy || !confirmed || !reason.trim() || (!room.archived && confirmationCode !== code)}>{room.archived ? 'Unarchive room' : 'Archive room'}</Button>
</div>
</form>}
</>}
</section>;
}
+3 -3
View File
@@ -63,7 +63,7 @@ export function AdminRoomClosure({ accountId, code, canManage, onClose, onUpdate
async function submit(saved?: AdminClosureInput) {
if (inFlight.current || loading || !canManage || storageProblem ||
(!saved && (!room || room.removed || pending || !confirmed || (!room.closed && confirmationCode !== code)))) return;
(!saved && (!room || room.removed || room.archived || pending || !confirmed || (!room.closed && confirmationCode !== code)))) return;
let input: AdminClosureInput;
try {
input = saved ?? newAdminClosureRequest(room!, !room!.closed, reason);
@@ -124,9 +124,9 @@ export function AdminRoomClosure({ accountId, code, canManage, onClose, onUpdate
</div> : pending ? <div className="admin-confirm">
<p>Saved request: <strong>{pending.closed ? 'Close' : 'Reopen'} room {code}</strong>.</p><p>Reason: {pending.reason}</p>
<p>The exact request stays in this tab across refresh and reopening these controls. Retrying confirms a completed change without applying it again. Current availability can differ if another administrator changed the room afterward.</p>
<p>After refreshing the page or signing in again, choose All rooms in the Directory filter to find this room and reopen these controls.</p>
<p>After refreshing the page or signing in again, choose All rooms in both the Directory and Archive filters to find this room and reopen these controls.</p>
<Button disabled={busy} onClick={() => void submit(pending)}>Retry saved request</Button>
</div> : room?.removed ? <p>Restore this removed room before changing its closure. Any saved close/reopen request can still be confirmed above.</p> : room && <form onSubmit={event => { event.preventDefault(); void submit(); }}>
</div> : room?.removed ? <p>Restore this removed room before changing its closure. Any saved close/reopen request can still be confirmed above.</p> : room?.archived ? <p>Unarchive this room before reopening it. The saved game remains closed and readable to existing players.</p> : room && <form onSubmit={event => { event.preventDefault(); void submit(); }}>
<label htmlFor="admin-closure-reason">Reason for the audit history<Input id="admin-closure-reason" value={reason} maxLength={300} required disabled={busy} onChange={e => { setReason(e.target.value); setConfirmed(false); }} /></label>
<p className="admin-secondary">Use a short operational reason. Do not include credentials or private game information.</p>
<div className="admin-confirm">
+2 -2
View File
@@ -124,7 +124,7 @@ export function AdminRoomRemoval({ accountId, code, canManage, onClose, onUpdate
</div> : pending ? <div className="admin-confirm">
<p>Saved request: <strong>{pending.removed ? 'Remove' : 'Restore'} room {code}</strong>.</p><p>Reason: {pending.reason}</p>
<p>The exact request stays in this tab across refresh and reopening these controls. Retrying confirms a completed change without applying it again. Current availability can differ if another administrator changed the room afterward.</p>
<p>After refreshing the page or signing in again, choose All rooms in the Directory filter to find this room and reopen these controls.</p>
<p>After refreshing the page or signing in again, choose All rooms in both the Directory and Archive filters to find this room and reopen these controls.</p>
<Button disabled={busy} onClick={() => void submit(pending)}>Retry saved request</Button>
</div> : room && <form onSubmit={event => { event.preventDefault(); void submit(); }}>
<label htmlFor="admin-removal-reason">Reason for the audit history<Input id="admin-removal-reason" value={reason} maxLength={300} required disabled={busy} onChange={e => { setReason(e.target.value); setConfirmed(false); }} /></label>
@@ -132,7 +132,7 @@ export function AdminRoomRemoval({ accountId, code, canManage, onClose, onUpdate
<div className="admin-confirm">
{room.removed ? <>
<p>Restore ordinary access to room <strong>{code}</strong>. Saved progress and seats are retained, and current credentials and access revocations remain in effect.</p>
<p>Restoration preserves the previous pause and joining settings. {room.closed && 'This room is also closed: restoration keeps it closed to further play until reopened.'} {room.closed ? 'AI remains stopped while closed.' : room.paused ? 'This room will remain paused.' : 'This room will resume with the existing AI pace.'} New joins will remain {room.joinLocked ? 'locked' : 'open'}.</p>
<p>Restoration preserves the previous pause and joining settings. {room.archived && 'This room remains archived; use the Archive filter to find it after restoration.'} {room.closed && 'This room is also closed: restoration keeps it closed to further play until reopened.'} {room.closed ? 'AI remains stopped while closed.' : room.paused ? 'This room will remain paused.' : 'This room will resume with the existing AI pace.'} New joins will remain {room.joinLocked ? 'locked' : 'open'}.</p>
</> : <>
<p>Remove room <strong>{code}</strong> from ordinary access. This interrupts every player: private room access, joining, game actions, AI play, seat security changes and messages stop until restoration.</p>
<p>Warn active players before interrupting their game. This recoverable removal keeps the saved game and seats for restoration.</p>
+217
View File
@@ -0,0 +1,217 @@
import { AdminError, type AdminIdentity } from './admin-access';
import {
validAdminArchiveInput,
type AdminArchiveInput,
type AdminArchiveResult,
type AdminArchiveView,
} from '../lib/admin-archive';
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 mutationAuthority = authority + " AND a.role IN ('owner','operator')";
const roomSelect = `SELECT r.code,r.version,EXISTS (SELECT 1 FROM room_closures WHERE room_code = r.code AND closed = 1) AS closed,EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1) AS removed,COALESCE(m.archived,0) AS archived,
COALESCE(m.revision,0) AS revision,m.archived_at,m.updated_at,
COALESCE(c.paused,0) AS paused,COALESCE(c.join_locked,0) AS join_locked
FROM rooms r LEFT JOIN room_archives m ON m.room_code = r.code
LEFT JOIN room_controls c ON c.room_code = r.code
WHERE r.code = ? AND EXISTS (${authority})`;
type Row = {
closed: number;
removed: number;
code: string;
version: number;
archived: number;
revision: number;
archived_at: number | null;
updated_at: number | null;
paused: number;
join_locked: number;
};
type Receipt = {
operation_id: string;
actor_admin_id: string;
room_code: string;
request_hash: string;
applied_version: number;
applied_revision: number;
};
const project = (row: Row): AdminArchiveView => ({
code: row.code,
closed: row.closed === 1,
removed: row.removed === 1,
version: row.version,
archived: row.archived === 1,
revision: row.revision,
archivedAt: row.archived_at,
updatedAt: row.updated_at,
paused: row.paused === 1,
joinLocked: row.join_locked === 1,
});
function credentials(identity: AdminIdentity, code: string, now: number) {
if (
!identity ||
typeof identity.id !== 'string' ||
typeof identity.sessionHash !== 'string' ||
identity.sessionHash.length !== 64 ||
!/^[0-9a-f]{64}$/.test(identity.sessionHash)
)
throw new AdminError('Administrator sign-in required.', 401);
if (
typeof code !== 'string' ||
code.length !== 8 ||
!/^[A-Z0-9]{8}$/.test(code)
)
throw new AdminError('Use an eight-character room code.', 400);
return [identity.sessionHash, identity.id, now];
}
function authorize(result: D1Result, mutation = false) {
const role = (result.results[0] as { role: string } | undefined)?.role;
if (!role) throw new AdminError('Administrator sign-in required.', 401);
if (mutation && role === 'viewer')
throw new AdminError(
'Your administrator role does not permit this action.',
403,
);
}
/** Public operational metadata remains available for archived and unreadable saves. */
export async function readAdminArchive(
database: D1Database,
identity: AdminIdentity,
code: string,
now = Date.now(),
): Promise<AdminArchiveView> {
const args = credentials(identity, code, now);
const results = await database.batch([
database.prepare(authority).bind(...args),
database.prepare(roomSelect).bind(code, ...args),
]);
authorize(results[0]);
const row = results[1].results[0] as Row | undefined;
if (!row) throw new AdminError('Room not found.', 404);
return project(row);
}
/** An exact receipt, room-version fence and archive transition commit together.
* Neither transition changes saved state, credentials, closure or AI pacing. */
export async function applyAdminArchive(
database: D1Database,
identity: AdminIdentity,
code: string,
input: AdminArchiveInput,
now = Date.now(),
): Promise<AdminArchiveResult> {
const args = credentials(identity, code, now);
if (!validAdminArchiveInput(input))
throw new AdminError(
'Provide the room version, archive revision, requested state, a fresh operation identifier and a reason of 1–300 characters.',
400,
);
const { operationId, expectedVersion, expectedRevision, archived } = input;
const actorId = identity.id,
reason = input.reason.trim();
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(
JSON.stringify({ expectedVersion, expectedRevision, archived, reason }),
),
);
const requestHash = Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, '0'),
).join('');
const results = await database.batch([
database.prepare(authority).bind(...args),
// Existing receipt IDs cannot enter this write, regardless of later room state.
database
.prepare(`INSERT INTO admin_room_archives
(operation_id,actor_admin_id,room_code,request_hash,expected_version,expected_revision,
applied_version,applied_revision,archived,reason,before_metadata,after_metadata,created_at)
SELECT ?,?,r.code,?,?,?,r.version + 1,COALESCE(m.revision,0) + 1,?,?,
json_object('closed',json('true'),'removed',json('false'),'code',r.code,'version',r.version,'archived',json(CASE WHEN m.archived = 1 THEN 'true' ELSE 'false' END),
'revision',COALESCE(m.revision,0),'archivedAt',m.archived_at,'updatedAt',m.updated_at,
'paused',json(CASE WHEN c.paused = 1 THEN 'true' ELSE 'false' END),
'joinLocked',json(CASE WHEN c.join_locked = 1 THEN 'true' ELSE 'false' END)),
json_object('closed',json('true'),'removed',json('false'),'code',r.code,'version',r.version + 1,'archived',json(CASE WHEN ? = 1 THEN 'true' ELSE 'false' END),
'revision',COALESCE(m.revision,0) + 1,'archivedAt',?,'updatedAt',?,
'paused',json(CASE WHEN c.paused = 1 THEN 'true' ELSE 'false' END),
'joinLocked',json(CASE WHEN c.join_locked = 1 THEN 'true' ELSE 'false' END)),?
FROM rooms r LEFT JOIN room_archives m ON m.room_code = r.code
LEFT JOIN room_controls c ON c.room_code = r.code
WHERE r.code = ? AND r.version = ? AND COALESCE(m.revision,0) = ? AND COALESCE(m.archived,0) != ?
AND EXISTS (${mutationAuthority})
AND EXISTS (SELECT 1 FROM room_closures WHERE room_code = r.code AND closed = 1)
AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1)
AND NOT EXISTS (SELECT 1 FROM admin_room_archives WHERE operation_id = ?)`)
.bind(
operationId,
actorId,
requestHash,
expectedVersion,
expectedRevision,
Number(archived),
reason,
Number(archived),
archived ? now : null,
now,
now,
code,
expectedVersion,
expectedRevision,
Number(archived),
...args,
operationId,
),
// Archive is directory metadata only. Preserve the entire saved JSON and its timestamps.
database.prepare('UPDATE rooms SET version = version + 1 WHERE code = ? AND changes() = 1').bind(code),
database
.prepare(`INSERT INTO room_archives(room_code,archived,revision,archived_at,updated_at)
SELECT room_code,archived,applied_revision,CASE WHEN archived = 1 THEN created_at ELSE NULL END,created_at
FROM admin_room_archives WHERE operation_id = ? AND changes() = 1
ON CONFLICT(room_code) DO UPDATE SET archived = excluded.archived,revision = excluded.revision,
archived_at = excluded.archived_at,updated_at = excluded.updated_at`)
.bind(operationId),
database
.prepare(`SELECT operation_id,actor_admin_id,room_code,request_hash,applied_version,applied_revision
FROM admin_room_archives WHERE operation_id = ? AND EXISTS (${mutationAuthority})`)
.bind(operationId, ...args),
database.prepare(roomSelect).bind(code, ...args),
]);
authorize(results[0], true);
const receipt = results[4].results[0] as Receipt | undefined;
const row = results[5].results[0] as Row | undefined;
if (receipt) {
if (
receipt.actor_admin_id !== actorId ||
receipt.room_code !== code ||
receipt.request_hash !== requestHash
)
throw new AdminError(
'This archive operation is bound to a different administrator or request.',
409,
);
if (!row) throw new AdminError('Room not found.', 404);
return {
operationId,
replayed: results[1].meta.changes !== 1,
appliedRevision: receipt.applied_revision,
appliedVersion: receipt.applied_version,
room: project(row),
};
}
if (!row) throw new AdminError('Room not found.', 404);
if (row.removed) throw new AdminError('Restore this removed room before changing its archive.', 409);
if (!row.closed) throw new AdminError('Close this room before changing its archive state.', 409);
if (row.version !== expectedVersion || row.revision !== expectedRevision)
throw new AdminError(
'The room or archive settings changed. Refresh before choosing a new operation.',
409,
);
throw new AdminError(
archived
? 'This room is already archived.'
: 'This room is already unarchived.',
409,
);
}
+5 -1
View File
@@ -11,13 +11,14 @@ const authority = `SELECT a.role FROM admin_sessions s JOIN admin_accounts a ON
AND s.expires_at > ? AND s.generation = a.session_generation
AND a.role IN ('owner','operator','viewer')`;
const mutationAuthority = authority + " AND a.role IN ('owner','operator')";
const roomSelect = `SELECT r.code,r.version,EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1) AS removed,COALESCE(m.closed,0) AS closed,
const roomSelect = `SELECT r.code,r.version,EXISTS (SELECT 1 FROM room_archives WHERE room_code = r.code AND archived = 1) AS archived,EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1) AS removed,COALESCE(m.closed,0) AS closed,
COALESCE(m.revision,0) AS revision,m.closed_at,m.updated_at,
COALESCE(c.paused,0) AS paused,COALESCE(c.join_locked,0) AS join_locked
FROM rooms r LEFT JOIN room_closures m ON m.room_code = r.code
LEFT JOIN room_controls c ON c.room_code = r.code
WHERE r.code = ? AND EXISTS (${authority})`;
type Row = {
archived: number;
removed: number;
code: string;
version: number;
@@ -38,6 +39,7 @@ type Receipt = {
};
const project = (row: Row): AdminClosureView => ({
code: row.code,
...(row.archived === 1 ? { archived: true as const } : {}),
removed: row.removed === 1,
version: row.version,
closed: row.closed === 1,
@@ -139,6 +141,7 @@ export async function applyAdminClosure(
LEFT JOIN room_controls c ON c.room_code = r.code
WHERE r.code = ? AND r.version = ? AND COALESCE(m.revision,0) = ? AND COALESCE(m.closed,0) != ?
AND EXISTS (${mutationAuthority})
AND NOT EXISTS (SELECT 1 FROM room_archives WHERE room_code = r.code AND archived = 1)
AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1)
AND NOT EXISTS (SELECT 1 FROM admin_room_closures WHERE operation_id = ?)`)
.bind(
@@ -211,6 +214,7 @@ export async function applyAdminClosure(
}
if (!row) throw new AdminError('Room not found.', 404);
if (row.removed) throw new AdminError('Restore this removed room before changing its closure.', 409);
if (row.archived) throw new AdminError('Unarchive this room before reopening it.', 409);
if (row.version !== expectedVersion || row.revision !== expectedRevision)
throw new AdminError(
'The room or closure settings changed. Refresh before choosing a new operation.',
+8 -6
View File
@@ -9,11 +9,11 @@ const text = (value: unknown, max = 80) => typeof value === 'string' ? value.sli
const integer = (value: unknown) => Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : null;
/** Only emit allowlisted fields. Never call viewGame with an administrator's or host's seat. */
type DirectoryRow = { code: string; version: number; updated_at: number; state: string; removed?: number; control_closed?: number; control_paused?: number; control_join_locked?: number; control_revision?: number; control_updated_at?: number | null };
type DirectoryRow = { code: string; version: number; updated_at: number; state: string; removed?: number; archived?: number; control_closed?: number; control_paused?: number; control_join_locked?: number; control_revision?: number; control_updated_at?: number | null };
export function projectAdminRoom(row: DirectoryRow): AdminRoom {
const base: AdminRoom = {
code: row.code, version: row.version, updatedAt: row.updated_at,
removed: row.removed === 1,
removed: row.removed === 1, archived: row.archived === 1,
status: 'unreadable', host: null, players: [], advanced: false, preview: false,
expansions: [], modules: [], turn: null, phase: 'Unavailable',
pending: { label: 'Saved room needs inspection', owners: [] },
@@ -50,7 +50,7 @@ export function projectAdminRoom(row: DirectoryRow): AdminRoom {
}
export async function readAdminDirectory(database: D1Database, identity: AdminIdentity, search: URLSearchParams, now = Date.now()): Promise<AdminDirectory> {
const allowed = ['q', 'status', 'rules', 'sort', 'page', 'availability', 'removal'];
const allowed = ['q', 'status', 'rules', 'sort', 'page', 'availability', 'removal', 'archive'];
if ([...search.keys()].some(k => !allowed.includes(k) || search.getAll(k).length !== 1)) throw new AdminError('Invalid room filters.', 400);
const q = (search.get('q') ?? '').trim();
const status = search.get('status') ?? 'all';
@@ -59,8 +59,9 @@ export async function readAdminDirectory(database: D1Database, identity: AdminId
const rawPage = search.get('page') ?? '1';
const availability = search.get('availability') ?? 'all';
const removal = search.get('removal') ?? 'active';
const archive = search.get('archive') ?? 'unarchived';
if (q.length > 80 || !['all', ...statuses].includes(status) || !['all', 'basic', 'advanced'].includes(rules) ||
!['recent', 'oldest', 'code'].includes(sort) || !['all', 'running', 'paused', 'locked', 'closed'].includes(availability) || !['active', 'removed', 'all'].includes(removal) || !/^[1-9][0-9]{0,5}$/.test(rawPage)) throw new AdminError('Invalid room filters.', 400);
!['recent', 'oldest', 'code'].includes(sort) || !['all', 'running', 'paused', 'locked', 'closed'].includes(availability) || !['active', 'removed', 'all'].includes(removal) || !['unarchived', 'archived', 'all'].includes(archive) || !/^[1-9][0-9]{0,5}$/.test(rawPage)) throw new AdminError('Invalid room filters.', 400);
const page = Number(rawPage), pageSize = 25;
// json_valid protects the rest of the directory if a legacy save is malformed.
const state = "CASE WHEN json_valid(state) THEN state ELSE '{}' END";
@@ -86,13 +87,14 @@ export async function readAdminDirectory(database: D1Database, identity: AdminId
if (availability === 'closed') where.push('cl.closed = 1');
if (availability === 'locked') where.push('c.join_locked = 1');
if (removal !== 'all') { where.push('COALESCE(m.removed,0) = ?'); args.push(removal === 'removed' ? 1 : 0); }
if (archive !== 'all') { where.push('COALESCE(ar.archived,0) = ?'); args.push(archive === 'archived' ? 1 : 0); }
const predicate = where.length ? 'WHERE ' + where.join(' AND ') : '';
const order = sort === 'code' ? 'r.code ASC' : `r.updated_at ${sort === 'oldest' ? 'ASC' : 'DESC'}, r.code ASC`;
const source = 'FROM rooms r LEFT JOIN room_controls c ON c.room_code = r.code LEFT JOIN room_removals m ON m.room_code = r.code LEFT JOIN room_closures cl ON cl.room_code = r.code';
const source = 'FROM rooms r LEFT JOIN room_controls c ON c.room_code = r.code LEFT JOIN room_removals m ON m.room_code = r.code LEFT JOIN room_closures cl ON cl.room_code = r.code LEFT JOIN room_archives ar ON ar.room_code = r.code';
const results = await database.batch([
database.prepare(authority).bind(...credentials),
database.prepare(`SELECT COUNT(*) AS total ${source} ${predicate}`).bind(...args),
database.prepare(`SELECT r.code,r.version,r.updated_at,r.state,COALESCE(m.removed,0) AS removed,COALESCE(cl.closed,0) AS control_closed,COALESCE(c.paused,0) AS control_paused,COALESCE(c.join_locked,0) AS control_join_locked,COALESCE(c.revision,0) AS control_revision,c.updated_at AS control_updated_at ${source} ${predicate} ORDER BY ${order} LIMIT ? OFFSET ?`).bind(...args, pageSize, (page - 1) * pageSize),
database.prepare(`SELECT r.code,r.version,r.updated_at,r.state,COALESCE(ar.archived,0) AS archived,COALESCE(m.removed,0) AS removed,COALESCE(cl.closed,0) AS control_closed,COALESCE(c.paused,0) AS control_paused,COALESCE(c.join_locked,0) AS control_join_locked,COALESCE(c.revision,0) AS control_revision,c.updated_at AS control_updated_at ${source} ${predicate} ORDER BY ${order} LIMIT ? OFFSET ?`).bind(...args, pageSize, (page - 1) * pageSize),
]);
if (!results[0].results.length) throw new AdminError('Administrator sign-in required.', 401);
const total = Number((results[1].results[0] as { total: number }).total);
+3 -1
View File
@@ -11,13 +11,14 @@ const authority = `SELECT a.role FROM admin_sessions s JOIN admin_accounts a ON
AND s.expires_at > ? AND s.generation = a.session_generation
AND a.role IN ('owner','operator','viewer')`;
const mutationAuthority = authority + " AND a.role IN ('owner','operator')";
const roomSelect = `SELECT r.code,r.version,EXISTS (SELECT 1 FROM room_closures WHERE room_code = r.code AND closed = 1) AS closed,COALESCE(m.removed,0) AS removed,
const roomSelect = `SELECT r.code,r.version,EXISTS (SELECT 1 FROM room_archives WHERE room_code = r.code AND archived = 1) AS archived,EXISTS (SELECT 1 FROM room_closures WHERE room_code = r.code AND closed = 1) AS closed,COALESCE(m.removed,0) AS removed,
COALESCE(m.revision,0) AS revision,m.removed_at,m.updated_at,
COALESCE(c.paused,0) AS paused,COALESCE(c.join_locked,0) AS join_locked
FROM rooms r LEFT JOIN room_removals m ON m.room_code = r.code
LEFT JOIN room_controls c ON c.room_code = r.code
WHERE r.code = ? AND EXISTS (${authority})`;
type Row = {
archived: number;
closed: number;
code: string;
version: number;
@@ -38,6 +39,7 @@ type Receipt = {
};
const project = (row: Row): AdminRemovalView => ({
code: row.code,
...(row.archived === 1 ? { archived: true as const } : {}),
...(row.closed === 1 ? { closed: true as const } : {}),
version: row.version,
removed: row.removed === 1,
+45
View File
@@ -136,6 +136,51 @@ export const adminRoomClosures = sqliteTable(
],
);
// Archive organizes closed rooms without changing saved games or credentials.
export const roomArchives = sqliteTable(
'room_archives',
{
roomCode: text('room_code')
.primaryKey()
.references(() => rooms.code, { onDelete: 'cascade' }),
archived: integer('archived').notNull().default(0),
revision: integer('revision').notNull().default(0),
archivedAt: integer('archived_at'),
updatedAt: integer('updated_at').notNull(),
},
(table) => [
check('room_archives_archived', sql`${table.archived} IN (0, 1)`),
check('room_archives_revision', sql`${table.revision} >= 0`),
check(
'room_archives_time',
sql`(${table.archived} = 1 AND ${table.archivedAt} IS NOT NULL) OR (${table.archived} = 0 AND ${table.archivedAt} IS NULL)`,
),
],
);
// Exact operation receipts also retain safe audit evidence independently of rooms/accounts.
export const adminRoomArchives = sqliteTable(
'admin_room_archives',
{
operationId: text('operation_id').primaryKey(),
actorAdminId: text('actor_admin_id').notNull(),
roomCode: text('room_code').notNull(),
requestHash: text('request_hash').notNull(),
expectedVersion: integer('expected_version').notNull(),
expectedRevision: integer('expected_revision').notNull(),
appliedVersion: integer('applied_version').notNull(),
appliedRevision: integer('applied_revision').notNull(),
archived: integer('archived').notNull(),
reason: text('reason').notNull(),
beforeMetadata: text('before_metadata').notNull(),
afterMetadata: text('after_metadata').notNull(),
createdAt: integer('created_at').notNull(),
},
(table) => [
index('admin_room_archives_room').on(table.roomCode, table.createdAt),
],
);
// Durable receipts are also the room operations audit. They intentionally survive
// account/room removal, and contain no credentials, game state or private cards.
export const adminRoomAudit = sqliteTable(
+5 -2
View File
@@ -12,7 +12,7 @@ recovery and persistence systems where suitable.
| Administrator access | Personal access keys, server-enforced owner/operator/viewer roles, persistent eight-hour sessions, individual/all-session sign-out and operator provisioning/revocation. A room host is not a site administrator. Account-management UI and further operational permissions remain. | Prototyped |
| Room directory | Search room codes/public player names, filter status/rules/availability, sort and paginate; show host, roster, modules, game change time, pause/join flags and bounded decision ownership. Detailed setup/shared-window ownership remains. | Prototyped |
| Create and configure | [Create a lobby](ADMIN_ROOM_CREATION.md) with an explicitly owned new host seat, Basic/Advanced preview and initial AI configuration; invite humans and use ordinary lobby controls/voluntary host-seat handover. [Neutral lobby configuration](ADMIN_LOBBY_CONFIGURATION.md) adds rules/modules, AI seats and host assignment to an existing human without private access. Reserved human seats remain. | 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, 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 | Remove disruptive participants or revoke access, manage appropriate restrictions, assist saved-seat recovery and host reassignment, and use supported AI takeover/replacement without losing forces, cards or decisions. | Missing |
| Saved-game operations | Create/list/download backups, validate imports, restore a selected checkpoint safely, and diagnose or resume interrupted automatic work through authoritative game actions. | Missing |
| Operations and audit | Show server/build/storage health, room/player counts and actionable errors; provide maintenance controls and a searchable record of administrator actions. | Missing |
@@ -46,6 +46,9 @@ find removed rooms; restoration retains prior pause and joining settings.
[Close or reopen](ADMIN_ROOM_CLOSURE.md) ends further play without awarding a
winner and preserves readable private tables and discussion. Reopening retains
the saved game, pause/join settings and current credentials.
[Archive or unarchive](ADMIN_ROOM_ARCHIVE.md) organizes closed rooms with separate
directory filters and exact retries. Unarchiving leaves play closed; restoring
a removed archive preserves both flags.
Disabling an account invalidates its sessions; later re-enabling it cannot restore
them. The database refuses to disable, demote or delete the final enabled owner.
No default key, public account creation or host-to-administrator promotion exists.
@@ -116,7 +119,7 @@ session revocation and unchanged game/seat continuation. Operator/owner QA keys
also create dedicated rooms for joining locks, pause/takeback/resume,
administrator creation, invitations, voluntary host handover and neutral lobby
configuration with existing-human host assignment and a real game start. They
also remove/restore a separate QA game, preserving its paused state, discussion,
also close/reopen, archive/unarchive and remove/restore separate QA games, preserving their paused state, discussion,
saved seats and revoked credentials. Private reports retain
room IDs and safe outcomes, never keys/cookies. The container verifier provisions
its own disposable QA account and checks admin-session continuity across restart
+65
View File
@@ -0,0 +1,65 @@
# Administrative archive and unarchive
Owners and operators can choose **Archive or unarchive** beside a room in
Administration. First close the room through [Close or reopen](ADMIN_ROOM_CLOSURE.md).
Archiving requires an operational reason, the exact room code and confirmation.
Unarchiving requires a reason and acknowledgment that play stays closed. Viewers
can inspect archive status. Instructions and outcome notices remain readable
until another deliberate action changes the panel.
Archive organizes closed rooms in the administrator directory. It preserves the
entire saved game, game-change timestamp, winner, pending decisions, physical
pieces, seats, revoked access, discussion and AI deadlines. Existing players can
still read their own closed table and history. It does not independently pause,
end or resume the rules engine. **Unarchive leaves the room closed**; an
administrator must separately reopen it when play should resume.
## Directory and other controls
The **Archive** filter defaults to **Unarchived rooms**; choose **Archived rooms**
or **All rooms** to find an archived game. This filter is independent of
**Directory → Active / Removed / All rooms** and the availability filter.
To find a room that is both removed and archived, choose **All rooms** in both
Directory and Archive. Searches still inspect only codes and public player names.
Archived rooms cannot be reopened until unarchived. Recoverable removal can hide
an archived room; restoration preserves its archive and closure and does not wake
its AI. Restore a removed room before changing its archive status. An exact
previous receipt can still be confirmed while a room is removed or after another
administrator has changed its availability.
## Saved retries and preservation
Before sending, the browser saves the exact account/room operation, requested
state, reason and two version counters in tab-scoped storage. A lost response or
refresh offers **Retry saved request** when the controls reopen. The server
confirms the original operation without repeating it, and reports the room's
current status. Keep the tab open until resolved. Discarding an unreadable local
record cannot undo a server change.
Live owner/operator authority, room version, archive revision, closed status and
removal status are fenced inside the write transaction. Audit receipt, room
version increment and archive metadata commit together. Rejected or competing
requests cannot partially change the room; identical concurrent requests commit
once. Admin responses and audit metadata contain operational flags and timestamps,
without hands, messages, seat credentials or raw saved state. These operations
never parse or repair the saved game, including an unreadable save.
Apply additive migration `0014_admin_room_archive.sql` before using this revision.
`room_archives` holds current directory status; `admin_room_archives` holds durable
audit receipts. Existing records are not rewritten or deleted. Permanent deletion,
bulk operations, participant support and backup/restore tools remain unfinished.
## Verification
Focused tests cover live authorization changes, exact receipts, concurrent
reopening/removal, transaction rollback, database reopen, AI deadline and credential
preservation, strict client projections and combined directory pagination/search.
The administrator HTTP verifier creates dedicated rooms for archive/unarchive,
closure/removal composition and unchanged saved player views/history. Browser
acceptance exercises actual confirmations, a lost successful response followed by
refresh and exact retry, persistent notices, mobile controls and saved-seat reads.
Keep source-bound checks, saved-record comparisons and deployed acceptance separate.
Local checks do not establish authenticated production acceptance; that remains
pending authorized QA operator access. See [verification workflow](VERIFICATION_WORKFLOW.md).
+3 -2
View File
@@ -44,7 +44,7 @@ available while the table's mutation controls are disabled.
## Other administrator controls
Closed rooms remain in the default directory and can be filtered with
Unarchived closed rooms remain in the default directory and can be filtered with
**Availability → Closed**. **Running** excludes closed rooms. New pause/join and
lobby configuration changes require reopening; prior exact receipts still work.
@@ -56,7 +56,8 @@ available to authorized administrators while it is removed.
Apply additive migration `0013_admin_room_closure.sql` before this revision.
`room_closures` stores current availability independently of pause and removal;
`admin_room_closures` stores durable audit receipts. No existing game or credential
rows are migrated or deleted. Archive, permanent deletion, bulk actions,
rows are migrated or deleted. [Archiving](ADMIN_ROOM_ARCHIVE.md) organizes closed
rooms separately; unarchive before reopening. Permanent deletion, bulk actions,
participant support and backup/restore tools remain unfinished.
## Verification
+5 -1
View File
@@ -64,7 +64,11 @@ These checks also exposed a lobby readiness bug: a joining human or human
faction change could clear AI readiness and block start. Those changes now clear
only human readiness; focused JSON continuation tests and the HTTP start cover it.
Close/end, archive, permanent deletion, bulk operations, participant support and
[Close/reopen](ADMIN_ROOM_CLOSURE.md) and [archive/unarchive](ADMIN_ROOM_ARCHIVE.md)
are separate reversible controls. Permanent deletion, bulk operations, participant support and
backup/import workflows remain unfinished. Recoverable removal does not satisfy
those separate [administration requirements](ADMIN_PANEL.md). No existing human
room is used for removal tests, and no database reset is authorized.
Restoring an archived room preserves both archive and closure. Use All rooms in
both Directory and Archive filters to find a room with both flags.
+15 -7
View File
@@ -6,12 +6,20 @@ Expansion starts and full rules/publication acceptance remain gated.**
## Current checkpoint and work
Current work adds [administrative close/reopen](ADMIN_ROOM_CLOSURE.md):
closed rooms preserve readable private tables and discussion while stopping new
play, joins, AI and seat mutations. Exact prior receipts remain confirmable;
reopening preserves pending decisions, credentials and pause/join flags.
Archive, permanent deletion, bulk actions and full participant/backup support
remain unfinished.
Current work adds [administrative archive/unarchive](ADMIN_ROOM_ARCHIVE.md):
closed rooms can leave the default directory without changing saved games,
credentials, discussion or AI deadlines. Unarchiving leaves play closed;
reopening requires unarchiving first. Exact saved retries and combined archive /
removal filters preserve recovery. Permanent deletion, bulk actions and full
participant/backup support remain unfinished.
Checkpoint `98b1b32` added [close/reopen](ADMIN_ROOM_CLOSURE.md) and passed types/lint,
5,356 offline tests, 52 HTTP tests and build, with seventeen administrator HTTP
groups and lost-response desktop/mobile browser QA. It is deployed on the NAS:
container verification passed, migration 0013 applied, all eight prior rooms and
ten seats were preserved, and public HTTPS/browser saved-seat restoration passed.
Anonymous admin routes remain denied. Positive production administrator acceptance
still awaits authorized QA access; archive/unarchive is not deployed yet.
Checkpoint `238de38` added [recoverable removal/restoration](ADMIN_ROOM_REMOVAL.md)
and passed types/lint, 5,314 offline tests, 52 HTTP tests and build, with fourteen
@@ -20,7 +28,7 @@ deployed on the NAS: container verification passed, migration 0012 applied,
all eight prior rooms and ten seats were preserved, and public HTTPS/browser
saved-seat restoration passed. Anonymous removal and other admin routes remain
denied. Positive production administrator acceptance remains pending authorized
QA operator access; the current close/reopen work is not deployed yet.
QA operator access.
Checkpoint `c0cc4de` added [neutral administrator lobby configuration](ADMIN_LOBBY_CONFIGURATION.md)
and passed types/lint, 5,260 offline tests, 52 HTTP tests and build, with eleven
+16
View File
@@ -1,5 +1,21 @@
# Dune implementation status
## 22 September 2026 — Administrative archive/unarchive prototype
[Archive or unarchive](ADMIN_ROOM_ARCHIVE.md) organizes closed rooms without
changing their saved state, timestamps, credentials, discussion or AI pacing.
Separate directory filters compose with removal; unarchive leaves play closed,
reopen rejects archives, and removal/restoration retains both flags. Live
operator authority and room/archive version fences commit with durable audit
receipts. Tab-scoped exact retries remain confirmable across refresh and later
availability changes.
Independent read-only review found no material issues. Focused database/client
checks and twenty dedicated administrator HTTP groups passed. Browser acceptance,
source-bound broad checks and preservation evidence accompany the checkpoint.
Authenticated production acceptance awaits authorized QA access. Full admin and
rules gates remain unfinished.
## 22 September 2026 — Administrative close/reopen prototype
[Close or reopen](ADMIN_ROOM_CLOSURE.md) now separates administrative availability
+28
View File
@@ -0,0 +1,28 @@
CREATE TABLE `admin_room_archives` (
`operation_id` text PRIMARY KEY NOT NULL,
`actor_admin_id` text NOT NULL,
`room_code` text NOT NULL,
`request_hash` text NOT NULL,
`expected_version` integer NOT NULL,
`expected_revision` integer NOT NULL,
`applied_version` integer NOT NULL,
`applied_revision` integer NOT NULL,
`archived` integer NOT NULL,
`reason` text NOT NULL,
`before_metadata` text NOT NULL,
`after_metadata` text NOT NULL,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `admin_room_archives_room` ON `admin_room_archives` (`room_code`,`created_at`);--> statement-breakpoint
CREATE TABLE `room_archives` (
`room_code` text PRIMARY KEY NOT NULL,
`archived` integer DEFAULT 0 NOT NULL,
`revision` integer DEFAULT 0 NOT NULL,
`archived_at` integer,
`updated_at` integer NOT NULL,
FOREIGN KEY (`room_code`) REFERENCES `rooms`(`code`) ON UPDATE no action ON DELETE cascade,
CONSTRAINT "room_archives_archived" CHECK("room_archives"."archived" IN (0, 1)),
CONSTRAINT "room_archives_revision" CHECK("room_archives"."revision" >= 0),
CONSTRAINT "room_archives_time" CHECK(("room_archives"."archived" = 1 AND "room_archives"."archived_at" IS NOT NULL) OR ("room_archives"."archived" = 0 AND "room_archives"."archived_at" IS NULL))
);
File diff suppressed because it is too large. Load diff
+7
View File
@@ -99,6 +99,13 @@
"when": 1790042692212,
"tag": "0013_admin_room_closure",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1790044461950,
"tag": "0014_admin_room_archive",
"breakpoints": true
}
]
}
+65
View File
@@ -0,0 +1,65 @@
import { randomId } from './random-id';
import { validAdminArchiveInput, type AdminArchiveInput, type AdminArchiveView, type AdminArchiveResult } from './admin-archive';
type Storage = Pick<globalThis.Storage, 'getItem' | 'setItem' | 'removeItem'>;
const key = (account: string, code: string) => `dune.admin-archive-request.v1:${account}:${code}`;
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
const keys = (value: Record<string, unknown>, names: string[]) => Object.keys(value).length === names.length && names.every(name => Object.hasOwn(value, name));
const integer = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0;
const timestamp = (value: unknown) => value === null || integer(value) && value <= 8_640_000_000_000_000;
export function validAdminArchiveView(value: unknown): value is AdminArchiveView {
return object(value) && keys(value, ['closed', 'removed', 'code', 'version', 'archived', 'revision', 'archivedAt', 'updatedAt', 'paused', 'joinLocked']) &&
typeof value.code === 'string' && /^[A-Z2-9]{8}$/.test(value.code) && integer(value.version) && integer(value.revision) &&
typeof value.closed === 'boolean' && typeof value.removed === 'boolean' && typeof value.archived === 'boolean' && typeof value.paused === 'boolean' && typeof value.joinLocked === 'boolean' &&
timestamp(value.archivedAt) && timestamp(value.updatedAt);
}
export function adminArchiveResponse(value: unknown, code: string): AdminArchiveView {
if (!validAdminArchiveView(value) || value.code !== code) throw new Error('The server returned unreadable room availability.');
return value;
}
export function adminArchiveConfirmation(value: unknown, code: string, input: AdminArchiveInput): AdminArchiveResult {
if (!object(value) || !keys(value, ['operationId', 'replayed', 'appliedRevision', 'appliedVersion', 'room']) ||
value.operationId !== input.operationId || typeof value.replayed !== 'boolean' ||
!integer(value.appliedVersion) || value.appliedVersion !== input.expectedVersion + 1 ||
!integer(value.appliedRevision) || value.appliedRevision !== input.expectedRevision + 1)
throw new Error('The server did not confirm this archive or unarchive request.');
const room = adminArchiveResponse(value.room, code);
if (room.version < value.appliedVersion || room.revision < value.appliedRevision)
throw new Error('The server returned room availability older than the confirmed change.');
return value as AdminArchiveResult;
}
export function newAdminArchiveRequest(view: AdminArchiveView, archived: boolean, reason: string): AdminArchiveInput {
const input = { operationId: randomId(), expectedVersion: view.version, expectedRevision: view.revision, archived, reason: reason.trim() };
if (!validAdminArchiveInput(input)) throw new Error('Enter a reason of 1–300 characters on one line.');
if (view.removed) throw new Error('Restore this removed room before changing its archive.');
if (!view.closed) throw new Error('Close this room before changing its archive state.');
if (archived === view.archived) throw new Error('Refresh the room before choosing an archive or unarchive.');
return input;
}
export function readAdminArchiveRequest(storage: Storage, account: string, code: string): AdminArchiveInput | null {
const raw = storage.getItem(key(account, code));
if (raw === null) return null;
let value: unknown;
try { value = raw.length <= 2048 ? JSON.parse(raw) : null; } catch { value = null; }
if (!validAdminArchiveInput(value)) throw new Error('This tab’s saved archive or unarchive request is unreadable. Inspect the current room before discarding its local retry record.');
return value;
}
/** Save before sending; a different unresolved request must never be replaced. */
export function saveAdminArchiveRequest(storage: Storage, account: string, code: string, input: AdminArchiveInput) {
if (!validAdminArchiveInput(input)) throw new Error('Invalid archive or unarchive request.');
const previous = readAdminArchiveRequest(storage, account, code), encoded = JSON.stringify(input);
if (previous && JSON.stringify(previous) !== encoded) throw new Error('Resolve the saved archive or unarchive request before sending a different change.');
storage.setItem(key(account, code), encoded);
if (storage.getItem(key(account, code)) !== encoded) throw new Error('The archive or unarchive request could not be preserved in this tab. Nothing was sent.');
}
export function clearAdminArchiveRequest(storage: Storage, account: string, code: string) {
storage.removeItem(key(account, code));
if (storage.getItem(key(account, code)) !== null) throw new Error('The saved archive or unarchive request could not be cleared.');
}
+64
View File
@@ -0,0 +1,64 @@
export type AdminArchiveInput = {
operationId: string;
expectedVersion: number;
expectedRevision: number;
archived: boolean;
reason: string;
};
export type AdminArchiveView = {
closed: boolean;
removed: boolean;
code: string;
version: number;
archived: boolean;
revision: number;
archivedAt: number | null;
updatedAt: number | null;
paused: boolean;
joinLocked: boolean;
};
export type AdminArchiveResult = {
operationId: string;
replayed: boolean;
appliedRevision: number;
appliedVersion: number;
room: AdminArchiveView;
};
export function validAdminArchiveInput(
value: unknown,
): value is AdminArchiveInput {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const input = value as Record<string, unknown>;
const fields = [
'operationId',
'expectedVersion',
'expectedRevision',
'archived',
'reason',
];
return (
Object.keys(input).length === fields.length &&
fields.every((field) => Object.hasOwn(input, field)) &&
typeof input.operationId === 'string' &&
input.operationId.length === 36 &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
input.operationId,
) &&
[input.expectedVersion, input.expectedRevision].every(
(version) =>
Number.isSafeInteger(version) &&
Number(version) >= 0 &&
Number(version) < Number.MAX_SAFE_INTEGER,
) &&
typeof input.archived === 'boolean' &&
typeof input.reason === 'string' &&
!!input.reason.trim() &&
input.reason.length <= 300 &&
!input.reason
.split('')
.some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)
);
}
+2 -1
View File
@@ -9,7 +9,7 @@ const integer = (value: unknown): value is number => Number.isSafeInteger(value)
const timestamp = (value: unknown) => value === null || integer(value) && value <= 8_640_000_000_000_000;
export function validAdminClosureView(value: unknown): value is AdminClosureView {
return object(value) && keys(value, ['removed', 'code', 'version', 'closed', 'revision', 'closedAt', 'updatedAt', 'paused', 'joinLocked']) &&
return object(value) && keys(value, ['removed', 'code', 'version', 'closed', 'revision', 'closedAt', 'updatedAt', 'paused', 'joinLocked', ...(value.archived === true ? ['archived'] : [])]) &&
typeof value.code === 'string' && /^[A-Z2-9]{8}$/.test(value.code) && integer(value.version) && integer(value.revision) &&
typeof value.removed === 'boolean' && typeof value.closed === 'boolean' && typeof value.paused === 'boolean' && typeof value.joinLocked === 'boolean' &&
timestamp(value.closedAt) && timestamp(value.updatedAt);
@@ -36,6 +36,7 @@ export function newAdminClosureRequest(view: AdminClosureView, closed: boolean,
const input = { operationId: randomId(), expectedVersion: view.version, expectedRevision: view.revision, closed, reason: reason.trim() };
if (!validAdminClosureInput(input)) throw new Error('Enter a reason of 1–300 characters on one line.');
if (view.removed) throw new Error('Restore this removed room before changing its closure.');
if (view.archived) throw new Error('Unarchive this room before reopening it.');
if (closed === view.closed) throw new Error('Refresh the room before choosing a closure or reopening.');
return input;
}
+1
View File
@@ -7,6 +7,7 @@ export type AdminClosureInput = {
};
export type AdminClosureView = {
archived?: true;
removed: boolean;
code: string;
version: number;
+1
View File
@@ -4,6 +4,7 @@ export type AdminRoom = {
code: string;
version: number;
removed: boolean;
archived: boolean;
updatedAt: number;
status: 'lobby' | 'setup' | 'playing' | 'finished' | 'unreadable';
host: string | null;
+1 -1
View File
@@ -9,7 +9,7 @@ const integer = (value: unknown): value is number => Number.isSafeInteger(value)
const timestamp = (value: unknown) => value === null || integer(value) && value <= 8_640_000_000_000_000;
export function validAdminRemovalView(value: unknown): value is AdminRemovalView {
return object(value) && keys(value, ['code', 'version', 'removed', 'revision', 'removedAt', 'updatedAt', 'paused', 'joinLocked', ...(value.closed === true ? ['closed'] : [])]) &&
return object(value) && keys(value, ['code', 'version', 'removed', 'revision', 'removedAt', 'updatedAt', 'paused', 'joinLocked', ...(value.closed === true ? ['closed'] : []), ...(value.archived === true ? ['archived'] : [])]) &&
typeof value.code === 'string' && /^[A-Z2-9]{8}$/.test(value.code) && integer(value.version) && integer(value.revision) &&
typeof value.removed === 'boolean' && typeof value.paused === 'boolean' && typeof value.joinLocked === 'boolean' &&
timestamp(value.removedAt) && timestamp(value.updatedAt);
+1
View File
@@ -7,6 +7,7 @@ export type AdminRemovalInput = {
};
export type AdminRemovalView = {
archived?: true;
closed?: true;
code: string;
version: number;
+85
View File
@@ -0,0 +1,85 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { adminArchiveConfirmation, adminArchiveResponse, clearAdminArchiveRequest, newAdminArchiveRequest, readAdminArchiveRequest, saveAdminArchiveRequest } from '../lib/admin-archive-client';
import { retainAdminRoomRequest } from '../lib/admin-room-control-client';
import { ClientRequestError } from '../lib/client-request';
import type { AdminArchiveView } from '../lib/admin-archive';
const view = (): AdminArchiveView => ({ code: 'ABCD2345', version: 7, archived: false, closed: true, removed: false, revision: 2, archivedAt: null, updatedAt: null, paused: false, joinLocked: true });
function storage() {
const values = new Map<string, string>();
return { values, getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); }, removeItem: (key: string) => { values.delete(key); } };
}
void test('archive retry preserves exact intent/version/revision/reason across refresh and isolates account and room', () => {
const tab = storage(), room = view(), input = newAdminArchiveRequest(room, true, ' QA archive ');
saveAdminArchiveRequest(tab, 'a', room.code, input);
assert.deepEqual(readAdminArchiveRequest(tab, 'a', room.code), input);
assert.equal(input.expectedVersion, 7); assert.equal(input.expectedRevision, 2); assert.equal(input.reason, 'QA archive');
assert.equal(readAdminArchiveRequest(tab, 'b', room.code), null);
assert.equal(readAdminArchiveRequest(tab, 'a', 'DIFF2345'), null);
for (const change of [{ operationId: crypto.randomUUID() }, { expectedVersion: 8 }, { expectedRevision: 3 }, { reason: 'Different' }, { archived: false }])
assert.throws(() => saveAdminArchiveRequest(tab, 'a', room.code, { ...input, ...change }), /Resolve the saved/);
saveAdminArchiveRequest(tab, 'a', room.code, input);
clearAdminArchiveRequest(tab, 'a', room.code);
assert.equal(readAdminArchiveRequest(tab, 'a', room.code), null);
const reopen = newAdminArchiveRequest({ ...room, archived: true, version: 8, revision: 3 }, false, 'Restore');
saveAdminArchiveRequest(tab, 'a', room.code, reopen);
assert.deepEqual(readAdminArchiveRequest(tab, 'a', room.code), reopen);
});
void test('corrupt or unwritable archive retry storage fails closed and never replaces the saved record', () => {
const tab = storage(), room = view(), input = newAdminArchiveRequest(room, true, 'QA');
assert.throws(() => saveAdminArchiveRequest({ ...tab, setItem() {} }, 'a', room.code, input), /Nothing was sent/);
saveAdminArchiveRequest(tab, 'a', room.code, input);
const key = [...tab.values.keys()][0];
for (const raw of ['{broken', JSON.stringify({ ...input, sessionToken: 'secret' }), JSON.stringify({ ...input, expectedRevision: -1 }), ' '.repeat(2049)]) {
tab.values.set(key, raw);
assert.throws(() => readAdminArchiveRequest(tab, 'a', room.code), /unreadable/);
assert.throws(() => saveAdminArchiveRequest(tab, 'a', room.code, input), /unreadable/);
assert.equal(tab.values.get(key), raw);
}
assert.throws(() => clearAdminArchiveRequest({ ...tab, removeItem() {} }, 'a', room.code), /could not be cleared/);
clearAdminArchiveRequest(tab, 'a', room.code);
for (const reason of [' ', 'a'.repeat(301), 'two\nlines', 'bad\u007freason'])
assert.throws(() => newAdminArchiveRequest(room, true, reason));
assert.throws(() => newAdminArchiveRequest(room, false, 'No change'));
assert.throws(() => newAdminArchiveRequest({ ...room, removed: true }, true, 'Removed'), /Restore/);
assert.throws(() => newAdminArchiveRequest({ ...room, closed: false }, true, 'Open'), /Close/);
assert.throws(() => newAdminArchiveRequest({ ...room, revision: Number.MAX_SAFE_INTEGER }, true, 'Overflow'));
});
void test('confirmation binds exact operation and both applied versions while replay accepts later current availability', () => {
const room = view(), input = newAdminArchiveRequest(room, true, 'QA');
const current = { ...room, version: 12, revision: 4, archived: false, closed: true, removed: false, archivedAt: 100, updatedAt: 200, paused: true };
const receipt = { operationId: input.operationId, appliedVersion: 8, appliedRevision: 3, replayed: true, room: current };
assert.deepEqual(adminArchiveConfirmation(receipt, room.code, input), receipt);
const fresh = { ...receipt, replayed: false, room: { ...room, version: 8, revision: 3, archived: true, archivedAt: 100, updatedAt: 100 } };
assert.deepEqual(adminArchiveConfirmation(fresh, room.code, input), fresh);
for (const invalid of [{ ...receipt, operationId: crypto.randomUUID() }, { ...receipt, appliedVersion: 9 }, { ...receipt, appliedRevision: 4 },
{ ...receipt, room: { ...current, version: 7 } }, { ...receipt, room: { ...current, revision: 2 } },
{ ...receipt, room: { ...current, code: 'DIFF2345' } }, { ...receipt, sessionToken: 'secret' }, { ...receipt, replayed: undefined }])
assert.throws(() => adminArchiveConfirmation(invalid, room.code, input));
});
void test('room availability projection excludes private data and rejects malformed metadata', () => {
const room = view();
assert.deepEqual(adminArchiveResponse(room, room.code), room);
assert.doesNotThrow(() => adminArchiveResponse({ ...room, archived: true, archivedAt: 100, updatedAt: 100 }, room.code));
for (const invalid of [null, [], { ...room, state: {} }, { ...room, reason: 'Private audit' }, { ...room, seats: [] },
{ ...room, version: 1.5 }, { ...room, revision: -1 }, { ...room, code: 'bad code' }, { ...room, archived: 'true' },
{ ...room, archivedAt: -1 }, { ...room, archivedAt: Number.MAX_SAFE_INTEGER }, { ...room, updatedAt: '100' },
{ ...room, closed: undefined }, { ...room, removed: 1 }, { ...room, paused: undefined }, { ...room, joinLocked: null }])
assert.throws(() => adminArchiveResponse(invalid, room.code));
});
void test('uncertain archive and changed-access responses keep the exact saved request', () => {
const tab = storage(), room = view(), input = newAdminArchiveRequest(room, true, 'QA');
saveAdminArchiveRequest(tab, 'a', room.code, input);
for (const error of [new ClientRequestError('Offline', 'network'), new ClientRequestError('Timeout', 'timeout'),
new ClientRequestError('Unreadable', 'invalid-response', 200), ...[401, 403, 408, 500, 502, 503].map(status => new ClientRequestError('Unavailable', 'http', status)), new Error('Wrong confirmation')]) {
if (!retainAdminRoomRequest(error)) clearAdminArchiveRequest(tab, 'a', room.code);
assert.deepEqual(readAdminArchiveRequest(tab, 'a', room.code), input);
}
for (const status of [400, 404, 409, 422]) assert.equal(retainAdminRoomRequest(new ClientRequestError('Rejected', 'http', status)), false);
});
+737
View File
@@ -0,0 +1,737 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomBytes, randomUUID } from 'node:crypto';
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { adminStore, pauseAdminBatch, sha256 } from './admin-access-fixture';
import {
AdminError,
adminLogin,
requireAdmin,
type AdminRole,
} from '../db/admin-access';
import { applyAdminArchive, readAdminArchive } from '../db/admin-archive';
import {
validAdminArchiveInput,
type AdminArchiveInput,
} from '../lib/admin-archive';
import { applyAdminRemoval, readAdminRemoval } from '../db/admin-removal';
import { applyAdminClosure, readAdminClosure } from '../db/admin-closure';
import { createGame, newPlayer } from '../game/engine';
const now = 10000;
const secret = () => randomBytes(32).toString('hex');
const status = (value: number) => (error: unknown) =>
error instanceof AdminError && error.status === value;
type Store = ReturnType<typeof adminStore>;
const rows = (store: Store, table: string) =>
store.sqlite.prepare(`SELECT * FROM ${table}`).all();
const secureTables = [
'room_closures',
'seats',
'seat_recovery_keys',
'seat_recovery_receipts',
'seat_handover_offers',
'seat_handover_claim_receipts',
'seat_ai_delegations',
'room_entry_receipts',
'room_messages',
];
const secureRows = (store: Store) =>
secureTables.map((table) => rows(store, table));
const change = (
expectedVersion = 0,
expectedRevision = 0,
archived = true,
): AdminArchiveInput => ({
operationId: randomUUID(),
expectedVersion,
expectedRevision,
archived,
reason: 'Dedicated QA room archive',
});
function seed(store: Store, code = 'ARCHIVES') {
const host = newPlayer(randomUUID(), 'Private player name', 'atreides');
const game = createGame(code, host, false);
const token = secret(),
tokenHash = sha256(token),
revokedHash = sha256(secret());
store.sqlite
.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)')
.run(code, JSON.stringify(game), 0, 1000);
store.sqlite
.prepare(
'INSERT INTO seats(token_hash,room_code,player_id,revoked) VALUES(?,?,?,?)',
)
.run(tokenHash, code, host.id, 0);
store.sqlite
.prepare(
'INSERT INTO seats(token_hash,room_code,player_id,revoked) VALUES(?,?,?,?)',
)
.run(revokedHash, code, host.id, 1);
store.sqlite.prepare('INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES (?,1,1,100,100)').run(code);
return { host, game, token, tokenHash, revokedHash, code };
}
function seedDependents(store: Store, room: ReturnType<typeof seed>) {
const { code, host, tokenHash } = room;
store.sqlite
.prepare(
'INSERT INTO room_entry_receipts(operation_hash,request_hash,session_hash,room_code,player_id) VALUES(?,?,?,?,?)',
)
.run(sha256(secret()), sha256(secret()), tokenHash, code, host.id);
store.sqlite
.prepare(
'INSERT INTO seat_recovery_keys(room_code,player_id,recovery_hash,current_operation_hash) VALUES(?,?,?,?)',
)
.run(code, host.id, sha256(secret()), sha256(secret()));
store.sqlite
.prepare(
'INSERT INTO seat_recovery_receipts(room_code,player_id,operation_hash,session_hash,recovery_hash) VALUES(?,?,?,?,?)',
)
.run(code, host.id, sha256(secret()), tokenHash, sha256(secret()));
store.sqlite
.prepare(
'INSERT INTO seat_handover_offers(room_code,player_id,offer_hash,secret_hash,issuer_session_hash,expires_at) VALUES(?,?,?,?,?,?)',
)
.run(code, host.id, sha256(secret()), sha256(secret()), tokenHash, now + 1);
store.sqlite
.prepare(
'INSERT INTO seat_handover_claim_receipts(room_code,player_id,operation_hash,offer_hash,secret_hash,session_hash,claim_fence,claimed_at) VALUES(?,?,?,?,?,?,?,?)',
)
.run(
code,
host.id,
sha256(secret()),
sha256(secret()),
sha256(secret()),
tokenHash,
sha256(secret()),
now,
);
store.sqlite
.prepare(
'INSERT INTO seat_ai_delegations(room_code,owner_id,grant_id,delegate_id,difficulty,owner_session_hash,delegate_session_hash,expires_at,revoked_at) VALUES(?,?,?,?,?,?,?,?,?)',
)
.run(
code,
host.id,
randomUUID(),
randomUUID(),
'Hard',
tokenHash,
sha256(secret()),
now + 1,
now,
);
store.sqlite
.prepare(
'INSERT INTO room_messages(room_code,id,sender_id,sender_session_hash,sender_name,sender_faction,recipient_id,recipient_name,body,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)',
)
.run(
code,
randomUUID(),
host.id,
tokenHash,
host.name,
host.faction,
randomUUID(),
'Private recipient',
'Private saved discussion',
now,
);
}
async function fixture(role: AdminRole = 'operator') {
const store = adminStore();
const account = store.provision(role, 'Private administrator');
const login = await adminLogin(store.database, account.key, now);
const identity = await requireAdmin(
store.database,
login.token,
undefined,
now,
);
const room = seed(store);
const row = () =>
store.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(room.code)!;
const version = () => Number(row().version);
const read = () => readAdminArchive(store.database, identity, room.code, now);
const apply = (input = change(version()), at = now) =>
applyAdminArchive(store.database, identity, room.code, input, at);
return {
...store,
...room,
account,
login,
identity,
row,
version,
read,
apply,
};
}
function gateBatch(store: Store, number = 1) {
let enter!: () => void, release!: () => void;
const waiting = new Promise<void>((resolve) => {
enter = resolve;
});
const gate = new Promise<void>((resolve) => {
release = resolve;
});
store.hooks.beforeBatch = async () => {
if (--number) return;
delete store.hooks.beforeBatch;
enter();
await gate;
};
return { waiting, release };
}
void test('archive migration is additive and preserves all existing rooms, credentials and audit records', () => {
const store = adminStore(':memory:', false);
try {
for (const file of readdirSync(new URL('../drizzle/', import.meta.url))
.filter((file) => file.endsWith('.sql') && file < '0014')
.sort())
store.sqlite.exec(
readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'),
);
const room = seed(store);
seedDependents(store, room);
store.provision();
const tables = [
'rooms',
...secureTables,
'room_controls',
'admin_accounts',
'admin_sessions',
'admin_audit',
'admin_room_audit',
'admin_room_creations',
'admin_lobby_operations',
];
const before = tables.map((table) => rows(store, table));
store.sqlite.exec(
readFileSync(
new URL('../drizzle/0014_admin_room_archive.sql', import.meta.url),
'utf8',
),
);
assert.deepEqual(
tables.map((table) => rows(store, table)),
before,
);
assert.deepEqual(rows(store, 'room_archives'), []);
assert.deepEqual(rows(store, 'admin_room_archives'), []);
assert.throws(
() =>
store.sqlite
.prepare(
'INSERT INTO room_archives(room_code,archived,revision,updated_at) VALUES(?,1,0,?)',
)
.run(room.code, now),
/CHECK/,
);
} finally {
store.sqlite.close();
}
});
void test('archive inputs require exact bounded fields and both safe version counters', () => {
const valid = change();
assert.equal(validAdminArchiveInput(valid), true);
for (const invalid of [
null,
[],
{},
{ ...valid, extra: true },
{ ...valid, archived: 1 },
...['', ' ', 'x'.repeat(301), 'line\n', 'a\u007f'].map((reason) => ({
...valid,
reason,
})),
...['', valid.operationId + '\n', valid.operationId.toUpperCase()].map(
(operationId) => ({ ...valid, operationId }),
),
...[-1, 0.5, Infinity, NaN, Number.MAX_SAFE_INTEGER, '0'].flatMap(
(value) => [
{ ...valid, expectedVersion: value },
{ ...valid, expectedRevision: value },
],
),
Object.assign(Object.create({ archived: true }), {
operationId: valid.operationId,
expectedVersion: 0,
expectedRevision: 0,
reason: 'Reason',
other: true,
}),
])
assert.equal(validAdminArchiveInput(invalid), false);
});
void test('live roles can read only operational defaults without backfilling or disclosing room and seat contents', async () => {
for (const role of ['owner', 'operator', 'viewer'] as const) {
const f = await fixture(role);
try {
const before = f.row();
assert.deepEqual(await f.read(), {
code: f.code,
version: 0,
archived: false,
closed: true,
removed: false,
revision: 0,
archivedAt: null,
updatedAt: null,
paused: false,
joinLocked: false,
});
assert.deepEqual(f.row(), before);
assert.deepEqual(rows(f, 'room_archives'), []);
if (role === 'viewer') await assert.rejects(f.apply(), status(403));
else assert.equal((await f.apply()).room.archived, true);
await assert.rejects(
readAdminArchive(f.database, f.identity, f.code + '\n', now),
status(400),
);
await assert.rejects(
readAdminArchive(f.database, f.identity, 'ABSENTAA', now),
status(404),
);
await assert.rejects(
readAdminArchive(
f.database,
{ ...f.identity, sessionHash: f.tokenHash },
f.code,
now,
),
status(401),
);
} finally {
f.sqlite.close();
}
}
});
void test('archive and unarchive preserve saved JSON, every dependent record, revoked access and unrelated rooms', async () => {
const f = await fixture();
try {
seedDependents(f, f);
const other = seed(f, 'KEEPSAFE');
const old = f.row(),
secure = secureRows(f);
const untouched = f.sqlite
.prepare('SELECT * FROM rooms WHERE code=?')
.get(other.code);
const result = await f.apply();
assert.equal(result.replayed, false);
assert.equal(result.appliedVersion, 1);
assert.equal(result.appliedRevision, 1);
assert.deepEqual(result.room, {
code: f.code,
version: 1,
archived: true,
closed: true,
removed: false,
revision: 1,
archivedAt: now,
updatedAt: now,
paused: false,
joinLocked: false,
});
assert.equal(f.row().state, old.state);
assert.equal(f.row().updated_at, old.updated_at);
const reopend = await f.apply(change(1, 1, false), now + 5000);
assert.deepEqual(reopend.room, {
...result.room,
version: 2,
archived: false,
revision: 2,
archivedAt: null,
updatedAt: now + 5000,
});
assert.equal(f.row().state, old.state);
assert.equal(f.row().updated_at, old.updated_at);
assert.deepEqual(secureRows(f), secure);
assert.deepEqual(
f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(other.code),
untouched,
);
assert.equal(rows(f, 'room_archives').length, 1);
const audit = rows(f, 'admin_room_archives');
assert.equal(audit.length, 2);
for (const row of audit) {
const metadata = JSON.stringify([
JSON.parse(String(row.before_metadata)),
JSON.parse(String(row.after_metadata)),
]);
for (const privateValue of [
f.host.id,
f.host.name,
f.token,
f.tokenHash,
'Private saved discussion',
'spice',
'deck',
f.account.name,
])
assert.equal(metadata.includes(privateValue), false);
assert.equal(row.actor_admin_id, f.identity.id);
assert.equal(row.reason, change().reason);
}
} finally {
f.sqlite.close();
}
});
void test('archiving and unarchiving tolerate unreadable saves without parsing diagnostics or repairing JSON', async () => {
for (const state of [
'{invalid private save',
'null',
'[]',
'{"status":"playing","players":[null,7,"private"]}',
]) {
const f = await fixture();
try {
f.sqlite
.prepare('UPDATE rooms SET state=? WHERE code=?')
.run(state, f.code);
await f.apply();
assert.equal((await f.read()).archived, true);
await f.apply(change(1, 1, false));
assert.equal(f.row().state, state);
} finally {
f.sqlite.close();
}
}
});
void test('exact receipts bind actor, room and normalized intent and report current state after later transitions', async () => {
const f = await fixture();
try {
const input = { ...change(), reason: ' Exact QA reason ' };
const first = await f.apply(input);
const reopen = await f.apply(change(1, 1, false), now + 1);
const replay = await f.apply(
{ ...input, reason: 'Exact QA reason' },
now + 2,
);
assert.deepEqual(replay, { ...first, replayed: true, room: reopen.room });
const before = f.row();
for (const mismatch of [
{ ...input, expectedVersion: 1 },
{ ...input, expectedRevision: 1 },
{ ...input, archived: false },
{ ...input, reason: 'Other reason' },
])
await assert.rejects(f.apply(mismatch), status(409));
const account = f.provision('operator');
const login = await adminLogin(f.database, account.key, now);
const identity = await requireAdmin(
f.database,
login.token,
undefined,
now,
);
await assert.rejects(
applyAdminArchive(f.database, identity, f.code, input, now),
status(409),
);
seed(f, 'KEEPSAFE');
await assert.rejects(
applyAdminArchive(f.database, f.identity, 'KEEPSAFE', input, now),
status(409),
);
assert.deepEqual(f.row(), before);
assert.equal(rows(f, 'admin_room_archives').length, 2);
} finally {
f.sqlite.close();
}
});
void test('new no-op and stale requests reject without writes; both room version and archive revision fence races', async () => {
const f = await fixture();
try {
const before = f.row();
for (const input of [change(0, 0, false), change(1), change(0, 1)])
await assert.rejects(f.apply(input), status(409));
assert.deepEqual(f.row(), before);
assert.deepEqual(rows(f, 'admin_room_archives'), []);
const pending = change();
const gate = pauseAdminBatch(f);
const result = f.apply(pending).then(
() => null,
(error: unknown) => error,
);
await gate.waiting;
f.sqlite
.prepare('UPDATE rooms SET version=version+1 WHERE code=?')
.run(f.code);
gate.release();
assert.ok(status(409)(await result));
await f.apply(change(1));
await assert.rejects(f.apply(change(2, 1, true)), status(409));
await assert.rejects(f.apply(change(2, 0, false)), status(409));
assert.equal(rows(f, 'admin_room_archives').length, 1);
} finally {
f.sqlite.close();
}
});
void test('live session, account, role, expiry and generation authority fences reads, writes and exact replay', async () => {
for (const operation of ['read', 'write', 'replay'] as const)
for (const revoke of [
'disabled',
'role',
'session',
'generation',
'expiry',
] as const) {
const f = await fixture();
try {
const input = change();
if (operation === 'replay') await f.apply(input);
const before = f.row(),
audit = rows(f, 'admin_room_archives');
const gate = pauseAdminBatch(f);
const pending = (operation === 'read' ? f.read() : f.apply(input)).then(
() => null,
(error: unknown) => error,
);
await gate.waiting;
if (revoke === 'disabled')
f.sqlite
.prepare('UPDATE admin_accounts SET enabled=0 WHERE id=?')
.run(f.identity.id);
if (revoke === 'role')
f.sqlite
.prepare("UPDATE admin_accounts SET role='viewer' WHERE id=?")
.run(f.identity.id);
if (revoke === 'session')
f.sqlite
.prepare(
'UPDATE admin_sessions SET revoked_at=? WHERE token_hash=?',
)
.run(now, f.identity.sessionHash);
if (revoke === 'generation')
f.sqlite
.prepare(
'UPDATE admin_accounts SET session_generation=session_generation+1 WHERE id=?',
)
.run(f.identity.id);
if (revoke === 'expiry')
f.sqlite
.prepare(
'UPDATE admin_sessions SET expires_at=? WHERE token_hash=?',
)
.run(now, f.identity.sessionHash);
gate.release();
const result = await pending;
if (revoke === 'role' && operation === 'read')
assert.equal(result, null);
else assert.ok(status(revoke === 'role' ? 403 : 401)(result));
assert.deepEqual(f.row(), before);
assert.deepEqual(rows(f, 'admin_room_archives'), audit);
} finally {
f.sqlite.close();
}
}
});
void test('queued identical requests commit once while competing operations cannot reuse the old version', async () => {
for (const same of [true, false]) {
const f = await fixture();
try {
const input = change(),
gate = pauseAdminBatch(f);
const pending = f.apply(input).then(
(value) => value,
(error: unknown) => error,
);
await gate.waiting;
const first = await f.apply(same ? input : change());
gate.release();
const second = await pending;
if (same) assert.deepEqual(second, { ...first, replayed: true });
else assert.ok(status(409)(second));
assert.equal(f.version(), 1);
assert.equal(rows(f, 'admin_room_archives').length, 1);
} finally {
f.sqlite.close();
}
}
});
void test('audit, game fence and archive metadata roll back together at every failed persistence stage', async () => {
for (const table of ['admin_room_archives', 'rooms', 'room_archives']) {
const f = await fixture();
try {
const before = f.row(),
secure = secureRows(f);
f.sqlite.exec(
`CREATE TRIGGER qa_failure BEFORE ${table === 'rooms' ? 'UPDATE' : 'INSERT'} ON ${table} BEGIN SELECT RAISE(ABORT,'QA archive failure'); END;`,
);
await assert.rejects(f.apply(), /QA archive failure/);
assert.deepEqual(f.row(), before);
assert.deepEqual(secureRows(f), secure);
assert.deepEqual(rows(f, 'admin_room_archives'), []);
assert.deepEqual(rows(f, 'room_archives'), []);
} finally {
f.sqlite.close();
}
}
});
void test('archived games and exact receipts survive database reopen without restoring historical credentials', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dune-admin-archive-'));
let store = adminStore(join(dir, 'rooms.sqlite'));
try {
const account = store.provision('operator'),
login = await adminLogin(store.database, account.key, now);
const identity = await requireAdmin(
store.database,
login.token,
undefined,
now,
),
room = seed(store);
seedDependents(store, room);
const secure = secureRows(store),
state = rows(store, 'rooms')[0].state;
const input = change();
const archived = await applyAdminArchive(
store.database,
identity,
room.code,
input,
now,
);
store.sqlite.close();
store = adminStore(join(dir, 'rooms.sqlite'), false);
assert.deepEqual(
await applyAdminArchive(store.database, identity, room.code, input, now),
{ ...archived, replayed: true },
);
await applyAdminArchive(
store.database,
identity,
room.code,
change(1, 1, false),
now + 10000,
);
assert.equal(rows(store, 'rooms')[0].state, state);
assert.deepEqual(secureRows(store), secure);
const audit = rows(store, 'admin_room_archives');
store.sqlite.prepare('DELETE FROM rooms WHERE code=?').run(room.code);
assert.deepEqual(rows(store, 'admin_room_archives'), audit);
} finally {
store.sqlite.close();
rmSync(dir, { recursive: true, force: true });
}
});
void test('archive requires closure; unarchive leaves closure and reopening requires unarchive first', async () => {
const f = await fixture();
try {
const native = await readAdminClosure(f.database, f.identity, f.code, now);
const reopen = { operationId: randomUUID(), expectedVersion: f.version(), expectedRevision: native.revision, closed: false, reason: 'QA reopening' };
const oldState = f.row().state;
await applyAdminClosure(f.database, f.identity, f.code, reopen, now);
const open = f.row();
await assert.rejects(f.apply(change(f.version())), /Close this room/);
assert.deepEqual(f.row(), open);
const close = { ...reopen, operationId: randomUUID(), expectedVersion: f.version(), expectedRevision: 2, closed: true };
await applyAdminClosure(f.database, f.identity, f.code, close, now);
const input = change(f.version());
await f.apply(input);
const archived = f.row();
assert.equal((await readAdminClosure(f.database, f.identity, f.code, now)).archived, true);
await assert.rejects(applyAdminClosure(f.database, f.identity, f.code, { ...reopen, operationId: randomUUID(), expectedVersion: f.version(), expectedRevision: 3 }, now), /Unarchive/);
assert.deepEqual(f.row(), archived);
const oldClosureReceipt = await applyAdminClosure(f.database, f.identity, f.code, close, now);
assert.equal(oldClosureReceipt.replayed, true);
assert.equal(oldClosureReceipt.room.archived, true);
const unarchive = change(f.version(), 1, false);
await f.apply(unarchive);
assert.equal((await f.read()).closed, true);
assert.equal((await readAdminClosure(f.database, f.identity, f.code, now)).archived, undefined);
await applyAdminClosure(f.database, f.identity, f.code, { ...reopen, operationId: randomUUID(), expectedVersion: f.version(), expectedRevision: 3 }, now);
assert.equal((await f.read()).closed, false);
assert.equal(f.row().state, oldState);
const after = f.row();
const receipt = await f.apply(input);
assert.equal(receipt.replayed, true); assert.equal(receipt.room.closed, false); assert.equal(receipt.room.archived, false);
assert.deepEqual(f.row(), after);
} finally { f.sqlite.close(); }
});
void test('removal and restoration preserve archived closure and every saved AI deadline and credential', async () => {
for (const paused of [false, true]) for (const controller of ['bot', 'autopilot', 'human'] as const) {
const f = await fixture();
try {
const game = structuredClone(f.game);
game.status = 'playing';
if (controller !== 'human') game.players[0][controller] = 'Hard';
game.botsPending = controller !== 'human'; game.botNextActionAt = 5;
f.sqlite.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), f.code);
f.sqlite.prepare('INSERT INTO room_controls(room_code,paused,join_locked,revision,updated_at) VALUES (?,?,1,1,100)').run(f.code, Number(paused));
seedDependents(f, f);
const original = f.row(), secure = secureRows(f), controls = rows(f, 'room_controls');
const input = change();
await f.apply(input);
const remove = { operationId: randomUUID(), expectedVersion: f.version(), expectedRevision: 0, removed: true, reason: 'QA removal' };
await applyAdminRemoval(f.database, f.identity, f.code, remove, now);
assert.equal((await readAdminRemoval(f.database, f.identity, f.code, now)).archived, true);
const removed = f.row();
const receipt = await f.apply(input); assert.equal(receipt.replayed, true); assert.equal(receipt.room.removed, true);
await assert.rejects(f.apply(change(f.version(), 1, false)), /Restore/);
assert.deepEqual(f.row(), removed);
await applyAdminRemoval(f.database, f.identity, f.code, { ...remove, operationId: randomUUID(), expectedVersion: f.version(), expectedRevision: 1, removed: false }, now + 10000);
assert.equal((await f.read()).archived, true); assert.equal((await f.read()).closed, true);
const beforeUnarchive = f.row();
await f.apply(change(f.version(), 1, false), now + 20000);
assert.equal(f.row().state, original.state);
assert.equal(f.row().updated_at, beforeUnarchive.updated_at);
assert.deepEqual(secureRows(f), secure); assert.deepEqual(rows(f, 'room_controls'), controls);
assert.equal((await f.read()).closed, true);
} finally { f.sqlite.close(); }
}
});
void test('reopening and archiving cannot both win a transaction race, in either order', async () => {
for (const archiveFirst of [false, true]) {
const f = await fixture();
try {
const before = f.row().state;
const archive = () => f.apply(change());
const reopen = () => applyAdminClosure(f.database, f.identity, f.code, { operationId: randomUUID(), expectedVersion: 0, expectedRevision: 1, closed: false, reason: 'QA reopen race' }, now);
const gate = gateBatch(f);
const pending = (archiveFirst ? reopen() : archive()).then(() => null, (error: unknown) => error);
await gate.waiting;
await (archiveFirst ? archive() : reopen()); gate.release();
assert.ok(status(409)(await pending));
const current = await f.read(); assert.equal(current.archived, archiveFirst); assert.equal(current.closed, archiveFirst);
assert.equal(f.row().state, before); assert.equal(f.version(), 1);
assert.equal(rows(f, 'admin_room_archives').length + rows(f, 'admin_room_closures').length, 1);
} finally { f.sqlite.close(); }
}
});
void test('removal fences a queued archive or unarchive without erasing a prior exact receipt', async () => {
for (const unarchive of [false, true]) {
const f = await fixture();
try {
const original = change();
if (unarchive) await f.apply(original);
const gate = gateBatch(f);
const pending = f.apply(change(f.version(), unarchive ? 1 : 0, !unarchive)).then(() => null, (error: unknown) => error);
await gate.waiting;
await applyAdminRemoval(f.database, f.identity, f.code, { operationId: randomUUID(), expectedVersion: f.version(), expectedRevision: 0, removed: true, reason: 'QA remove race' }, now);
const removed = f.row(); gate.release();
assert.ok(status(409)(await pending)); assert.deepEqual(f.row(), removed);
assert.equal((await f.read()).archived, unarchive);
if (unarchive) assert.equal((await f.apply(original)).replayed, true);
} finally { f.sqlite.close(); }
}
});
+8 -1
View File
@@ -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<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`]) {
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`]) {
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');
@@ -50,6 +50,13 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden
assert.equal(closure.status, 401);
assert.equal(closure.headers.get('set-cookie'), null);
assert.deepEqual(Object.keys(await closure.json()), ['error']);
const archive = await fetch(`${base}/api/admin/rooms/${room}/archive`, {
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' },
body: JSON.stringify({ operationId: crypto.randomUUID(), expectedVersion: 0, expectedRevision: 0, archived: true, reason: 'Denied archive QA' }),
});
assert.equal(archive.status, 401);
assert.equal(archive.headers.get('set-cookie'), null);
assert.deepEqual(Object.keys(await archive.json()), ['error']);
const create = await fetch(base + '/api/admin/rooms', {
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' },
body: JSON.stringify({ operationId: crypto.randomUUID(), sessionToken: 'b'.repeat(64), name: 'Denied creation QA', faction: 'atreides', advanced: false, techTokens: false, strongholdCards: false, bots: [], reason: 'Must not create a room' }),
+10
View File
@@ -82,3 +82,13 @@ void test('uncertain closure and changed-access responses keep the exact saved r
}
for (const status of [400, 404, 409, 422]) assert.equal(retainAdminRoomRequest(new ClientRequestError('Rejected', 'http', status)), false);
});
void test('closure projections and exact receipts retain only the explicit archived flag', () => {
const room = view(), input = newAdminClosureRequest(room, true, 'QA');
assert.deepEqual(adminClosureResponse({ ...room, archived: true }, room.code), { ...room, archived: true });
for (const archived of [false, undefined, null, 1, 'true'])
assert.throws(() => adminClosureResponse({ ...room, archived }, room.code));
const receipt = { operationId: input.operationId, appliedVersion: 8, appliedRevision: 3, replayed: true, room: { ...room, version: 12, revision: 4, archived: true } };
assert.deepEqual(adminClosureConfirmation(receipt, room.code, input), receipt);
assert.throws(() => newAdminClosureRequest({ ...room, closed: true, archived: true }, false, 'Reopen'), /Unarchive/);
});
+32
View File
@@ -114,3 +114,35 @@ void test('closed rooms remain in the directory but are excluded from running ro
assert.deepEqual((await read('availability=running')).rooms.map(room => room.code), ['OPENROOM']);
} finally { f.sqlite.close(); }
});
void test('archive filters compose with removal, availability, private-safe search and stable pagination', async () => {
const f = await fixture();
try {
insert(f.sqlite, 'OPENROOM', state('Open QA'));
for (let n = 0; n < 28; n++) {
const code = `AR${String(n).padStart(6, '0')}`;
insert(f.sqlite, code, state(n === 0 ? 'Archived %_ QA' : 'Archived QA'));
f.sqlite.prepare('INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES (?,1,1,100,100)').run(code);
f.sqlite.prepare('INSERT INTO room_archives(room_code,archived,revision,archived_at,updated_at) VALUES (?,1,1,100,100)').run(code);
if (n === 27) f.sqlite.prepare('INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES (?,1,1,100,100)').run(code);
}
const read = (q = '') => readAdminDirectory(f.database, f.identity, new URLSearchParams(q));
assert.deepEqual((await read()).rooms.map(room => room.code), ['OPENROOM']);
assert.equal((await read('q=Archived')).total, 0);
const first = await read('archive=archived'), second = await read('archive=archived&page=2');
assert.equal(first.total, 27); assert.equal(first.rooms.length, 25); assert.equal(second.rooms.length, 2);
assert.equal(new Set([...first.rooms, ...second.rooms].map(room => room.code)).size, 27);
assert.ok(first.rooms.every(room => room.archived && room.control.closed && !room.removed));
assert.equal(JSON.stringify(first).includes('SECRET'), false);
assert.equal((await read('archive=all')).total, 28);
assert.equal((await read('removal=all')).total, 1);
assert.equal((await read('removal=removed')).total, 0);
assert.equal((await read('archive=all&removal=all')).total, 29);
assert.equal((await read('archive=archived&removal=removed')).total, 1);
assert.equal((await read('archive=archived&availability=closed')).total, 27);
assert.equal((await read('archive=all&availability=running')).total, 1);
assert.equal((await read('archive=archived&q=%25_')).total, 1);
assert.equal((await read('archive=all&q=SECRET')).total, 0);
for (const q of ['archive=unknown', 'archive=all&archive=archived']) await assert.rejects(read(q), (e: unknown) => e instanceof AdminError && e.status === 400);
} finally { f.sqlite.close(); }
});
+9
View File
@@ -81,3 +81,12 @@ void test('uncertain removal and changed-access responses keep the exact saved r
}
for (const status of [400, 404, 409, 422]) assert.equal(retainAdminRoomRequest(new ClientRequestError('Rejected', 'http', status)), false);
});
void test('removal projections and exact receipts retain only the explicit archived flag', () => {
const room = view(), input = newAdminRemovalRequest(room, true, 'QA');
assert.deepEqual(adminRemovalResponse({ ...room, archived: true }, room.code), { ...room, archived: true });
for (const archived of [false, undefined, null, 1, 'true'])
assert.throws(() => adminRemovalResponse({ ...room, archived }, room.code));
const receipt = { operationId: input.operationId, appliedVersion: 8, appliedRevision: 3, replayed: true, room: { ...room, version: 12, revision: 4, archived: true } };
assert.deepEqual(adminRemovalConfirmation(receipt, room.code, input), receipt);
});
+88
View File
@@ -0,0 +1,88 @@
// Mutations are confined to the new QA room; archiving never edits saved game content.
import assert from 'node:assert/strict';
import { randomBytes, randomUUID } from 'node:crypto';
export async function verifyRoomArchive(request, adminCookie, report) {
let code, path, adminPath, failure;
async function transition(kind, flag, value, saved) {
const current = await request(adminPath + '/' + kind, undefined, adminCookie); assert.equal(current.status, 200);
const input = saved ?? { operationId: randomUUID(), expectedVersion: current.data.version, expectedRevision: current.data.revision, [flag]: value, reason: 'Dedicated archive acceptance' };
const response = await request(adminPath + '/' + kind, input, adminCookie);
assert.equal(response.status, 200); assert.equal(response.rawCookie, null);
assert.equal(response.data.operationId, input.operationId);
assert.equal(response.data.appliedVersion, input.expectedVersion + 1); assert.equal(response.data.appliedRevision, input.expectedRevision + 1);
return { ...response.data, input };
}
try {
const creation = { operationId: randomUUID(), sessionToken: randomBytes(32).toString('hex'), name: 'Archive QA ' + Date.now(), faction: 'atreides', advanced: false,
techTokens: false, strongholdCards: false, bots: [], reason: 'Dedicated archive acceptance' };
const made = await request('/api/admin/rooms', creation, adminCookie); assert.equal(made.status, 200);
code = made.data.code; report.archiveRoom = code; path = '/api/rooms/' + code; adminPath = '/api/admin/rooms/' + code;
const host = made.cookie;
const joined = await request(path, { type: 'join', name: 'Archive participant QA', faction: 'emperor' }); assert.equal(joined.status, 200);
for (const cookie of [host, joined.cookie]) {
const current = await request(path, undefined, cookie);
assert.equal((await request(path, { version: current.data.version, action: { type: 'ready' } }, cookie)).status, 200);
}
const ready = await request(path, undefined, host);
assert.equal((await request(path, { version: ready.data.version, action: { type: 'start' } }, host)).status, 200);
const message = { id: randomUUID(), recipientId: null, text: 'Archive continuity QA' };
assert.equal((await request(path + '/messages', message, host)).status, 200);
const initial = await request(adminPath + '/archive', undefined, adminCookie); assert.equal(initial.status, 200);
const early = { operationId: randomUUID(), expectedVersion: initial.data.version, expectedRevision: initial.data.revision, archived: true, reason: 'Reject archive of open room' };
assert.equal((await request(adminPath + '/archive', early, adminCookie)).status, 409);
const close = await transition('closure', 'closed', true);
const before = (await request(path, undefined, host)).data, history = (await request(path + '/messages', undefined, host)).data;
const input = { ...early, operationId: randomUUID(), expectedVersion: before.version };
for (const denied of [await request(adminPath + '/archive', input, host), await request(adminPath + '/archive', input, adminCookie, 'https://foreign.invalid'), await request(adminPath + '/archive', input, adminCookie, undefined, randomUUID())]) {
assert.ok([401, 403].includes(denied.status)); assert.equal(denied.rawCookie, null);
}
const archived = await transition('archive', 'archived', true, input); assert.equal(archived.room.archived, true); assert.equal(archived.room.closed, true);
assert.equal((await transition('archive', 'archived', true, input)).replayed, true);
assert.equal((await request('/api/admin/rooms?q=' + code, undefined, adminCookie)).data.total, 0);
const directory = await request('/api/admin/rooms?archive=archived&q=' + code, undefined, adminCookie);
assert.equal(directory.status, 200); assert.equal(directory.data.total, 1); assert.equal(directory.data.rooms[0].archived, true);
const view = await request(path, undefined, host); assert.equal(view.status, 200);
assert.deepEqual({ ...view.data, version: before.version }, before);
assert.equal((await request(path, undefined, joined.cookie)).data.me, joined.data.me);
assert.deepEqual((await request(path + '/messages', undefined, host)).data, history);
assert.equal((await request(path + '/messages', message, host)).data.replayed, true);
report.checks.push('Archive requires a closed room and live administrator authority; exact replay, archive filters and private saved table/history remain intact');
const closure = await request(adminPath + '/closure', undefined, adminCookie);
assert.equal(closure.data.archived, true);
assert.equal((await request(adminPath + '/closure', { ...close.input, operationId: randomUUID(), expectedVersion: view.data.version, expectedRevision: closure.data.revision, closed: false }, adminCookie)).status, 409);
const removed = await transition('removal', 'removed', true); assert.equal(removed.room.archived, true); assert.equal(removed.room.closed, true);
assert.equal((await request('/api/admin/rooms?removal=all&q=' + code, undefined, adminCookie)).data.total, 0);
assert.equal((await request('/api/admin/rooms?removal=all&archive=all&q=' + code, undefined, adminCookie)).data.total, 1);
assert.equal((await transition('archive', 'archived', true, input)).replayed, true);
const unavailable = await request(adminPath + '/archive', undefined, adminCookie);
assert.equal((await request(adminPath + '/archive', { ...input, operationId: randomUUID(), expectedVersion: unavailable.data.version, expectedRevision: unavailable.data.revision, archived: false }, adminCookie)).status, 409);
const restored = await transition('removal', 'removed', false); assert.equal(restored.room.archived, true); assert.equal(restored.room.closed, true);
assert.deepEqual({ ...(await request(path, undefined, host)).data, version: before.version }, before);
report.checks.push('Archived rooms cannot reopen; removal and restoration retain archive/closure, with explicit combined filters and confirmable prior archive receipts');
const unarchived = await transition('archive', 'archived', false); assert.equal(unarchived.room.archived, false); assert.equal(unarchived.room.closed, true);
assert.equal((await request('/api/admin/rooms?q=' + code, undefined, adminCookie)).data.total, 1);
assert.deepEqual({ ...(await request(path, undefined, host)).data, version: before.version }, before);
const reopened = await transition('closure', 'closed', false); assert.equal(reopened.room.closed, false);
const after = (await request(path, undefined, host)).data;
const replay = await transition('archive', 'archived', true, input); assert.equal(replay.replayed, true); assert.equal(replay.room.closed, false); assert.equal(replay.room.archived, false);
assert.deepEqual((await request(path, undefined, host)).data, after);
assert.deepEqual((await request(path + '/messages', undefined, host)).data, history);
report.checks.push('Unarchive returns the directory entry but leaves play closed; separate reopening restores the same table and old archive receipts cannot rearchive it');
} catch (error) {
report.failureRequest ??= report.lastRequest;
report.archiveFailure = 'Archive acceptance failed; the dedicated QA room is retained.';
report.archiveFailureLocation = error instanceof Error ? error.stack?.split('\n').find(line => line.trim().startsWith('at ') && line.includes('admin-archive-verification.mjs'))?.trim() : undefined;
failure = error;
} finally {
if (code) try {
const removed = await request(adminPath + '/removal', undefined, adminCookie); assert.equal(removed.status, 200);
if (removed.data.removed) await transition('removal', 'removed', false);
const archive = await request(adminPath + '/archive', undefined, adminCookie); assert.equal(archive.status, 200);
if (archive.data.archived) await transition('archive', 'archived', false);
const closed = await request(adminPath + '/closure', undefined, adminCookie); assert.equal(closed.status, 200);
if (closed.data.closed) await transition('closure', 'closed', false);
} catch (error) { report.archiveCleanupFailure = { message: 'Dedicated QA unarchive/reopening could not be confirmed.', request: report.lastRequest }; failure ??= error; }
}
if (failure) throw failure;
}
+3 -1
View File
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
import { verifyRoomLifecycle } from './admin-lifecycle-verification.mjs';
import { verifyRoomCreation } from './admin-creation-verification.mjs';
import { verifyLobbyConfiguration } from './admin-lobby-verification.mjs';
import { verifyRoomArchive } from './admin-archive-verification.mjs';
import { verifyRoomClosure } from './admin-closure-verification.mjs';
import { verifyRoomRemoval } from './admin-removal-verification.mjs';
@@ -34,7 +35,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)$/.test(path)) ? { 'X-Dune-Admin-Id': expectedAdminId } : {}) },
...(expectedAdminId && (path === '/api/admin/rooms' && body !== undefined || /^\/api\/admin\/rooms\/[^/]+\/(control|lobby|removal|closure|archive)$/.test(path)) ? { 'X-Dune-Admin-Id': expectedAdminId } : {}) },
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15000),
});
report.lastRequest = { path, status: response.status };
@@ -91,6 +92,7 @@ try {
await verifyLobbyConfiguration(request, session1, report);
await verifyRoomRemoval(request, session1, report);
await verifyRoomClosure(request, session1, report);
await verifyRoomArchive(request, session1, report);
}
report.passed = true;
} catch (error) {