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:
1 parent
98b1b3261b
commit
e918e2e0c0
35 files changed
+3624
-38
No files matched your search
@@ -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
@@ -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); }
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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.',
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export type AdminClosureInput = {
|
||||
};
|
||||
|
||||
export type AdminClosureView = {
|
||||
archived?: true;
|
||||
removed: boolean;
|
||||
code: string;
|
||||
version: number;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,6 +7,7 @@ export type AdminRemovalInput = {
|
||||
};
|
||||
|
||||
export type AdminRemovalView = {
|
||||
archived?: true;
|
||||
closed?: true;
|
||||
code: string;
|
||||
version: number;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(); }
|
||||
}
|
||||
});
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user