Add administrative room close and reopen controls

Preserve readable private tables, discussion and pending games while closure blocks new play, joins, messages, AI and seat mutations. Add live-authorized atomic closure audits, room-version/revision fences, dedicated controls and exact saved retries. Reopen with the saved pause/join settings and current credentials; restoring a removed but closed room keeps its AI stopped.

Verified types, lint, 5356 offline tests, 52 HTTP tests and build. Seventeen dedicated authenticated HTTP groups and independent privacy/persistence review passed. Desktop/mobile browser QA confirmed one close after a lost response and refresh, persistent readable notices, retained private draft, mobile reopening and saved-seat refresh. All 2,001 baseline rooms, 4,243 seats and related records preserved. Positive production admin acceptance awaits authorized QA access; full admin and rules gates remain open.
This commit is contained in:
Quintine committed 2026-09-22 12:31:02 +10:00
1 parent 238de3860d
commit 98b1b3261b
49 files changed
+4255 -86

No files matched your search

+3 -1
View File
@@ -48,7 +48,9 @@ an explicitly owned new host seat and initial AI players.
[Configure lobby](docs/ADMIN_LOBBY_CONFIGURATION.md) manages supported rules, AI
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/end, archive,
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.
## Saved seats and reconnecting
+11 -5
View File
@@ -11,6 +11,7 @@ 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 { AdminRoomClosure } from '@/components/admin-room-closure';
import { AdminRoomRemoval } from '@/components/admin-room-removal';
import './admin.css';
@@ -31,6 +32,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 [selectedClosure, setSelectedClosure] = useState<string | null>(null);
const [selectedRemoval, setSelectedRemoval] = useState<string | null>(null);
const [creatingRoom, setCreatingRoom] = useState(false);
const [operationBusy, setOperationBusy] = useState(false);
@@ -39,7 +41,7 @@ export default function Administration() {
const clearSession = useCallback(() => {
epoch.current++;
setAccount(null); setDirectory(null); setConfirmAll(false);
setBusy(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setCreatingRoom(false);
setBusy(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setCreatingRoom(false);
}, []);
const accessChanged = useCallback(() => {
clearSession();
@@ -115,7 +117,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); 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); 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}
@@ -132,7 +134,7 @@ export default function Administration() {
<option value="all">All rules</option><option value="basic">Basic</option><option value="advanced">Advanced</option>
</select></label>
<label>Availability<select disabled={controlsBusy} value={filters.availability} onChange={e => setFilters({ ...filters, availability: e.target.value })}>
<option value="all">All rooms</option><option value="running">Running</option><option value="paused">Paused</option><option value="locked">New joins locked</option>
<option value="all">All rooms</option><option value="running">Running</option><option value="closed">Closed</option><option value="paused">Paused</option><option value="locked">New joins locked</option>
</select></label>
<label>Sort<select disabled={controlsBusy} value={filters.sort} onChange={e => setFilters({ ...filters, sort: e.target.value })}>
<option value="recent">Recently changed</option><option value="oldest">Oldest change</option><option value="code">Room code</option>
@@ -145,6 +147,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} />}
{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} />}
{selectedRemoval && <AdminRoomRemoval key={`${account.id}:${selectedRemoval}`} accountId={account.id} code={selectedRemoval}
canManage={account.role !== 'viewer'} onClose={() => setSelectedRemoval(null)} onDenied={accessChanged}
onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />}
@@ -157,6 +162,7 @@ export default function Administration() {
</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.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>}
<p>Host: {room.players.find(p => p.id === room.host)?.name ?? 'Unavailable'}</p>
@@ -169,7 +175,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); setSelectedLobby(null); setSelectedRemoval(null); setSelectedRoom(room.code); }}>Room controls · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedRoom(null); setSelectedRemoval(null); setSelectedLobby(room.code); }}>Configure lobby · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(room.code); }}>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(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>
</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>
@@ -185,7 +191,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, and recoverably remove or restore rooms. Close/end, 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, and recoverably remove or restore rooms. Archive, permanent removal, participant support and backup tools are still being implemented.</p>
</>}
</main>;
}
@@ -0,0 +1,35 @@
import { env, waitUntil } from 'cloudflare:workers';
import { AdminError, requireAdmin } from '@/db/admin-access';
import { applyAdminClosure, readAdminClosure } from '@/db/admin-closure';
import { resumeRoom } from '@/db/room-continuation';
import { validAdminClosureInput } from '@/lib/admin-closure';
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 readAdminClosure(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 (!validAdminClosureInput(input)) throw new AdminError('Choose closure or reopening, current versions and an operational reason.', 400);
const room = code(request);
const result = await applyAdminClosure(env.DB, identity, room, input);
if (!result.replayed && !result.room.closed && !result.room.paused) waitUntil(resumeRoom(room));
return adminResponse(result);
} catch (error) { return adminFailure(error); }
}
+1 -1
View File
@@ -29,7 +29,7 @@ export async function POST(request: Request) {
if (!validAdminRoomControlInput(body)) throw new AdminError('Provide room settings and a reason of 1–300 characters.', 400);
const code = roomCode(request);
const result = await applyAdminRoomControl(env.DB, identity, code, body);
if (!result.paused) waitUntil(resumeRoom(code));
if (!result.paused && !result.closed) waitUntil(resumeRoom(code));
return adminResponse(result);
} catch (error) { return adminFailure(error); }
}
+1 -1
View File
@@ -29,7 +29,7 @@ export async function POST(request: Request) {
if (!validAdminRemovalInput(input)) throw new AdminError('Choose removal or restoration, current versions and an operational reason.', 400);
const room = code(request);
const result = await applyAdminRemoval(env.DB, identity, room, input);
if (!result.replayed && !result.room.removed && !result.room.paused) waitUntil(resumeRoom(room));
if (!result.replayed && !result.room.removed && !result.room.paused && !result.room.closed) waitUntil(resumeRoom(room));
return adminResponse(result);
} catch (error) { return adminFailure(error); }
}
+1 -1
View File
@@ -86,7 +86,7 @@ export async function POST(req: Request) {
ownerId: input.ownerId,
grantId: input.grantId,
});
if (!result.view.roomControl?.paused && (result.view.botsPending || needsAutomaticRoomRecovery(result.view)))
if (!result.view.roomControl?.paused && !result.view.roomControl?.closed && (result.view.botsPending || needsAutomaticRoomRecovery(result.view)))
waitUntil(resumeRoom(code));
return Response.json(result, { headers: noStore });
}
+2 -2
View File
@@ -56,7 +56,7 @@ export async function GET(req: Request) {
const view = await readSeatView(code, id);
// Reconnect resumes persisted automatic effects/acknowledgements and AI work
// after an interruption. Actual human choices remain engine-owned decisions.
if (!view.roomControl?.paused && (view.botsPending || needsAutomaticRoomRecovery(view)))
if (!view.roomControl?.paused && !view.roomControl?.closed && (view.botsPending || needsAutomaticRoomRecovery(view)))
waitUntil(resumeRoom(code));
return Response.json(view, {
headers: { 'Cache-Control': 'no-store' },
@@ -132,7 +132,7 @@ export async function POST(req: Request) {
)
throw new RuleError('The action is invalid.');
const view = await act(code, id, body.version, body.action);
if (!view.roomControl?.paused && (view.botsPending || needsAutomaticRoomRecovery(view)))
if (!view.roomControl?.paused && !view.roomControl?.closed && (view.botsPending || needsAutomaticRoomRecovery(view)))
waitUntil(resumeRoom(code));
return Response.json(view, {
headers: { 'Cache-Control': 'no-store' },
+2 -1
View File
@@ -189,7 +189,7 @@ export default function Home() {
if (
!data.botsPending ||
removedRooms.current.has(room) ||
data.roomControl?.paused ||
data.roomControl?.paused || data.roomControl?.closed ||
activeRoom.current !== room ||
data.me !== activeSeat.current ||
data.version < (knownVersions.current.get(room) ?? -1) ||
@@ -526,6 +526,7 @@ export default function Home() {
async function send(action: Action) {
if (!game || removedRooms.current.has(game.code) || mutationPending.current || recovery.current) return;
if (game.roomControl?.closed) { setNotice('An administrator closed this room. The saved table remains readable; play resumes after reopening.'); return; }
if (game.roomControl?.paused && !(action.type === 'setAutopilot' && action.difficulty === null)) {
setNotice('An administrator paused this room. Game decisions will be available when the room resumes.');
return;
+147
View File
@@ -0,0 +1,147 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { ClientRequestError, requestJson, requestMayHaveCompleted } from '@/lib/client-request';
import { retainAdminRoomRequest } from '@/lib/admin-room-control-client';
import { adminClosureResponse, adminClosureConfirmation, clearAdminClosureRequest, newAdminClosureRequest, readAdminClosureRequest, saveAdminClosureRequest } from '@/lib/admin-closure-client';
import type { AdminClosureInput, AdminClosureView } from '@/lib/admin-closure';
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 AdminRoomClosure({ accountId, code, canManage, onClose, onUpdated, onDenied, onBusyChange }: Props) {
const [room, setRoom] = useState<AdminClosureView | null>(null);
const [reason, setReason] = useState('');
const [confirmed, setConfirmed] = useState(false);
const [confirmationCode, setConfirmationCode] = useState('');
const [pending, setPending] = useState<AdminClosureInput | 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}/closure`;
useEffect(() => {
alive.current = true; title.current?.focus();
let canceled = false;
try {
const saved = readAdminClosureRequest(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 closure or reopening 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 = adminClosureResponse(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 = adminClosureResponse(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?: AdminClosureInput) {
if (inFlight.current || loading || !canManage || storageProblem ||
(!saved && (!room || room.removed || pending || !confirmed || (!room.closed && confirmationCode !== code)))) return;
let input: AdminClosureInput;
try {
input = saved ?? newAdminClosureRequest(room!, !room!.closed, reason);
saveAdminClosureRequest(window.sessionStorage, accountId, code, input);
} catch (error) { setNotice(message(error)); return; }
inFlight.current = true; setBusy(true); onBusyChange(true); setPending(input); setNotice(input.closed ? 'Closing room…' : 'Reopening room…');
try {
const result = adminClosureConfirmation(await requestJson<unknown>(url, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input),
}), code, input);
clearAdminClosureRequest(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.closed ? 'Room closed to further play. Existing players can still read their saved table and discussion.' : 'Room reopened with its saved game, seats and previous pause and joining settings.');
onUpdated();
}
} catch (error) {
let cleared = false;
if (!retainAdminRoomRequest(error)) {
try { clearAdminClosureRequest(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 = adminClosureResponse(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 {
clearAdminClosureRequest(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-closure-title" aria-busy={loading || busy}>
<div className="admin-section-heading"><h2 id="admin-closure-title" ref={title} tabIndex={-1}>Close or reopen · {code}</h2><Button variant="outline" disabled={busy} onClick={onClose}>Close room availability controls</Button></div>
{notice && <output className="admin-notice">{notice}</output>}
{loading ? <output>Loading room availability…</output> : <>
{room ? <>
<p>Current availability: <strong>{room.closed ? 'Closed' : 'Open'}</strong>. Saved pause setting: <strong>{room.paused ? 'Paused' : 'Running'}</strong> · New joins <strong>{room.joinLocked ? 'locked' : 'open'}</strong>.</p>
{room.closed && <p>The table and discussion history remain readable to existing players. New play, messages and seat changes are stopped.</p>}
{room.closedAt !== null && <p className="admin-secondary">Closed at: <time dateTime={new Date(room.closedAt).toISOString()}>{new Date(room.closedAt).toLocaleString()}</time>.</p>}
<Button variant="outline" disabled={busy} onClick={() => void refresh()}>Refresh room availability</Button>
</> : <Button disabled={busy} onClick={() => void refresh()}>Retry loading room availability</Button>}
{!canManage ? <p>Your viewer role can inspect room availability. An owner or operator can close or reopen 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 change already saved on the server.</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.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>
<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(); }}>
<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">
{room.closed ? <>
<p>Reopen play in room <strong>{code}</strong>. Saved progress and seats are retained, and current credentials and access revocations remain in effect.</p>
<p>Reopening preserves the previous pause and joining settings. {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>Close room <strong>{code}</strong> to further play. This stops joining, game decisions, AI, new messages and seat security changes. Existing players can still read their own table and discussion history.</p>
<p>Warn active players before interrupting their game. Closing preserves the exact pending game and seats. It does not award a winner. An administrator can reopen it later.</p>
<label htmlFor="admin-closure-code">Type {code} to confirm this room<Input id="admin-closure-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.closed ? `I have reviewed room ${code} and its saved pause setting and am ready to reopen it.` : `I have checked room ${code}, warned active players, and am ready to close it.`}</label>
<Button type="submit" disabled={busy || !confirmed || !reason.trim() || (!room.closed && confirmationCode !== code)}>{room.closed ? 'Reopen room' : 'Close room'}</Button>
</div>
</form>}
</>}
</section>;
}
+2 -2
View File
@@ -133,7 +133,7 @@ export function AdminRoomControls({ accountId, code, canManage, onClose, onUpdat
<div className="admin-section-heading"><h2 id="admin-room-control-title" ref={title} tabIndex={-1}>Room {code}</h2><Button variant="outline" disabled={busy} onClick={onClose}>Close room controls</Button></div>
{notice && <output className="admin-notice">{notice}</output>}
{loading ? <output>Loading current room settings…</output> : !control ? <Button disabled={busy} onClick={() => void refresh()}>Retry loading settings</Button> : <>
<p>Current status: <strong>{control.paused ? 'Paused' : 'Running'}</strong> · New joins <strong>{control.joinLocked ? 'locked' : 'open'}</strong>.</p>
<p>Current status: <strong>{control.closed ? 'Closed' : control.paused ? 'Paused' : 'Running'}</strong> · New joins <strong>{control.joinLocked ? 'locked' : 'open'}</strong>.</p>
<p>Pause stops player decisions, automatic effects and AI play. The pending game stays saved. Discussion, seat recovery and taking back control remain available.</p>
<p>A joining lock blocks new players. Existing players can reconnect or recover their seats.</p>
<Button variant="outline" disabled={busy} onClick={() => void refresh()}>Refresh current settings</Button>
@@ -146,7 +146,7 @@ export function AdminRoomControls({ accountId, code, canManage, onClose, onUpdat
<p>Reason: {pending.reason}</p>
<p>The exact request is kept in this tab across refresh. Retrying confirms an already completed operation without applying it again.</p>
<Button disabled={busy} onClick={() => void submit(pending)}>Retry saved request</Button>
</div> : <form onSubmit={event => { event.preventDefault(); void submit(); }}>
</div> : control.closed ? <p>Reopen this room before changing pause or joining settings. Existing settings are preserved while closed.</p> : <form onSubmit={event => { event.preventDefault(); void submit(); }}>
<label className="admin-check"><input type="checkbox" checked={paused} disabled={busy} onChange={e => { setPaused(e.target.checked); setConfirmed(false); }} />Pause this room</label>
<label className="admin-check"><input type="checkbox" checked={joinLocked} disabled={busy} onChange={e => { setJoinLocked(e.target.checked); setConfirmed(false); }} />Lock new joins</label>
<label htmlFor="admin-room-reason">Reason for the audit history<Input id="admin-room-reason" value={reason} maxLength={300} required disabled={busy} onChange={e => setReason(e.target.value)} /></label>
+1 -1
View File
@@ -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.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.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>
+14 -13
View File
@@ -286,7 +286,7 @@ export function GameTable({
: [],
);
const busy =
transportBusy || !!g.roomControl?.paused || !!me.autopilot || g.automaticContinuationPending;
transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot || g.automaticContinuationPending;
const traitorBattle = g.battle;
const traitorBeneficiary =
traitorBattle &&
@@ -1575,14 +1575,14 @@ export function GameTable({
<PrivateBattlePlan game={g} />
<NexusCards game={g} act={act} busy={busy} />
<Recruits game={g} act={act} busy={busy} />
<NexusTraitors game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
<DiscoveryPanel game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
<LeaderSkillsPanel skills={g.leaderSkills} leaders={g.allLeaders} players={g.players} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
<NexusTraitors game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
<DiscoveryPanel game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
<LeaderSkillsPanel skills={g.leaderSkills} leaders={g.allLeaders} players={g.players} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
<RihaniHistory game={g} />
<MentatHistory game={g} />
<NexusChoamTrade game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
<NexusTleilaxu game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
<NexusSuboids game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
<NexusChoamTrade game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
<NexusTleilaxu game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
<NexusSuboids game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
<NexusAdvisors game={g} act={act} busy={busy} />
<NexusSardaukar game={g} act={act} busy={busy} />
{!g.nexusCards?.waiting.length && <TupileIntelligence game={g} act={act} busy={busy} />}
@@ -1633,6 +1633,7 @@ export function GameTable({
Your faction
<select
value={me.faction}
disabled={busy}
onChange={(e) =>
act({ type: 'faction', faction: e.target.value })
}
@@ -3037,9 +3038,9 @@ export function GameTable({
))}
</>
) : g.decision.kind === 'sukRescue' ? (
<SukGraduatePanel key={g.decision.event} decision={g.decision} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
<SukGraduatePanel key={g.decision.event} decision={g.decision} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
) : g.decision.kind === 'rihani' ? (
<RihaniChoice key={g.decision.event} game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
<RihaniChoice key={g.decision.event} game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
) : g.decision.kind === 'battleLosses' ? (
<>
<p className="muted">
@@ -3144,13 +3145,13 @@ export function GameTable({
})}
</>
) : g.decision.kind === 'discoveryEntry' ? (
<DiscoveryEntryDecision game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
<DiscoveryEntryDecision game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
) : g.decision.kind === 'ecologicalStorm' ? (
<DiscoveryStormDecision game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
<DiscoveryStormDecision game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
) : g.decision.kind === 'discoveryDiscard' ? (
<DiscoveryDiscardDecision game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
<DiscoveryDiscardDecision game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
) : g.decision.kind === 'greatMakerVote' || g.decision.kind === 'greatMakerRide' ? (
<GreatMakerDecision game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
<GreatMakerDecision game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
) : g.decision.kind === 'wormProtection' ? (
<>
<p className="muted">
+5
View File
@@ -2,6 +2,11 @@ import type { RoomControl } from '@/lib/room-control';
/** Public operational status only; administrator reasons and identity stay private. */
export function RoomControlNotice({ control }: { control?: RoomControl }) {
if (control?.closed) return <section className="notice" aria-label="Room availability" aria-live="polite">
<h2 className="text-lg font-semibold">Room closed by an administrator</h2>
<p>This room is closed to further play. Your table and discussion history remain readable. The saved position and pending choices are preserved; closing does not award a winner.</p>
<p>New joins, game decisions, AI, messages and seat changes are stopped. Keep any unconfirmed request and recovery kit. Exact saved requests can still confirm an earlier completed action. An administrator can reopen the room.</p>
</section>;
if (!control?.paused && !control?.joinLocked) return null;
return <section className="notice" aria-label="Room availability" aria-live="polite">
{control.paused ? <>
+5 -4
View File
@@ -73,7 +73,8 @@ export function SeatAiDelegation({
const received = grants.filter((grant) => grant.delegateId === game.me);
const started = ['setup', 'playing'].includes(game.status);
const locked = disabled || busy || !loaded;
const paused = !!game.roomControl?.paused;
const closed = !!game.roomControl?.closed;
const paused = !!game.roomControl?.paused || closed;
if (!own || own.bot) return null;
function store(next: SeatAiDelegateRequest | null) {
@@ -82,7 +83,7 @@ export function SeatAiDelegation({
setStorageProblem(false);
}
async function submit(request: SeatAiDelegateRequest) {
if (locked || pending.current) return;
if (locked || pending.current || (closed && !record)) return;
pending.current = true;
setBusy(true);
setExpanded(true);
@@ -241,7 +242,7 @@ export function SeatAiDelegation({
<Button
className="min-h-11 whitespace-normal"
variant="outline"
disabled={locked}
disabled={locked || closed}
onClick={() =>
void submit({
type: 'revokeSeatAiDelegate',
@@ -255,7 +256,7 @@ export function SeatAiDelegation({
)}
</section>
)}
{paused && <p>The room is paused. New AI permissions and activation will be available after it resumes. Revoking permission and confirming saved requests remain available.</p>}
{closed ? <p>The room is closed. New AI permission changes are stopped; exact saved requests can still confirm earlier completed actions.</p> : paused && <p>The room is paused. New AI permissions and activation will be available after it resumes. Revoking permission and confirming saved requests remain available.</p>}
{started && !paused && !own.autopilot && peers.length > 0 && (
<div className="flex min-w-0 flex-col gap-3">
<label htmlFor={`${id}-player`}>
+2
View File
@@ -24,6 +24,8 @@ export function SeatAutopilot({ game, act, busy }: SeatAutopilotProps) {
if (!own || own.bot || !['setup', 'playing'].includes(game.status))
return null;
if (game.roomControl?.closed) return <p className="notice">The room is closed. AI play and changes to its control are stopped until an administrator reopens it.</p>;
if (own.autopilot)
return (
<section
+6 -4
View File
@@ -50,6 +50,7 @@ export function SeatHandoverSetup({
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const [replaceAcknowledged, setReplaceAcknowledged] = useState(false);
const closed = !!game.roomControl?.closed;
const own = game.players.find((player) => player.id === game.me);
useEffect(() => {
try {
@@ -96,7 +97,7 @@ export function SeatHandoverSetup({
setRecord(next);
}
async function submit(operation: 'create' | 'revoke', kit: SeatHandoverKit) {
if (disabled || pending.current) return;
if (disabled || pending.current || (closed && !record?.operation)) return;
pending.current = true;
setReplaceAcknowledged(false);
setBusy(true);
@@ -170,10 +171,11 @@ export function SeatHandoverSetup({
accepted. Recovering your seat invalidates its pending handover. Only
share the kit with the intended recipient.
</p>
{closed && <p>The room is closed. New handovers and cancellations are stopped; exact saved requests can still confirm a completed change.</p>}
{!record ? (
<Button
variant="outline"
disabled={disabled || busy || !loaded}
disabled={disabled || closed || busy || !loaded}
onClick={() => {
try {
void submit('create', createHandoverKit(game.code, game.me));
@@ -238,7 +240,7 @@ export function SeatHandoverSetup({
</Button>
<Button
variant="outline"
disabled={disabled || busy}
disabled={disabled || closed || busy}
onClick={() => void submit('revoke', record.kit)}
>
Cancel handover
@@ -263,7 +265,7 @@ export function SeatHandoverSetup({
<Button
variant="outline"
disabled={
disabled || busy || (!!record.operation && !replaceAcknowledged)
disabled || closed || busy || (!!record.operation && !replaceAcknowledged)
}
onClick={() => {
try {
+9 -7
View File
@@ -6,7 +6,7 @@ import type { GameView } from '@/game/engine';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { isRoomRemoved, requestJson, requestMayHaveCompleted } from '@/lib/client-request';
import { isRoomClosed, isRoomRemoved, requestJson, requestMayHaveCompleted } from '@/lib/client-request';
import { clearHandoverOwner } from '@/lib/seat-handover';
import {
createRecoveryAttempt,
@@ -41,6 +41,7 @@ export function SeatRecoverySetup({
const [busy, setBusy] = useState(false);
const [uncertain, setUncertain] = useState(false);
const [message, setMessage] = useState('');
const closed = !!game.roomControl?.closed;
const own = game.players.find((player) => player.id === game.me);
const kit =
draft?.roomCode === game.code && draft.playerId === game.me ? draft : null;
@@ -61,7 +62,7 @@ export function SeatRecoverySetup({
}
async function saveKey() {
if (!kit || !saved || confirmed || disabled || pending.current) return;
if (!kit || !saved || confirmed || disabled || closed || pending.current) return;
let confirmedView: GameView | undefined;
pending.current = true;
setBusy(true);
@@ -96,7 +97,7 @@ export function SeatRecoverySetup({
const mayHaveSaved = requestMayHaveCompleted(error);
setUncertain(mayHaveSaved);
setMessage(
isRoomRemoved(error)
isRoomClosed(error) ? `${(error as Error).message} Keep this kit and request; retry after reopening.` : isRoomRemoved(error)
? `${(error as Error).message} Keep this same recovery kit and this page open. Retry saving after the room is restored.`
: mayHaveSaved
? 'Saving was not confirmed and may have completed. Keep this same kit. Retry saving to confirm it; no retry has been sent automatically.'
@@ -126,10 +127,11 @@ export function SeatRecoverySetup({
invalidates the previous kit. The kit stays in memory here; save it
before refreshing or closing this page.
</p>
{closed && <p>The room is closed. Keep any existing kit; new recovery keys can be enabled after reopening.</p>}
{!kit ? (
<Button
variant="outline"
disabled={disabled || busy}
disabled={disabled || busy || closed}
onClick={() => {
try {
setDraft(createRecoveryKit(game.code, game.me));
@@ -173,14 +175,14 @@ export function SeatRecoverySetup({
id={`${id}-saved`}
type="checkbox"
checked={saved}
disabled={disabled || busy}
disabled={disabled || busy || closed}
onChange={(event) => setSaved(event.target.checked)}
/>
I have saved the complete private kit outside this page.
</label>
<div className="flex flex-wrap gap-2">
<Button
disabled={disabled || busy || !saved || confirmed}
disabled={disabled || busy || closed || !saved || confirmed}
onClick={() => void saveKey()}
>
{confirmed
@@ -309,7 +311,7 @@ export function SeatRecoveryClaim({
setUncertain(mayHaveRecovered);
onUncertain?.(mayHaveRecovered);
setMessage(
isRoomRemoved(error)
isRoomClosed(error) ? `${(error as Error).message} Keep this kit and request; retry after reopening.` : isRoomRemoved(error)
? `${(error as Error).message} Your exact recovery request is retained. Keep this page open and retry it after restoration.`
: mayHaveRecovered
? 'Recovery was not confirmed and may have completed. Retry this same request below. Keep this page open to preserve its exact retry details; no retry is automatic.'
+5 -2
View File
@@ -18,6 +18,7 @@ import {
/** Remount on room/seat changes. Messages belong to seats, never browser identities. */
export function TableTalk({ game }: { game: GameView }) {
const field = useId();
const closed = !!game.roomControl?.closed;
const [open, setOpen] = useState(false);
const [recipient, setRecipient] = useState('');
const [text, setText] = useState('');
@@ -151,7 +152,7 @@ export function TableTalk({ game }: { game: GameView }) {
}
}
async function submit() {
if (sending.current || !ready) return;
if (sending.current || !ready || (closed && !record)) return;
const draft = record ?? {
id: randomId(),
recipientId: recipient || null,
@@ -314,6 +315,7 @@ export function TableTalk({ game }: { game: GameView }) {
{shownPage.messages.length === 0 && (
<p className="muted">No messages loaded in this conversation.</p>
)}
{closed && <p className="notice">This room is closed. Discussion history remains available; an exact saved message can still be confirmed.</p>}
<form
onSubmit={(event) => {
event.preventDefault();
@@ -330,7 +332,7 @@ export function TableTalk({ game }: { game: GameView }) {
rows={3}
maxLength={TALK_LIMIT}
value={text}
disabled={busy || !!record}
disabled={busy || !!record || closed}
onChange={(event) => setText(event.target.value)}
aria-describedby={`${field}-count`}
/>
@@ -341,6 +343,7 @@ export function TableTalk({ game }: { game: GameView }) {
type="submit"
disabled={
!ready ||
(closed && !record) ||
busy ||
!text.trim() ||
(!record && !!recipient && !target)
+225
View File
@@ -0,0 +1,225 @@
import { AdminError, type AdminIdentity } from './admin-access';
import {
validAdminClosureInput,
type AdminClosureInput,
type AdminClosureResult,
type AdminClosureView,
} from '../lib/admin-closure';
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_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 = {
removed: number;
code: string;
version: number;
closed: number;
revision: number;
closed_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): AdminClosureView => ({
code: row.code,
removed: row.removed === 1,
version: row.version,
closed: row.closed === 1,
revision: row.revision,
closedAt: row.closed_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 closed and unreadable saves. */
export async function readAdminClosure(
database: D1Database,
identity: AdminIdentity,
code: string,
now = Date.now(),
): Promise<AdminClosureView> {
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 closure transition commit together.
* Neither transition replaces state or credentials; reopen only refreshes AI pacing. */
export async function applyAdminClosure(
database: D1Database,
identity: AdminIdentity,
code: string,
input: AdminClosureInput,
now = Date.now(),
): Promise<AdminClosureResult> {
const args = credentials(identity, code, now);
if (!validAdminClosureInput(input))
throw new AdminError(
'Provide the room version, closure revision, requested state, a fresh operation identifier and a reason of 1–300 characters.',
400,
);
const { operationId, expectedVersion, expectedRevision, closed } = input;
const actorId = identity.id,
reason = input.reason.trim();
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(
JSON.stringify({ expectedVersion, expectedRevision, closed, 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_closures
(operation_id,actor_admin_id,room_code,request_hash,expected_version,expected_revision,
applied_version,applied_revision,closed,reason,before_metadata,after_metadata,created_at)
SELECT ?,?,r.code,?,?,?,r.version + 1,COALESCE(m.revision,0) + 1,?,?,
json_object('code',r.code,'version',r.version,'closed',json(CASE WHEN m.closed = 1 THEN 'true' ELSE 'false' END),
'revision',COALESCE(m.revision,0),'closedAt',m.closed_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('code',r.code,'version',r.version + 1,'closed',json(CASE WHEN ? = 1 THEN 'true' ELSE 'false' END),
'revision',COALESCE(m.revision,0) + 1,'closedAt',?,'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_closures 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.closed,0) != ?
AND EXISTS (${mutationAuthority})
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(
operationId,
actorId,
requestHash,
expectedVersion,
expectedRevision,
Number(closed),
reason,
Number(closed),
closed ? now : null,
now,
now,
code,
expectedVersion,
expectedRevision,
Number(closed),
...args,
operationId,
),
// Closing never parses or changes JSON. Reopen tolerates malformed saved state.
database
.prepare(`UPDATE rooms SET version = version + 1,
state = CASE WHEN ? = 0 AND NOT EXISTS (
SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1
) THEN CASE WHEN json_valid(state) THEN CASE WHEN
json_extract(state, '$.status') IN ('setup','playing') AND
EXISTS (SELECT 1 FROM json_each(CASE WHEN json_type(state, '$.players') = 'array' THEN json_extract(state, '$.players') ELSE '[]' END) p
WHERE json_extract(CASE WHEN p.type = 'object' THEN p.value ELSE '{}' END, '$.bot') IS NOT NULL
OR json_extract(CASE WHEN p.type = 'object' THEN p.value ELSE '{}' END, '$.autopilot') IS NOT NULL)
THEN json_set(state, '$.botsPending', json('true'), '$.botNextActionAt', ?)
ELSE state END ELSE state END ELSE state END
WHERE code = ? AND changes() = 1`)
.bind(Number(closed), now + 1500, code),
database
.prepare(`INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at)
SELECT room_code,closed,applied_revision,CASE WHEN closed = 1 THEN created_at ELSE NULL END,created_at
FROM admin_room_closures WHERE operation_id = ? AND changes() = 1
ON CONFLICT(room_code) DO UPDATE SET closed = excluded.closed,revision = excluded.revision,
closed_at = excluded.closed_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_closures 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 closure 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 closure.', 409);
if (row.version !== expectedVersion || row.revision !== expectedRevision)
throw new AdminError(
'The room or closure settings changed. Refresh before choosing a new operation.',
409,
);
throw new AdminError(
closed
? 'This room is already closed.'
: 'This room is already open.',
409,
);
}
+7 -6
View File
@@ -9,7 +9,7 @@ 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_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; 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,
@@ -17,7 +17,7 @@ export function projectAdminRoom(row: DirectoryRow): AdminRoom {
status: 'unreadable', host: null, players: [], advanced: false, preview: false,
expansions: [], modules: [], turn: null, phase: 'Unavailable',
pending: { label: 'Saved room needs inspection', owners: [] },
control: { paused: row.control_paused === 1, joinLocked: row.control_join_locked === 1, revision: row.control_revision ?? 0, updatedAt: row.control_updated_at ?? null },
control: { ...(row.control_closed === 1 ? { closed: true as const } : {}), paused: row.control_paused === 1, joinLocked: row.control_join_locked === 1, revision: row.control_revision ?? 0, updatedAt: row.control_updated_at ?? null },
};
try {
const g = JSON.parse(row.state) as Game;
@@ -60,7 +60,7 @@ export async function readAdminDirectory(database: D1Database, identity: AdminId
const availability = search.get('availability') ?? 'all';
const removal = search.get('removal') ?? 'active';
if (q.length > 80 || !['all', ...statuses].includes(status) || !['all', 'basic', 'advanced'].includes(rules) ||
!['recent', 'oldest', 'code'].includes(sort) || !['all', 'running', 'paused', 'locked'].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) || !/^[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";
@@ -82,16 +82,17 @@ export async function readAdminDirectory(database: D1Database, identity: AdminId
if (status !== 'all') { where.push(`json_extract(${state}, '$.status') = ?`); args.push(status); }
if (rules !== 'all') { where.push(`json_extract(${state}, '$.advanced') = ?`); args.push(rules === 'advanced' ? 1 : 0); }
if (availability === 'paused') where.push('c.paused = 1');
if (availability === 'running') where.push('COALESCE(c.paused, 0) = 0');
if (availability === 'running') where.push('COALESCE(c.paused, 0) = 0 AND COALESCE(cl.closed, 0) = 0 AND COALESCE(m.removed, 0) = 0');
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); }
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';
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 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(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(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);
+4 -1
View File
@@ -11,14 +11,16 @@ const authority = `SELECT a.role FROM admin_sessions s JOIN admin_accounts a ON
AND a.role IN ('owner','operator','viewer')`;
const mutationAuthority = authority + " AND a.role IN ('owner','operator')";
const fields = `COALESCE(c.paused, 0) AS paused, COALESCE(c.join_locked, 0) AS join_locked,
COALESCE(c.revision, 0) AS revision, c.updated_at`;
COALESCE(c.revision, 0) AS revision, c.updated_at, EXISTS (SELECT 1 FROM room_closures WHERE room_code = r.code AND closed = 1) AS closed`;
type ControlRow = {
closed: number;
paused: number;
join_locked: number;
revision: number;
updated_at: number | null;
};
const project = (row: ControlRow): RoomControl => ({
...(row.closed === 1 ? { closed: true as const } : {}),
paused: row.paused === 1,
joinLocked: row.join_locked === 1,
revision: row.revision,
@@ -92,6 +94,7 @@ export async function applyAdminRoomControl(
SELECT ?,?,r.code,?,?,COALESCE(c.paused,0),COALESCE(c.join_locked,0),?,?,?
FROM rooms r LEFT JOIN room_controls c ON c.room_code = r.code
WHERE r.code = ? AND COALESCE(c.revision,0) = ? AND EXISTS (${mutationAuthority})
AND NOT 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_audit WHERE operation_id = ?)`)
.bind(
+5 -1
View File
@@ -17,7 +17,7 @@ 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.state,r.version,COALESCE(c.paused,0) AS paused,
const roomSelect = `SELECT r.code,r.state,r.version,EXISTS (SELECT 1 FROM room_closures WHERE room_code = r.code AND closed = 1) AS closed,COALESCE(c.paused,0) AS paused,
EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1) AS removed,
COALESCE(c.join_locked,0) AS join_locked,COALESCE(c.revision,0) AS control_revision,c.updated_at AS control_updated_at,
(SELECT json_group_array(DISTINCT s.player_id) FROM seats s WHERE s.room_code = r.code AND s.revoked = 0) AS active_humans
@@ -29,6 +29,7 @@ type Row = {
state: string;
version: number;
removed: number;
closed: number;
paused: number;
join_locked: number;
control_revision: number;
@@ -77,6 +78,7 @@ function decode(row: Row) {
}
const activeHumans = new Set<string>(JSON.parse(row.active_humans));
const control = {
...(row.closed === 1 ? { closed: true as const } : {}),
paused: row.paused === 1,
joinLocked: row.join_locked === 1,
revision: row.control_revision,
@@ -89,6 +91,7 @@ function decode(row: Row) {
activeHumans,
control,
);
if (row.closed === 1) { view.editable = false; view.blockedReason = 'Reopen this closed room before configuring its lobby.'; }
if (row.removed === 1) {
view.editable = false;
view.blockedReason =
@@ -259,6 +262,7 @@ export async function configureAdminLobby(
database
.prepare(`UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ?
AND json_extract(state, '$.status') = 'lobby' AND EXISTS (${mutationAuthority})
AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1)
AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)
AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1)
AND NOT EXISTS (SELECT 1 FROM admin_lobby_operations WHERE operation_id = ?)
+4 -2
View File
@@ -11,13 +11,14 @@ const authority = `SELECT a.role FROM admin_sessions s JOIN admin_accounts a ON
AND s.expires_at > ? AND s.generation = a.session_generation
AND a.role IN ('owner','operator','viewer')`;
const mutationAuthority = authority + " AND a.role IN ('owner','operator')";
const roomSelect = `SELECT r.code,r.version,COALESCE(m.removed,0) AS removed,
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,
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 = {
closed: number;
code: string;
version: number;
removed: number;
@@ -37,6 +38,7 @@ type Receipt = {
};
const project = (row: Row): AdminRemovalView => ({
code: row.code,
...(row.closed === 1 ? { closed: true as const } : {}),
version: row.version,
removed: row.removed === 1,
revision: row.revision,
@@ -160,7 +162,7 @@ export async function applyAdminRemoval(
// Removing never parses or changes JSON. Restore tolerates malformed saved state.
database
.prepare(`UPDATE rooms SET version = version + 1,
state = CASE WHEN ? = 0 AND NOT EXISTS (
state = CASE WHEN ? = 0 AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND NOT EXISTS (
SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1
) THEN CASE WHEN json_valid(state) THEN CASE WHEN
json_extract(state, '$.status') IN ('setup','playing') AND
+45 -19
View File
@@ -85,6 +85,13 @@ async function requireRoomNotRemoved(code: string) {
.first();
if (removed) throw new RoomRemovedError();
}
export class RoomClosedError extends RoomEntryError {
constructor() { super('An administrator closed this room. The saved table remains readable; new play and seat changes are available after it is reopened.', 'ROOM_CLOSED', 409); }
}
async function requireRoomNotClosed(code: string) {
const closed = await db().prepare('SELECT 1 AS closed FROM room_closures WHERE room_code = ? AND closed = 1').bind(code).first();
if (closed) throw new RoomClosedError();
}
type PreparedEntry = {
operationId: string;
operationHash: string;
@@ -261,11 +268,11 @@ export async function createRoom(
}
type RoomControlRow = { room_control: string };
// SQLite constructs an explicit public allowlist, consistent with delegation views.
const roomControlProjection = `json_object(
const roomControlProjection = `json_patch(json_object(
'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),
'revision', COALESCE(c.revision, 0), 'updatedAt', c.updated_at
) AS room_control`;
), CASE WHEN EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) THEN '{"closed":true}' ELSE '{}' END) AS room_control`;
function publicRoomControl(row: RoomControlRow): RoomControl {
return JSON.parse(row.room_control) as RoomControl;
}
@@ -273,7 +280,7 @@ function attachRoomControl(view: GameView, row: RoomControlRow) {
const control = publicRoomControl(row);
// Untouched rooms retain the ordinary game-view shape. Once an administrator
// changes controls, keep the revision visible even after both flags are cleared.
if (control.revision > 0) view.roomControl = control;
if (control.revision > 0 || control.closed) view.roomControl = control;
}
async function readRoomSnapshot(code: string) {
const row = await db()
@@ -305,6 +312,7 @@ export async function readRoom(code: string) {
return game;
}
function requireUnpaused(control: RoomControl) {
if (control.closed) throw new RoomClosedError();
if (control.paused)
throw new RuleError(
'An administrator paused this room. Game decisions and AI will continue after it resumes.',
@@ -364,6 +372,7 @@ export async function joinRoom(
}
await requireUnusedEntryToken(entry);
const { game: g, control } = await readRoomSnapshot(code);
if (control.closed) throw new RoomClosedError();
if (control.paused || control.joinLocked)
throw new RoomEntryError(
control.paused
@@ -380,7 +389,7 @@ export async function joinRoom(
const results = await db().batch([
db()
.prepare(
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND (paused = 1 OR join_locked = 1))',
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND (paused = 1 OR join_locked = 1))',
)
.bind(JSON.stringify(g), Date.now(), code, old),
db()
@@ -406,6 +415,7 @@ export async function joinRoom(
]);
if (results[0].meta.changes !== 1) {
await requireRoomNotRemoved(code);
await requireRoomNotClosed(code);
throw new RoomEntryError(
'The table changed. Retry the same room-entry request.',
'ENTRY_CONFLICT',
@@ -435,6 +445,7 @@ export async function act(
const { game: current, control } = await readRoomSnapshot(code);
const takingBackControl =
action?.type === 'setAutopilot' && action.difficulty === null;
if (control.closed) throw new RoomClosedError();
if (!takingBackControl) requireUnpaused(control);
if (current.version !== version)
throw new RuleError(
@@ -456,7 +467,7 @@ export async function act(
const now = clock.now();
const update = db()
.prepare(
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND (? = 1 OR NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0)',
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND (? = 1 OR NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0)',
)
.bind(
JSON.stringify(next),
@@ -481,6 +492,7 @@ export async function act(
: [await update.run()];
if (results[0].meta.changes !== 1) {
await requireRoomNotRemoved(code);
await requireRoomNotClosed(code);
throw new RuleError(
'Another action arrived first. Your view has refreshed; try again.',
);
@@ -600,7 +612,7 @@ export async function continueRoomAutomatic(
const snapshot = await readWorkerRoomSnapshot(code);
if (!snapshot) return;
const { game: current, control } = snapshot;
if (control.paused) return;
if (control.paused || control.closed) return;
if (
!needsAutomaticRoomRecovery(current) ||
(!current.pendingTreacheryDiscard &&
@@ -615,7 +627,7 @@ export async function continueRoomAutomatic(
next.version = current.version + 1;
const result = await db()
.prepare(
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)',
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)',
)
.bind(JSON.stringify(next), clock.now(), code, current.version)
.run();
@@ -637,7 +649,7 @@ export async function continueRoomBots(
if (!snapshot) return;
let current = snapshot.game;
if (
snapshot.control.paused ||
snapshot.control.paused || snapshot.control.closed ||
!current.botsPending ||
current.status === 'finished'
)
@@ -650,7 +662,7 @@ export async function continueRoomBots(
if (!snapshot) return;
current = snapshot.game;
if (
snapshot.control.paused ||
snapshot.control.paused || snapshot.control.closed ||
!current.botsPending ||
current.status === 'finished'
)
@@ -666,7 +678,7 @@ export async function continueRoomBots(
next.version = current.version + 1;
const result = await db()
.prepare(
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)',
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)',
)
.bind(JSON.stringify(next), clock.now(), code, current.version)
.run();
@@ -825,7 +837,7 @@ export async function setSeatAiDelegate(
db()
.prepare(
`UPDATE rooms SET version = version + 1, updated_at = ?
WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1)
WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1)
AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)
AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0)
AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0)
@@ -889,6 +901,7 @@ export async function setSeatAiDelegate(
(await delegationSessionsActive(won))
)
return { view: await readSeatView(code, auth), replayed: true };
await requireRoomNotClosed(code);
throw invalidDelegation(
'The table, owner or delegate changed. Reconnect before saving this consent.',
'STALE_VERSION',
@@ -932,11 +945,12 @@ export async function revokeSeatAiDelegate(
'STALE_VERSION',
);
const now = clock.now();
await requireRoomNotClosed(code);
const results = await db().batch([
db()
.prepare(
`UPDATE rooms SET version = version + 1, updated_at = ?
WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1)
WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1)
AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0)
AND EXISTS (
SELECT 1 FROM seat_ai_delegations AS consent
@@ -973,6 +987,7 @@ export async function revokeSeatAiDelegate(
]);
if (results[0].meta.changes !== 1 || results[1].meta.changes !== 1) {
await requireRoomNotRemoved(code);
await requireRoomNotClosed(code);
throw invalidDelegation(
'The table, owner, delegate or consent changed. Reconnect before revoking it.',
'STALE_VERSION',
@@ -1060,7 +1075,7 @@ export async function useSeatAiDelegate(
db()
.prepare(
`UPDATE rooms SET state = ?, version = version + 1, updated_at = ?
WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1)
WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1)
AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)
AND EXISTS (
SELECT 1 FROM seat_ai_delegations AS consent
@@ -1125,6 +1140,7 @@ export async function useSeatAiDelegate(
(await delegationSessionsActive(won))
)
return { view: await readSeatView(code, auth), replayed: true };
await requireRoomNotClosed(code);
throw invalidDelegation(
'The table, owner, delegate or consent changed. Retry the exact activation after reconnecting.',
'STALE_VERSION',
@@ -1148,10 +1164,11 @@ export async function setRecoveryKey(
'INVALID_CONTROL_REQUEST',
400,
);
await requireRoomNotClosed(code);
const results = await db().batch([
db()
.prepare(
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0)',
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0)',
)
.bind(Date.now(), code, version, code, auth.playerId, auth.tokenHash),
db()
@@ -1162,6 +1179,7 @@ export async function setRecoveryKey(
]);
if (results[0].meta.changes !== 1) {
await requireRoomNotRemoved(code);
await requireRoomNotClosed(code);
throw new SeatControlError(
'The table or seat changed. Reconnect before saving the recovery key.',
'STALE_VERSION',
@@ -1268,10 +1286,11 @@ export async function recoverSeat(
'INVALID_SESSION_TOKEN',
);
const current = await readRoom(code);
await requireRoomNotClosed(code);
const results = await db().batch([
db()
.prepare(
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND EXISTS (SELECT 1 FROM seat_recovery_keys WHERE room_code = ? AND player_id = ? AND recovery_hash = ?) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND revoked = 0) AND NOT EXISTS (SELECT 1 FROM seat_recovery_receipts WHERE room_code = ? AND player_id = ? AND operation_hash = ?) AND NOT EXISTS (SELECT 1 FROM seats WHERE token_hash = ?)',
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND EXISTS (SELECT 1 FROM seat_recovery_keys WHERE room_code = ? AND player_id = ? AND recovery_hash = ?) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND revoked = 0) AND NOT EXISTS (SELECT 1 FROM seat_recovery_receipts WHERE room_code = ? AND player_id = ? AND operation_hash = ?) AND NOT EXISTS (SELECT 1 FROM seats WHERE token_hash = ?)',
)
.bind(
Date.now(),
@@ -1310,6 +1329,7 @@ export async function recoverSeat(
]);
if (results[0].meta.changes !== 1) {
await requireRoomNotRemoved(code);
await requireRoomNotClosed(code);
// A concurrent exact request may have completed: retrying the same proof is safe.
throw new SeatControlError(
'The table or recovery operation changed. Retry the exact recovery request.',
@@ -1414,10 +1434,11 @@ export async function createSeatHandover(
if (replay) return replay;
const expiresAt = now + SEAT_HANDOVER_TTL_MS;
try {
const results = await db().batch([
await requireRoomNotClosed(code);
const results = await db().batch([
db()
.prepare(
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0) AND NOT EXISTS (SELECT 1 FROM seat_handover_offers WHERE offer_hash = ? AND (room_code != ? OR player_id != ?))',
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0) AND NOT EXISTS (SELECT 1 FROM seat_handover_offers WHERE offer_hash = ? AND (room_code != ? OR player_id != ?))',
)
.bind(
now,
@@ -1453,6 +1474,7 @@ export async function createSeatHandover(
now,
);
if (won) return won;
await requireRoomNotClosed(code);
throw new SeatControlError(
'The table, seat, or handover offer changed. Reconnect before creating the offer.',
'STALE_VERSION',
@@ -1501,10 +1523,11 @@ export async function revokeSeatHandover(
'A different handover offer is now current for this seat.',
'HANDOVER_CHANGED',
);
await requireRoomNotClosed(code);
const results = await db().batch([
db()
.prepare(
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0) AND EXISTS (SELECT 1 FROM seat_handover_offers WHERE room_code = ? AND player_id = ? AND offer_hash = ? AND claim_operation_hash IS NULL)',
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0) AND EXISTS (SELECT 1 FROM seat_handover_offers WHERE room_code = ? AND player_id = ? AND offer_hash = ? AND claim_operation_hash IS NULL)',
)
.bind(
clock.now(),
@@ -1525,6 +1548,7 @@ export async function revokeSeatHandover(
]);
if (results[0].meta.changes !== 1) {
await requireRoomNotRemoved(code);
await requireRoomNotClosed(code);
const current = await handoverOffer(code, auth.playerId);
if (!current)
return {
@@ -1713,10 +1737,11 @@ export async function claimSeatHandover(
);
const current = await readRoom(code);
const claimFence = await hash(crypto.randomUUID());
await requireRoomNotClosed(code);
const results = await db().batch([
db()
.prepare(
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND EXISTS (SELECT 1 FROM seat_handover_offers WHERE room_code = ? AND player_id = ? AND offer_hash = ? AND secret_hash = ? AND issuer_session_hash = ? AND expires_at > ? AND claim_operation_hash IS NULL) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0) AND NOT EXISTS (SELECT 1 FROM seats WHERE token_hash = ?) AND NOT EXISTS (SELECT 1 FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ? AND operation_hash = ?)',
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND EXISTS (SELECT 1 FROM seat_handover_offers WHERE room_code = ? AND player_id = ? AND offer_hash = ? AND secret_hash = ? AND issuer_session_hash = ? AND expires_at > ? AND claim_operation_hash IS NULL) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0) AND NOT EXISTS (SELECT 1 FROM seats WHERE token_hash = ?) AND NOT EXISTS (SELECT 1 FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ? AND operation_hash = ?)',
)
.bind(
now,
@@ -1786,6 +1811,7 @@ export async function claimSeatHandover(
]);
if (results[0].meta.changes !== 1) {
await requireRoomNotRemoved(code);
await requireRoomNotClosed(code);
const won = await replayClaimedHandover(
code,
playerId,
+45
View File
@@ -91,6 +91,51 @@ export const adminRoomRemovals = sqliteTable(
],
);
// Closure freezes mutations while retaining private read access, independent of saved game state and pause settings.
export const roomClosures = sqliteTable(
'room_closures',
{
roomCode: text('room_code')
.primaryKey()
.references(() => rooms.code, { onDelete: 'cascade' }),
closed: integer('closed').notNull().default(0),
revision: integer('revision').notNull().default(0),
closedAt: integer('closed_at'),
updatedAt: integer('updated_at').notNull(),
},
(table) => [
check('room_closures_closed', sql`${table.closed} IN (0, 1)`),
check('room_closures_revision', sql`${table.revision} >= 0`),
check(
'room_closures_time',
sql`(${table.closed} = 1 AND ${table.closedAt} IS NOT NULL) OR (${table.closed} = 0 AND ${table.closedAt} IS NULL)`,
),
],
);
// Exact operation receipts also retain safe audit evidence independently of rooms/accounts.
export const adminRoomClosures = sqliteTable(
'admin_room_closures',
{
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(),
closed: integer('closed').notNull(),
reason: text('reason').notNull(),
beforeMetadata: text('before_metadata').notNull(),
afterMetadata: text('after_metadata').notNull(),
createdAt: integer('created_at').notNull(),
},
(table) => [
index('admin_room_closures_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(
+3
View File
@@ -116,6 +116,7 @@ export async function sendTableTalk(
SELECT r.code, ?, s.player_id, s.token_hash, json_extract(p.value, '$.name'), json_extract(p.value, '$.faction'), ?,
(SELECT json_extract(target.value, '$.name') FROM json_each(r.state, '$.players') target WHERE json_extract(target.value, '$.id') = ?), ?, ?
${viewer}
AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = r.code AND closed = 1)
AND (? IS NULL OR EXISTS (SELECT 1 FROM json_each(r.state, '$.players') target
WHERE json_extract(target.value, '$.id') = ? AND json_extract(target.value, '$.bot') IS NULL))
AND NOT EXISTS (SELECT 1 FROM room_messages recent WHERE recent.room_code = r.code
@@ -155,6 +156,8 @@ export async function sendTableTalk(
.first<{ accepted: number }>();
if (!receipt) {
await requireRoomNotRemoved(code);
if (await database().prepare('SELECT 1 FROM room_closures WHERE room_code = ? AND closed = 1').bind(code).first())
throw new TableTalkError('An administrator closed this room. Discussion history remains readable; new messages are available after reopening.', 409, 'ROOM_CLOSED');
throw new TableTalkError(
'Message not confirmed. Check your seat and recipient, wait a second, then try again.',
);
+4 -1
View File
@@ -12,7 +12,7 @@ recovery and persistence systems where suitable.
| Administrator access | Personal access keys, server-enforced owner/operator/viewer roles, persistent eight-hour sessions, individual/all-session sign-out and operator provisioning/revocation. A room host is not a site administrator. Account-management UI and further operational permissions remain. | Prototyped |
| Room directory | Search room codes/public player names, filter status/rules/availability, sort and paginate; show host, roster, modules, game change time, pause/join flags and bounded decision ownership. Detailed setup/shared-window ownership remains. | Prototyped |
| Create and configure | [Create a lobby](ADMIN_ROOM_CREATION.md) with an explicitly owned new host seat, Basic/Advanced preview and initial AI configuration; invite humans and use ordinary lobby controls/voluntary host-seat handover. [Neutral lobby configuration](ADMIN_LOBBY_CONFIGURATION.md) adds rules/modules, AI seats and host assignment to an existing human without private access. Reserved human seats remain. | Prototyped, partial |
| Lifecycle and removal | [Pause/resume and joining locks](ADMIN_ROOM_CONTROLS.md), plus [recoverable removal/restoration](ADMIN_ROOM_REMOVAL.md), have controls, durable audit, exact retries and player/AI enforcement. Close/end, 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, 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 |
@@ -43,6 +43,9 @@ and the host without granting private seat access.
[Remove or restore](ADMIN_ROOM_REMOVAL.md) blocks ordinary room access while
preserving the game, seats and access revocations. Use the Directory filter to
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.
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.
+78
View File
@@ -0,0 +1,78 @@
# Administrative close and reopen
Owners and operators can choose **Close or reopen** beside a room in
Administration. Closing requires a reason, the exact room code and confirmation
that active players have been warned. Reopening requires a reason and review of
the retained pause setting. Viewer accounts can inspect availability only.
Notices stay visible; the operation does not depend on a short timer.
Closing ends further play administratively. It preserves the exact game state,
pending decisions, official outcome and all seats. It does not invent a winner
or replace the engine's game status. Existing authenticated players can still
read their own table and discussion history. New joins, game decisions, AI,
automatic continuation, messages and seat security changes stop. The closed
notice is separate from the saved pause and joining settings, including for
rooms that have never been paused.
Reopening retains those pause/join settings and existing access revocations.
An unpaused started game with AI resumes with a fresh 1.5-second pacing deadline;
a paused room remains paused. No credential is restored, rotated or extended.
Existing physical pieces, cards and sealed commitments remain in the saved game.
## Retries and concurrency
The tab saves the exact account/room operation before sending it. An uncertain
response offers **Retry saved request**, including after refresh and reopening
the controls. Its receipt confirms the original operation and reports current
availability; replaying an old close after a later reopen cannot close it again.
An unreadable local record requires deliberate local discard after inspecting
the room. Discarding a retry cannot undo a server change.
The server checks current owner/operator authority and both the room version and
closure revision. The audit receipt, version fence and closure metadata commit
atomically. Concurrent identical requests create one record. A stale or competing
new request cannot change the room. Failed actions preserve their game, credential
and receipt records. Privacy-safe audit metadata contains operational flags and
timestamps, with no private hands, messages or keys.
Already-completed entry, message, recovery, handover and AI-permission requests
may still confirm their exact receipts while closed, provided their credentials
remain valid. They cannot repeat a mutation, extend permission expiry or revive
revoked access. Keep unconfirmed requests and private kits. A new key/transfer
cannot be enabled until reopening. Local drafts and readable inspectors remain
available while the table's mutation controls are disabled.
## Other administrator controls
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.
[Recoverable removal](ADMIN_ROOM_REMOVAL.md) can also hide a closed room.
Restoring it retains its closure and does not wake its AI. A removed room must be
restored before a new close/reopen transition. Old closure receipts remain
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,
participant support and backup/restore tools remain unfinished.
## Verification
Focused database/runtime tests exercise live roles and revocation, rollback,
concurrent/stale writes, all player mutation commit fences, AI before/after sleep,
automatic normalization, private views, exact receipts, database reopen and
removed-plus-closed restoration. Route tests check that closed reads and exact
AI-permission receipts do not schedule workers. Recovery rendering is checked
without browser globals.
The reusable administrator HTTP verifier creates its own QA room, checks closure
access and disabled operations, and reopens it with its complete saved table,
discussion and current seats intact. Browser checks cover lost successful
responses, refresh/retry, persistent notices, retained local drafts and mobile
controls. Source-bound checkpoint checks and saved-record comparisons are
recorded outside the checkout. Deployed acceptance must match the pushed revision;
local checks alone do not establish production behavior. See
[verification workflow](VERIFICATION_WORKFLOW.md).
+15 -6
View File
@@ -6,12 +6,21 @@ Expansion starts and full rules/publication acceptance remain gated.**
## Current checkpoint and work
Current work adds [recoverable administrator removal/restoration](ADMIN_ROOM_REMOVAL.md):
live authority and atomic version/revision fences block ordinary room access,
player/AI work, seat operations and discussion while preserving saved records.
Persistent notices and exact saved retries retain proof through temporary removal.
Restoration retains prior pause/join settings and credential revocations.
Close/end, archive, permanent deletion, bulk actions and full support remain open.
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.
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
administrator HTTP groups and lost-response desktop/mobile browser QA. It is
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.
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
+18
View File
@@ -1,5 +1,23 @@
# Dune implementation status
## 22 September 2026 — Administrative close/reopen prototype
[Close or reopen](ADMIN_ROOM_CLOSURE.md) now separates administrative availability
from the saved game's rules status and winner. It retains readable private tables
and discussion, blocks new player/AI/seat/message mutations, and preserves exact
prior receipts. Atomic audit/version/revision fences and independent closure
metadata compose with pause, lobby configuration and recoverable removal.
Reopening preserves credentials and pending choices, with existing AI pacing.
Independent review corrected recovery-claim scope, unnecessary closed-room worker
scheduling and typed closure errors after write races. Focused runtime checks,
dedicated HTTP acceptance and lost-response/mobile browser QA accompany the
checkpoint. Source-bound broad checks and preservation evidence remain private;
production acceptance must match the deployed checkpoint. Archive, permanent
removal, bulk operations and the rest of the full administration panel remain
unfinished; this does not certify complete rules or open publication gates.
## 22 September 2026 — Recoverable room removal and restoration
Added [administrator removal/restoration](ADMIN_ROOM_REMOVAL.md) with a typed-room
+28
View File
@@ -0,0 +1,28 @@
CREATE TABLE `admin_room_closures` (
`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,
`closed` 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_closures_room` ON `admin_room_closures` (`room_code`,`created_at`);--> statement-breakpoint
CREATE TABLE `room_closures` (
`room_code` text PRIMARY KEY NOT NULL,
`closed` integer DEFAULT 0 NOT NULL,
`revision` integer DEFAULT 0 NOT NULL,
`closed_at` integer,
`updated_at` integer NOT NULL,
FOREIGN KEY (`room_code`) REFERENCES `rooms`(`code`) ON UPDATE no action ON DELETE cascade,
CONSTRAINT "room_closures_closed" CHECK("room_closures"."closed" IN (0, 1)),
CONSTRAINT "room_closures_revision" CHECK("room_closures"."revision" >= 0),
CONSTRAINT "room_closures_time" CHECK(("room_closures"."closed" = 1 AND "room_closures"."closed_at" IS NOT NULL) OR ("room_closures"."closed" = 0 AND "room_closures"."closed_at" IS NULL))
);
File diff suppressed because it is too large. Load diff
+7
View File
@@ -92,6 +92,13 @@
"when": 1790040786205,
"tag": "0012_admin_room_removal",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1790042692212,
"tag": "0013_admin_room_closure",
"breakpoints": true
}
]
}
+64
View File
@@ -0,0 +1,64 @@
import { randomId } from './random-id';
import { validAdminClosureInput, type AdminClosureInput, type AdminClosureView, type AdminClosureResult } from './admin-closure';
type Storage = Pick<globalThis.Storage, 'getItem' | 'setItem' | 'removeItem'>;
const key = (account: string, code: string) => `dune.admin-closure-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 validAdminClosureView(value: unknown): value is AdminClosureView {
return object(value) && keys(value, ['removed', 'code', 'version', 'closed', 'revision', 'closedAt', 'updatedAt', 'paused', 'joinLocked']) &&
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);
}
export function adminClosureResponse(value: unknown, code: string): AdminClosureView {
if (!validAdminClosureView(value) || value.code !== code) throw new Error('The server returned unreadable room availability.');
return value;
}
export function adminClosureConfirmation(value: unknown, code: string, input: AdminClosureInput): AdminClosureResult {
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 closure or reopening request.');
const room = adminClosureResponse(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 AdminClosureResult;
}
export function newAdminClosureRequest(view: AdminClosureView, closed: boolean, reason: string): AdminClosureInput {
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 (closed === view.closed) throw new Error('Refresh the room before choosing a closure or reopening.');
return input;
}
export function readAdminClosureRequest(storage: Storage, account: string, code: string): AdminClosureInput | 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 (!validAdminClosureInput(value)) throw new Error('This tab’s saved closure or reopening 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 saveAdminClosureRequest(storage: Storage, account: string, code: string, input: AdminClosureInput) {
if (!validAdminClosureInput(input)) throw new Error('Invalid closure or reopening request.');
const previous = readAdminClosureRequest(storage, account, code), encoded = JSON.stringify(input);
if (previous && JSON.stringify(previous) !== encoded) throw new Error('Resolve the saved closure or reopening request before sending a different change.');
storage.setItem(key(account, code), encoded);
if (storage.getItem(key(account, code)) !== encoded) throw new Error('The closure or reopening request could not be preserved in this tab. Nothing was sent.');
}
export function clearAdminClosureRequest(storage: Storage, account: string, code: string) {
storage.removeItem(key(account, code));
if (storage.getItem(key(account, code)) !== null) throw new Error('The saved closure or reopening request could not be cleared.');
}
+63
View File
@@ -0,0 +1,63 @@
export type AdminClosureInput = {
operationId: string;
expectedVersion: number;
expectedRevision: number;
closed: boolean;
reason: string;
};
export type AdminClosureView = {
removed: boolean;
code: string;
version: number;
closed: boolean;
revision: number;
closedAt: number | null;
updatedAt: number | null;
paused: boolean;
joinLocked: boolean;
};
export type AdminClosureResult = {
operationId: string;
replayed: boolean;
appliedRevision: number;
appliedVersion: number;
room: AdminClosureView;
};
export function validAdminClosureInput(
value: unknown,
): value is AdminClosureInput {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const input = value as Record<string, unknown>;
const fields = [
'operationId',
'expectedVersion',
'expectedRevision',
'closed',
'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.closed === '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)
);
}
+1 -1
View File
@@ -17,7 +17,7 @@ export function validAdminLobbyView(value: unknown): value is AdminLobbyView {
typeof value.advanced !== 'boolean' || typeof value.techTokens !== 'boolean' || typeof value.strongholdCards !== 'boolean' ||
!Array.isArray(value.players) || value.players.length > 6 || !object(value.control)) return false;
const control = value.control;
return keys(control, ['paused', 'joinLocked', 'revision', 'updatedAt']) && typeof control.paused === 'boolean' && typeof control.joinLocked === 'boolean' &&
return keys(control, ['paused', 'joinLocked', 'revision', 'updatedAt', ...(control.closed === true ? ['closed'] : [])]) && typeof control.paused === 'boolean' && typeof control.joinLocked === 'boolean' &&
integer(control.revision) && (control.updatedAt === null || integer(control.updatedAt)) && value.players.every(player =>
object(player) && keys(player, ['id', 'name', 'faction', 'bot', 'position', 'ready', 'hostEligible']) &&
typeof player.id === 'string' && typeof player.name === 'string' && FACTIONS.some(faction => faction.id === player.faction) &&
+1 -1
View File
@@ -9,7 +9,7 @@ const integer = (value: unknown): value is number => Number.isSafeInteger(value)
const timestamp = (value: unknown) => value === null || integer(value) && value <= 8_640_000_000_000_000;
export function validAdminRemovalView(value: unknown): value is AdminRemovalView {
return object(value) && keys(value, ['code', 'version', 'removed', 'revision', 'removedAt', 'updatedAt', 'paused', 'joinLocked']) &&
return object(value) && keys(value, ['code', 'version', 'removed', 'revision', 'removedAt', 'updatedAt', 'paused', 'joinLocked', ...(value.closed === true ? ['closed'] : [])]) &&
typeof value.code === 'string' && /^[A-Z2-9]{8}$/.test(value.code) && integer(value.version) && integer(value.revision) &&
typeof value.removed === 'boolean' && typeof value.paused === 'boolean' && typeof value.joinLocked === 'boolean' &&
timestamp(value.removedAt) && timestamp(value.updatedAt);
+1
View File
@@ -7,6 +7,7 @@ export type AdminRemovalInput = {
};
export type AdminRemovalView = {
closed?: true;
code: string;
version: number;
removed: boolean;
+4 -1
View File
@@ -17,6 +17,9 @@ export class ClientRequestError extends Error {
}
}
export const isRoomClosed = (error: unknown): error is ClientRequestError =>
error instanceof ClientRequestError && error.code === 'ROOM_CLOSED';
export const isRoomRemoved = (error: unknown): error is ClientRequestError =>
error instanceof ClientRequestError && error.code === 'ROOM_REMOVED';
@@ -33,7 +36,7 @@ export function subscribeRoomRemoval(listener: (room: string) => void) {
export function requestMayHaveCompleted(error: unknown): boolean {
// Creation/recovery can commit before their final private read loses to removal.
// A temporary removal is never proof that a preceding mutation did not commit.
if (isRoomRemoved(error)) return true;
if (isRoomRemoved(error) || isRoomClosed(error)) return true;
if (!(error instanceof ClientRequestError) || error.kind !== 'http')
return true;
return (
+1
View File
@@ -1,5 +1,6 @@
/** Public room operations only. Reasons and administrator identities stay private. */
export type RoomControl = {
closed?: true;
paused: boolean;
joinLocked: boolean;
revision: number;
+9 -1
View File
@@ -13,7 +13,7 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden
assert.ok(cookie);
const room = (await created.json() as { code: string }).code;
for (const headers of [{}, { cookie }, { cookie: 'dune_admin_session=' + 'a'.repeat(64) }, { 'oai-authenticated-user-id': 'owner', 'oai-authenticated-user-email': 'owner@example.test' }] as Record<string, string>[]) {
for (const path of ['/api/admin/session', '/api/admin/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`, `/api/admin/rooms/${room}/removal`]) {
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`]) {
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');
@@ -42,6 +42,14 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden
assert.equal(removal.status, 401);
assert.equal(removal.headers.get('set-cookie'), null);
assert.deepEqual(Object.keys(await removal.json()), ['error']);
const closure = await fetch(`${base}/api/admin/rooms/${room}/closure`, {
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' },
body: JSON.stringify({ operationId: crypto.randomUUID(), expectedVersion: 0, expectedRevision: 0, closed: true, reason: 'Denied closure QA' }),
signal: AbortSignal.timeout(15000),
});
assert.equal(closure.status, 401);
assert.equal(closure.headers.get('set-cookie'), null);
assert.deepEqual(Object.keys(await closure.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' }),
+84
View File
@@ -0,0 +1,84 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { adminClosureConfirmation, adminClosureResponse, clearAdminClosureRequest, newAdminClosureRequest, readAdminClosureRequest, saveAdminClosureRequest } from '../lib/admin-closure-client';
import { retainAdminRoomRequest } from '../lib/admin-room-control-client';
import { ClientRequestError } from '../lib/client-request';
import type { AdminClosureView } from '../lib/admin-closure';
const view = (): AdminClosureView => ({ code: 'ABCD2345', version: 7, closed: false, removed: false, revision: 2, closedAt: 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('closure retry preserves exact intent/version/revision/reason across refresh and isolates account and room', () => {
const tab = storage(), room = view(), input = newAdminClosureRequest(room, true, ' QA closure ');
saveAdminClosureRequest(tab, 'a', room.code, input);
assert.deepEqual(readAdminClosureRequest(tab, 'a', room.code), input);
assert.equal(input.expectedVersion, 7); assert.equal(input.expectedRevision, 2); assert.equal(input.reason, 'QA closure');
assert.equal(readAdminClosureRequest(tab, 'b', room.code), null);
assert.equal(readAdminClosureRequest(tab, 'a', 'DIFF2345'), null);
for (const change of [{ operationId: crypto.randomUUID() }, { expectedVersion: 8 }, { expectedRevision: 3 }, { reason: 'Different' }, { closed: false }])
assert.throws(() => saveAdminClosureRequest(tab, 'a', room.code, { ...input, ...change }), /Resolve the saved/);
saveAdminClosureRequest(tab, 'a', room.code, input);
clearAdminClosureRequest(tab, 'a', room.code);
assert.equal(readAdminClosureRequest(tab, 'a', room.code), null);
const reopen = newAdminClosureRequest({ ...room, closed: true, version: 8, revision: 3 }, false, 'Restore');
saveAdminClosureRequest(tab, 'a', room.code, reopen);
assert.deepEqual(readAdminClosureRequest(tab, 'a', room.code), reopen);
});
void test('corrupt or unwritable closure retry storage fails closed and never replaces the saved record', () => {
const tab = storage(), room = view(), input = newAdminClosureRequest(room, true, 'QA');
assert.throws(() => saveAdminClosureRequest({ ...tab, setItem() {} }, 'a', room.code, input), /Nothing was sent/);
saveAdminClosureRequest(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(() => readAdminClosureRequest(tab, 'a', room.code), /unreadable/);
assert.throws(() => saveAdminClosureRequest(tab, 'a', room.code, input), /unreadable/);
assert.equal(tab.values.get(key), raw);
}
assert.throws(() => clearAdminClosureRequest({ ...tab, removeItem() {} }, 'a', room.code), /could not be cleared/);
clearAdminClosureRequest(tab, 'a', room.code);
for (const reason of [' ', 'a'.repeat(301), 'two\nlines', 'bad\u007freason'])
assert.throws(() => newAdminClosureRequest(room, true, reason));
assert.throws(() => newAdminClosureRequest(room, false, 'No change'));
assert.throws(() => newAdminClosureRequest({ ...room, removed: true }, true, 'Removed'), /Restore/);
assert.throws(() => newAdminClosureRequest({ ...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 = newAdminClosureRequest(room, true, 'QA');
const current = { ...room, version: 12, revision: 4, closed: false, removed: false, closedAt: 100, updatedAt: 200, paused: true };
const receipt = { operationId: input.operationId, appliedVersion: 8, appliedRevision: 3, replayed: true, room: current };
assert.deepEqual(adminClosureConfirmation(receipt, room.code, input), receipt);
const fresh = { ...receipt, replayed: false, room: { ...room, version: 8, revision: 3, closed: true, closedAt: 100, updatedAt: 100 } };
assert.deepEqual(adminClosureConfirmation(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(() => adminClosureConfirmation(invalid, room.code, input));
});
void test('room availability projection excludes private data and rejects malformed metadata', () => {
const room = view();
assert.deepEqual(adminClosureResponse(room, room.code), room);
assert.doesNotThrow(() => adminClosureResponse({ ...room, closed: true, closedAt: 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, closed: 'true' },
{ ...room, closedAt: -1 }, { ...room, closedAt: Number.MAX_SAFE_INTEGER }, { ...room, updatedAt: '100' },
{ ...room, paused: undefined }, { ...room, joinLocked: null }])
assert.throws(() => adminClosureResponse(invalid, room.code));
});
void test('uncertain closure and changed-access responses keep the exact saved request', () => {
const tab = storage(), room = view(), input = newAdminClosureRequest(room, true, 'QA');
saveAdminClosureRequest(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)) clearAdminClosureRequest(tab, 'a', room.code);
assert.deepEqual(readAdminClosureRequest(tab, 'a', room.code), input);
}
for (const status of [400, 404, 409, 422]) assert.equal(retainAdminRoomRequest(new ClientRequestError('Rejected', 'http', status)), false);
});
+851
View File
@@ -0,0 +1,851 @@
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 { applyAdminClosure, readAdminClosure } from '../db/admin-closure';
import {
validAdminClosureInput,
type AdminClosureInput,
} from '../lib/admin-closure';
import {
applyAdminRoomControl,
readAdminRoomControl,
} from '../db/admin-lifecycle';
import {
configureAdminLobby,
readAdminLobby,
} from '../db/admin-lobby-configuration';
import { applyAdminRemoval, readAdminRemoval } from '../db/admin-removal';
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 = [
'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,
closed = true,
): AdminClosureInput => ({
operationId: randomUUID(),
expectedVersion,
expectedRevision,
closed,
reason: 'Dedicated QA room closure',
});
function seed(store: Store, code = 'CLOSURES') {
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);
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 = () => readAdminClosure(store.database, identity, room.code, now);
const apply = (input = change(version()), at = now) =>
applyAdminClosure(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('closure 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 < '0013')
.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/0013_admin_room_closure.sql', import.meta.url),
'utf8',
),
);
assert.deepEqual(
tables.map((table) => rows(store, table)),
before,
);
assert.deepEqual(rows(store, 'room_closures'), []);
assert.deepEqual(rows(store, 'admin_room_closures'), []);
assert.throws(
() =>
store.sqlite
.prepare(
'INSERT INTO room_closures(room_code,closed,revision,updated_at) VALUES(?,1,0,?)',
)
.run(room.code, now),
/CHECK/,
);
} finally {
store.sqlite.close();
}
});
void test('closure inputs require exact bounded fields and both safe version counters', () => {
const valid = change();
assert.equal(validAdminClosureInput(valid), true);
for (const invalid of [
null,
[],
{},
{ ...valid, extra: true },
{ ...valid, closed: 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({ closed: true }), {
operationId: valid.operationId,
expectedVersion: 0,
expectedRevision: 0,
reason: 'Reason',
other: true,
}),
])
assert.equal(validAdminClosureInput(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,
closed: false,
removed: false,
revision: 0,
closedAt: null,
updatedAt: null,
paused: false,
joinLocked: false,
});
assert.deepEqual(f.row(), before);
assert.deepEqual(rows(f, 'room_closures'), []);
if (role === 'viewer') await assert.rejects(f.apply(), status(403));
else assert.equal((await f.apply()).room.closed, true);
await assert.rejects(
readAdminClosure(f.database, f.identity, f.code + '\n', now),
status(400),
);
await assert.rejects(
readAdminClosure(f.database, f.identity, 'ABSENTAA', now),
status(404),
);
await assert.rejects(
readAdminClosure(
f.database,
{ ...f.identity, sessionHash: f.tokenHash },
f.code,
now,
),
status(401),
);
} finally {
f.sqlite.close();
}
}
});
void test('close and reopen 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,
closed: true,
removed: false,
revision: 1,
closedAt: now,
updatedAt: now,
paused: false,
joinLocked: false,
});
assert.equal(f.row().state, old.state);
const reopend = await f.apply(change(1, 1, false), now + 5000);
assert.deepEqual(reopend.room, {
...result.room,
version: 2,
closed: false,
revision: 2,
closedAt: null,
updatedAt: now + 5000,
});
assert.equal(f.row().state, old.state);
assert.deepEqual(secureRows(f), secure);
assert.deepEqual(
f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(other.code),
untouched,
);
assert.equal(rows(f, 'room_closures').length, 1);
const audit = rows(f, 'admin_room_closures');
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('closure and reopening 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()).closed, true);
await f.apply(change(1, 1, false));
assert.equal(f.row().state, state);
} finally {
f.sqlite.close();
}
}
});
void test('reopening refreshes existing AI pacing only when unpaused and preserves pause and joining flags', 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);
const controls = {
operationId: randomUUID(),
expectedRevision: 0,
paused,
joinLocked: true,
reason: 'QA controls',
};
await applyAdminRoomControl(
f.database,
f.identity,
f.code,
controls,
now,
);
const state = String(f.row().state),
beforeControls = rows(f, 'room_controls');
await f.apply(change(f.version()));
assert.equal(f.row().state, state);
const reopend = await f.apply(
change(f.version(), 1, false),
now + 3000,
);
assert.equal(reopend.room.paused, paused);
assert.equal(reopend.room.joinLocked, true);
const expected = JSON.parse(state);
if (!paused && controller !== 'human') {
expected.botsPending = true;
expected.botNextActionAt = now + 4500;
} else assert.equal(f.row().state, state);
assert.deepEqual(JSON.parse(String(f.row().state)), expected);
assert.deepEqual(rows(f, 'room_controls'), beforeControls);
} 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, closed: 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(
applyAdminClosure(f.database, identity, f.code, input, now),
status(409),
);
seed(f, 'KEEPSAFE');
await assert.rejects(
applyAdminClosure(f.database, f.identity, 'KEEPSAFE', input, now),
status(409),
);
assert.deepEqual(f.row(), before);
assert.equal(rows(f, 'admin_room_closures').length, 2);
} finally {
f.sqlite.close();
}
});
void test('new no-op and stale requests reject without writes; both room version and closure 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_closures'), []);
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_closures').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_closures');
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_closures'), 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_closures').length, 1);
} finally {
f.sqlite.close();
}
}
});
void test('audit, game fence and closure metadata roll back together at every failed persistence stage', async () => {
for (const table of ['admin_room_closures', 'rooms', 'room_closures']) {
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 closure failure'); END;`,
);
await assert.rejects(f.apply(), /QA closure failure/);
assert.deepEqual(f.row(), before);
assert.deepEqual(secureRows(f), secure);
assert.deepEqual(rows(f, 'admin_room_closures'), []);
assert.deepEqual(rows(f, 'room_closures'), []);
} finally {
f.sqlite.close();
}
}
});
void test('closed games and exact receipts survive database reopen without restoring historical credentials', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dune-admin-closure-'));
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 closed = await applyAdminClosure(
store.database,
identity,
room.code,
input,
now,
);
store.sqlite.close();
store = adminStore(join(dir, 'rooms.sqlite'), false);
assert.deepEqual(
await applyAdminClosure(store.database, identity, room.code, input, now),
{ ...closed, replayed: true },
);
await applyAdminClosure(
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_closures');
store.sqlite.prepare('DELETE FROM rooms WHERE code=?').run(room.code);
assert.deepEqual(rows(store, 'admin_room_closures'), audit);
} finally {
store.sqlite.close();
rmSync(dir, { recursive: true, force: true });
}
});
void test('closed rooms keep public admin reads and old receipts while new lobby and pause mutations reject', async () => {
const f = await fixture();
try {
const lobbyInput = {
operationId: randomUUID(),
expectedVersion: 0,
action: { type: 'rules' as const, advanced: true },
reason: 'QA rules',
};
const lobby = await configureAdminLobby(
f.database,
f.identity,
f.code,
lobbyInput,
now,
);
const controls = {
operationId: randomUUID(),
expectedRevision: 0,
paused: false,
joinLocked: true,
reason: 'QA controls',
};
await applyAdminRoomControl(f.database, f.identity, f.code, controls, now);
await f.apply(change(f.version()));
const before = f.row();
assert.equal(
(await readAdminLobby(f.database, f.identity, f.code, now)).editable,
false,
);
assert.equal(
(await readAdminRoomControl(f.database, f.identity, f.code, now))
.joinLocked,
true,
);
const receipt = await configureAdminLobby(
f.database,
f.identity,
f.code,
lobbyInput,
now,
);
assert.equal(receipt.replayed, true);
assert.equal(receipt.appliedVersion, lobby.appliedVersion);
assert.equal(receipt.lobby.editable, false);
assert.equal(
(
await applyAdminRoomControl(
f.database,
f.identity,
f.code,
controls,
now,
)
).replayed,
true,
);
await assert.rejects(
configureAdminLobby(
f.database,
f.identity,
f.code,
{
...lobbyInput,
operationId: randomUUID(),
expectedVersion: f.version(),
},
now,
),
status(409),
);
await assert.rejects(
applyAdminRoomControl(
f.database,
f.identity,
f.code,
{
...controls,
operationId: randomUUID(),
expectedRevision: 1,
paused: true,
},
now,
),
status(409),
);
assert.deepEqual(f.row(), before);
} finally {
f.sqlite.close();
}
});
void test('prepared admin lobby and pause writes lose their transaction race to closure', async () => {
for (const operation of ['lobby', 'pause'] as const) {
const f = await fixture();
try {
const gate = gateBatch(f, operation === 'lobby' ? 2 : 1);
const pending = (
operation === 'lobby'
? configureAdminLobby(
f.database,
f.identity,
f.code,
{
operationId: randomUUID(),
expectedVersion: 0,
action: { type: 'rules', advanced: true },
reason: 'QA rules',
},
now,
)
: applyAdminRoomControl(
f.database,
f.identity,
f.code,
{
operationId: randomUUID(),
expectedRevision: 0,
paused: true,
joinLocked: true,
reason: 'QA pause',
},
now,
)
).then(
() => null,
(error: unknown) => error,
);
await gate.waiting;
await f.apply();
const before = f.row();
gate.release();
assert.ok(status(409)(await pending));
assert.deepEqual(f.row(), before);
assert.deepEqual(rows(f, 'admin_lobby_operations'), []);
assert.deepEqual(rows(f, 'admin_room_audit'), []);
} finally {
f.sqlite.close();
}
}
});
void test('removal retains closure and restoring a closed room preserves AI deadlines until a separate reopen', async () => {
const f = await fixture();
try {
const game = JSON.parse(String(f.row().state));
game.status = 'playing'; game.players[0].autopilot = 'Hard'; game.botNextActionAt = 222; game.botsPending = true;
f.sqlite.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), f.code);
const closing = change();
await f.apply(closing);
const state = f.row().state, secure = secureRows(f);
const removal = { operationId: randomUUID(), expectedVersion: f.version(), expectedRevision: 0, removed: true, reason: 'QA removal while closed' };
await applyAdminRemoval(f.database, f.identity, f.code, removal, now);
assert.equal((await readAdminRemoval(f.database, f.identity, f.code, now)).closed, true);
const current = await readAdminClosure(f.database, f.identity, f.code, now);
assert.equal(current.removed, true); assert.equal(current.closed, true);
await assert.rejects(f.apply(change(f.version(), 1, false)), status(409));
assert.equal((await f.apply(closing)).replayed, true);
assert.equal(f.row().state, state);
await applyAdminRemoval(f.database, f.identity, f.code, { ...removal, operationId: randomUUID(), expectedVersion: f.version(), expectedRevision: 1, removed: false }, now + 50000);
assert.equal(f.row().state, state);
assert.equal((await readAdminClosure(f.database, f.identity, f.code, now)).closed, true);
await applyAdminClosure(f.database, f.identity, f.code, change(f.version(), 1, false), now + 50000);
assert.equal(JSON.parse(String(f.row().state)).botNextActionAt, now + 51500);
assert.deepEqual(secureRows(f), secure);
} finally { f.sqlite.close(); }
});
+15
View File
@@ -99,3 +99,18 @@ void test('directory projects and filters operational flags without private audi
await assert.rejects(read('availability=unsupported'), (e: unknown) => e instanceof AdminError && e.status === 400);
} finally { f.sqlite.close(); }
});
void test('closed rooms remain in the directory but are excluded from running rooms and expose only the closure flag', async () => {
const f = await fixture();
try {
insert(f.sqlite, 'OPENROOM', state()); insert(f.sqlite, 'CLOSEDQA', state());
f.sqlite.prepare('INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES (?,1,1,100,100)').run('CLOSEDQA');
const read = (q: string) => readAdminDirectory(f.database, f.identity, new URLSearchParams(q));
assert.equal((await read('')).total, 2);
const closed = await read('availability=closed');
assert.equal(closed.total, 1); assert.equal(closed.rooms[0].code, 'CLOSEDQA');
assert.deepEqual(closed.rooms[0].control, { paused: false, joinLocked: false, revision: 0, updatedAt: null, closed: true });
assert.equal(JSON.stringify(closed).includes('SECRET'), false);
assert.deepEqual((await read('availability=running')).rooms.map(room => room.code), ['OPENROOM']);
} finally { f.sqlite.close(); }
});
+7
View File
@@ -276,3 +276,10 @@ void test('a failed gateway response keeps its status and requires reconciliatio
);
assert.equal(calls, 1);
});
void test('a typed closure rejection retains possibly completed request proof without treating it as removal', async () => {
const { isRoomClosed, isRoomRemoved, requestMayHaveCompleted } = await import('../lib/client-request');
const error = new ClientRequestError('Room closed', 'http', 409, 'ROOM_CLOSED');
assert.equal(isRoomClosed(error), true); assert.equal(isRoomRemoved(error), false);
assert.equal(requestMayHaveCompleted(error), true);
});
+598
View File
@@ -0,0 +1,598 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomBytes, randomUUID, webcrypto } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { DatabaseSync } from 'node:sqlite';
import { runInNewContext } from 'node:vm';
import ts from 'typescript';
import { adminStore } from './admin-access-fixture';
import { adminLogin, requireAdmin } from '../db/admin-access';
import { applyAdminClosure, readAdminClosure } from '../db/admin-closure';
import { baseDeck } from '../game/cards';
import * as engine from '../game/engine';
import * as bots from '../game/bots';
import * as delegation from '../lib/seat-ai-delegation';
import { requestOrigin } from '../lib/request-origin';
import * as talkContract from '../lib/table-talk';
import type * as Rooms from '../db/rooms';
import type * as Talk from '../db/table-talk';
const secret = () => randomBytes(32).toString('hex');
const entry = () => ({ operationId: randomUUID(), sessionToken: secret() });
const closed = (error: unknown) =>
!!error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ROOM_CLOSED' &&
'status' in error &&
error.status === 409;
const draft = (recipientId: string | null = null) => ({
id: randomUUID(),
recipientId,
text: 'Retained discussion for the same saved seat.',
});
type Values = (string | number | null)[];
async function fixture(started = false) {
const store = adminStore();
assert.ok(store.sqlite instanceof DatabaseSync);
const account = store.provision('operator');
const login = await adminLogin(store.database, account.key);
const identity = await requireAdmin(store.database, login.token);
const calls = { bots: 0, normalization: 0, views: 0 };
const hooks: {
beforeFirst?: (sql: string, values: Values) => void;
beforeRun?: (sql: string, values: Values) => void;
} = {};
const database = {
batch: (statements: D1PreparedStatement[]) =>
store.database.batch(statements),
prepare(sql: string) {
const statement = store.database.prepare(sql);
const first = statement.first.bind(statement);
const run = statement.run.bind(statement);
const bind = statement.bind.bind(statement);
let values: Values = [];
statement.bind = (...input: Values) => {
values = input;
return bind(...input);
};
statement.first = async <T>() => {
hooks.beforeFirst?.(sql, values);
return first<T>();
};
statement.run = async <T>() => {
hooks.beforeRun?.(sql, values);
return run<T>();
};
return statement;
},
} as D1Database;
function load<T>(file: string) {
const exports = {};
runInNewContext(
ts.transpileModule(readFileSync(new URL(file, import.meta.url), 'utf8'), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
}).outputText,
{
exports,
crypto: webcrypto,
TextEncoder,
structuredClone,
JSON,
require: (name: string) => {
if (name === 'cloudflare:workers') return { env: { DB: database } };
if (name === '@/game/engine')
return {
...engine,
viewGame: (...args: Parameters<typeof engine.viewGame>) => {
calls.views++;
return engine.viewGame(...args);
},
normalizeAutomaticGame: (
...args: Parameters<typeof engine.normalizeAutomaticGame>
) => {
calls.normalization++;
return engine.normalizeAutomaticGame(...args);
},
};
if (name === '@/game/bots')
return {
...bots,
runBots: (...args: Parameters<typeof bots.runBots>) => {
calls.bots++;
return bots.runBots(...args);
},
};
if (name === '@/lib/seat-ai-delegation') return delegation;
if (name === '../lib/table-talk') return talkContract;
throw new Error('Unexpected dependency ' + name);
},
},
);
return exports as T;
}
const rooms = load<typeof Rooms>('../db/rooms.ts');
const talk = load<typeof Talk>('../db/table-talk.ts');
const ownerEntry = entry(),
guestEntry = entry();
const owner = await rooms.createRoom(
'Closure owner',
'atreides',
false,
[],
ownerEntry,
);
const code = owner.view.code;
const guest = await rooms.joinRoom(
code,
'Closure guest',
'emperor',
guestEntry,
);
const auth = await rooms.authenticate(code, owner.token);
const guestAuth = await rooms.authenticate(code, guest.token!);
let now = 10000;
const clock = {
now: () => now,
advance: (ms: number) => {
now += ms;
},
sleep: async (ms: number) => {
now += ms;
},
};
const version = () =>
Number(
store.sqlite
.prepare('SELECT version FROM rooms WHERE code = ?')
.get(code)!.version,
);
if (started) {
for (const seat of [auth, guestAuth])
await rooms.act(code, seat, version(), { type: 'ready' }, clock);
await rooms.act(code, auth, version(), { type: 'start' }, clock);
}
const transition = async (closed: boolean, roomCode = code) => {
const view = await readAdminClosure(database, identity, roomCode, now);
return applyAdminClosure(
database,
identity,
roomCode,
{
operationId: randomUUID(),
expectedVersion: view.version,
expectedRevision: view.revision,
closed,
reason: 'Dedicated recoverable closure runtime QA',
},
now,
);
};
// Deliberately retain the old game version to prove the SQL closure predicate itself,
// independently of the additional version bump made by the real admin transaction.
const flagClosed = (roomCode = code) =>
store.sqlite
.prepare(
'INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES (?,1,1,10000,10000)',
)
.run(roomCode);
const snapshot = () =>
Object.fromEntries(
[
'rooms',
'seats',
'room_entry_receipts',
'seat_recovery_keys',
'seat_recovery_receipts',
'seat_handover_offers',
'seat_handover_claim_receipts',
'seat_ai_delegations',
'room_messages',
'room_controls',
'room_closures',
].map((table) => [
table,
store.sqlite
.prepare('SELECT * FROM ' + table + ' ORDER BY rowid')
.all(),
]),
);
return {
...store,
rooms,
talk,
hooks,
calls,
code,
owner,
ownerEntry,
guest,
guestEntry,
auth,
guestAuth,
clock,
version,
transition,
flagClosed,
snapshot,
restart: () => load<typeof Rooms>('../db/rooms.ts'),
};
}
type Fixture = Awaited<ReturnType<typeof fixture>>;
const grant = (f: Fixture) => ({
grantId: randomUUID(),
delegateId: f.guestAuth.playerId,
difficulty: 'Hard' as const,
});
const offer = () => ({ offerId: randomUUID(), handoverSecret: secret() });
const recovery = (f: Fixture, recoverySecret: string) => ({
playerId: f.auth.playerId,
recoverySecret,
operationId: randomUUID(),
newSessionToken: secret(),
});
for (const kind of [
'join',
'action',
'takeback',
'grant',
'revoke grant',
'activate grant',
'recovery key',
'recovery',
'create handover',
'revoke handover',
'claim handover',
'discussion send',
] as const) {
void test(`${kind} commit fence rejects closure without changing state, credentials or receipts`, async () => {
const f = await fixture(kind !== 'join' && kind !== 'action');
try {
const consent = grant(f),
handover = offer(),
recoverySecret = secret();
if (kind === 'revoke grant' || kind === 'activate grant')
await f.rooms.setSeatAiDelegate(
f.code,
f.auth,
f.version(),
consent,
f.clock,
);
if (kind === 'recovery')
await f.rooms.setRecoveryKey(
f.code,
f.auth,
f.version(),
recoverySecret,
);
if (kind === 'revoke handover' || kind === 'claim handover')
await f.rooms.createSeatHandover(
f.code,
f.auth,
f.version(),
handover,
f.clock,
);
if (kind === 'takeback')
await f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'setAutopilot', difficulty: 'Hard' },
f.clock,
);
let expected: ReturnType<typeof f.snapshot> | undefined;
f.hooks.beforeRun = (sql) => {
if (
!sql.startsWith(
kind === 'discussion send'
? 'INSERT INTO room_messages'
: 'UPDATE rooms',
)
)
return;
delete f.hooks.beforeRun;
f.flagClosed();
expected = f.snapshot();
};
const operations = {
join: () => f.rooms.joinRoom(f.code, 'Racing guest', 'guild', entry()),
action: () =>
f.rooms.act(f.code, f.auth, f.version(), { type: 'ready' }, f.clock),
takeback: () =>
f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'setAutopilot', difficulty: null },
f.clock,
),
grant: () =>
f.rooms.setSeatAiDelegate(
f.code,
f.auth,
f.version(),
consent,
f.clock,
),
'revoke grant': () =>
f.rooms.revokeSeatAiDelegate(
f.code,
f.auth,
f.version(),
consent.grantId,
f.clock,
),
'activate grant': () =>
f.rooms.useSeatAiDelegate(
f.code,
f.guestAuth,
f.version(),
{ ownerId: f.auth.playerId, grantId: consent.grantId },
f.clock,
),
'recovery key': () =>
f.rooms.setRecoveryKey(f.code, f.auth, f.version(), recoverySecret),
recovery: () =>
f.rooms.recoverSeat(f.code, recovery(f, recoverySecret)),
'create handover': () =>
f.rooms.createSeatHandover(
f.code,
f.auth,
f.version(),
handover,
f.clock,
),
'revoke handover': () =>
f.rooms.revokeSeatHandover(
f.code,
f.auth,
f.version(),
handover.offerId,
f.clock,
),
'claim handover': () =>
f.rooms.claimSeatHandover(
f.code,
{
playerId: f.auth.playerId,
...handover,
operationId: randomUUID(),
newSessionToken: secret(),
},
f.clock,
),
'discussion send': () =>
f.talk.sendTableTalk(f.code, f.auth, draft(), 1000),
};
await assert.rejects(operations[kind](), closed);
assert.ok(expected);
assert.deepEqual(f.snapshot(), expected);
} finally {
f.sqlite.close();
}
});
}
void test('sleeping AI rechecks closure before computing and reopened AI retains normal pacing', async () => {
const f = await fixture(true);
try {
await f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'setAutopilot', difficulty: 'Hard' },
f.clock,
);
const clock = {
now: f.clock.now,
sleep: async (ms: number) => {
await f.transition(true);
f.clock.advance(ms);
},
};
await f.rooms.continueRoomBots(f.code, 1, clock);
assert.equal(f.calls.bots, 0);
const before = f.snapshot();
await f.rooms.continueRoomAutomatic(f.code, f.clock);
await f.restart().continueRoomBots(f.code, 4, f.clock);
assert.deepEqual(f.snapshot(), before);
f.clock.advance(50000);
await f.transition(false);
assert.equal(
(await f.rooms.readRoom(f.code)).botNextActionAt,
f.clock.now() + 1500,
);
await f.rooms.continueRoomBots(f.code, 1, f.clock);
assert.equal(f.calls.bots, 1);
} finally {
f.sqlite.close();
}
});
for (const kind of ['AI', 'automatic normalization'] as const) {
void test(`${kind} discards computed work when closure wins its final SQL fence`, async () => {
const f = await fixture(kind === 'AI');
try {
if (kind === 'AI') {
await f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'setAutopilot', difficulty: 'Hard' },
f.clock,
);
f.clock.advance(1500);
} else {
const game = await f.rooms.readRoom(f.code);
game.players[0] = engine.newPlayer(f.auth.playerId, 'CHOAM', 'choam');
game.players.push(engine.newPlayer('guild', 'Guild', 'guild'));
Object.assign(game, {
status: 'playing',
phase: 3,
turn: 2,
order: game.players.map((p) => p.id),
active: f.auth.playerId,
auction: null,
response: null,
deck: baseDeck(),
discard: [],
choamMarket: { owner: f.auth.playerId, resume: 'phase', blocked: [] },
decision: { kind: 'choamMarket', player: f.auth.playerId },
aid: { [f.auth.playerId]: { recipient: 'emperor', amount: 2 } },
});
for (const player of game.players) {
player.hand = [];
player.spice = 10;
}
f.sqlite
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
.run(JSON.stringify(game), f.code);
}
let expected: ReturnType<typeof f.snapshot> | undefined;
f.hooks.beforeRun = (sql) => {
if (!sql.startsWith('UPDATE rooms')) return;
delete f.hooks.beforeRun;
f.flagClosed();
expected = f.snapshot();
};
if (kind === 'AI') await f.rooms.continueRoomBots(f.code, 4, f.clock);
else await f.rooms.continueRoomAutomatic(f.code, f.clock);
assert.ok(expected);
assert.deepEqual(f.snapshot(), expected);
assert.equal(kind === 'AI' ? f.calls.bots : f.calls.normalization, 1);
} finally {
f.sqlite.close();
}
});
}
void test('closed rooms retain private views, history and committed entry/offer/message receipts while all new mutations stop', async () => {
const f = await fixture(true);
try {
const consent = grant(f), handover = offer(), recoverySecret = secret();
await f.rooms.setSeatAiDelegate(f.code, f.auth, f.version(), consent, f.clock);
await f.rooms.setRecoveryKey(f.code, f.auth, f.version(), recoverySecret);
await f.rooms.createSeatHandover(f.code, f.auth, f.version(), handover, f.clock);
const message = draft(f.guestAuth.playerId);
await f.talk.sendTableTalk(f.code, f.auth, message, 1000);
const original = await f.rooms.readSeatView(f.code, f.auth);
assert.equal(original.roomControl, undefined);
await f.transition(true);
const before = f.snapshot();
assert.deepEqual(await f.rooms.authenticate(f.code, f.owner.token), f.auth);
const view = await f.rooms.readSeatView(f.code, f.auth);
assert.deepEqual(view.roomControl, { paused: false, joinLocked: false, closed: true, revision: 0, updatedAt: null });
const { roomControl: _control, ...rest } = view;
assert.deepEqual({ ...rest, version: original.version }, original);
assert.equal((await f.talk.readTableTalk(f.code, f.auth, f.guestAuth.playerId)).messages[0].text, message.text);
assert.equal((await f.talk.sendTableTalk(f.code, f.auth, message, 1000)).replayed, true);
assert.equal((await f.rooms.createRoom('Closure owner', 'atreides', false, [], f.ownerEntry)).entryReceipt?.replayed, true);
assert.equal((await f.rooms.joinRoom(f.code, 'Closure guest', 'emperor', f.guestEntry)).entryReceipt?.replayed, true);
assert.equal((await f.rooms.joinRoom(f.code, 'Any name', 'guild', undefined, f.auth)).view.me, f.auth.playerId);
assert.equal((await f.rooms.createSeatHandover(f.code, f.auth, f.version(), handover, f.clock)).replayed, true);
assert.equal((await f.rooms.setSeatAiDelegate(f.code, f.auth, f.version(), consent, f.clock)).replayed, true);
for (const action of [
() => f.rooms.joinRoom(f.code, 'New guest', 'guild', entry()),
() => f.rooms.act(f.code, f.auth, f.version(), { type: 'advanceBots' }, f.clock),
() => f.rooms.act(f.code, f.auth, f.version(), { type: 'setAutopilot', difficulty: null }, f.clock),
() => f.rooms.setSeatAiDelegate(f.code, f.auth, f.version(), grant(f), f.clock),
() => f.rooms.revokeSeatAiDelegate(f.code, f.auth, f.version(), consent.grantId, f.clock),
() => f.rooms.useSeatAiDelegate(f.code, f.guestAuth, f.version(), { ownerId: f.auth.playerId, grantId: consent.grantId }, f.clock),
() => f.rooms.setRecoveryKey(f.code, f.auth, f.version(), secret()),
() => f.rooms.recoverSeat(f.code, recovery(f, recoverySecret)),
() => f.rooms.createSeatHandover(f.code, f.auth, f.version(), offer(), f.clock),
() => f.rooms.revokeSeatHandover(f.code, f.auth, f.version(), handover.offerId, f.clock),
() => f.rooms.claimSeatHandover(f.code, { playerId: f.auth.playerId, ...handover, operationId: randomUUID(), newSessionToken: secret() }, f.clock),
() => f.talk.sendTableTalk(f.code, f.auth, draft(), 3000),
]) await assert.rejects(action(), closed);
assert.deepEqual(f.snapshot(), before);
await f.transition(false);
assert.deepEqual({ ...await f.rooms.readSeatView(f.code, f.auth), version: original.version }, original);
} finally { f.sqlite.close(); }
});
void test('completed recovery and handover confirm while closed without rotating again or reviving old credentials', async () => {
const f = await fixture();
try {
const recoverySecret = secret();
await f.rooms.setRecoveryKey(f.code, f.guestAuth, f.version(), recoverySecret);
const recovering = { ...recovery(f, recoverySecret), playerId: f.guestAuth.playerId };
const recovered = await f.rooms.recoverSeat(f.code, recovering);
const handover = offer();
await f.rooms.createSeatHandover(f.code, f.auth, f.version(), handover, f.clock);
const claiming = { playerId: f.auth.playerId, ...handover, operationId: randomUUID(), newSessionToken: secret() };
const claimed = await f.rooms.claimSeatHandover(f.code, claiming, f.clock);
await f.transition(true);
const before = f.snapshot();
const recoveredAgain = await f.restart().recoverSeat(f.code, recovering);
const claimedAgain = await f.restart().claimSeatHandover(f.code, claiming, f.clock);
assert.equal(recoveredAgain.replayed, true); assert.equal(recoveredAgain.token, recovered.token);
assert.equal(claimedAgain.replayed, true); assert.equal(claimedAgain.token, claimed.token);
assert.equal(recoveredAgain.view.roomControl?.closed, true); assert.equal(claimedAgain.view.roomControl?.closed, true);
for (const token of [f.owner.token, f.guest.token!]) await assert.rejects(f.rooms.authenticate(f.code, token), /could not be verified/);
assert.deepEqual(f.snapshot(), before);
await f.transition(false);
for (const token of [f.owner.token, f.guest.token!]) await assert.rejects(f.rooms.authenticate(f.code, token), /could not be verified/);
} finally { f.sqlite.close(); }
});
void test('an already used AI grant remains a read-only receipt while closure blocks taking control back', async () => {
const f = await fixture(true);
try {
const consent = grant(f);
await f.rooms.setSeatAiDelegate(f.code, f.auth, f.version(), consent, f.clock);
const activate = { ownerId: f.auth.playerId, grantId: consent.grantId };
await f.rooms.useSeatAiDelegate(f.code, f.guestAuth, f.version(), activate, f.clock);
await f.transition(true);
const before = f.snapshot();
assert.equal((await f.rooms.useSeatAiDelegate(f.code, f.guestAuth, f.version(), activate, f.clock)).replayed, true);
await assert.rejects(f.rooms.act(f.code, f.auth, f.version(), { type: 'setAutopilot', difficulty: null }, f.clock), closed);
assert.deepEqual(f.snapshot(), before);
} finally { f.sqlite.close(); }
});
void test('closed HTTP reads and exact delegation receipts do not schedule automatic or AI workers', async () => {
const f = await fixture(true);
try {
const consent = grant(f), activation = { ownerId: f.auth.playerId, grantId: '' };
await f.rooms.setSeatAiDelegate(f.code, f.auth, f.version(), consent, f.clock);
activation.grantId = consent.grantId;
await f.rooms.useSeatAiDelegate(f.code, f.guestAuth, f.version(), activation, f.clock);
await f.transition(true);
const before = f.snapshot();
let scheduled = 0;
function route(control: boolean) {
const exports: Record<string, (req: Request) => Promise<Response>> = {};
runInNewContext(ts.transpileModule(readFileSync(new URL(control ? '../app/api/rooms/[code]/control/route.ts' : '../app/api/rooms/[code]/route.ts', import.meta.url), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText, { exports, URL, Response, JSON, require: (name: string) => {
if (name === '@/db/rooms') return f.rooms;
if (name === '@/db/room-continuation') return { resumeRoom: async () => { assert.fail('Closed room scheduled a worker.'); } };
if (name === '@/game/engine') return engine;
if (name === '@/lib/request-origin') return { requestOrigin };
if (name === '@/lib/seat-ai-delegation') return delegation;
if (name === 'cloudflare:workers') return { env: {}, waitUntil: () => { scheduled++; } };
throw new Error('Unexpected route dependency ' + name);
} });
return exports;
}
const path = `http://localhost/api/rooms/${f.code}`;
const headers = { cookie: `dune_${f.code}=${f.guest.token}`, origin: 'http://localhost', 'content-type': 'application/json' };
const read = await route(false).GET(new Request(path, { headers }));
assert.equal(read.status, 200);
assert.equal((await read.json() as { roomControl: { closed: boolean } }).roomControl.closed, true);
const replay = await route(true).POST(new Request(path + '/control', { method: 'POST', headers, body: JSON.stringify({ type: 'useSeatAiDelegate', version: f.version(), ...activation }) }));
assert.equal(replay.status, 200);
assert.equal((await replay.json() as { replayed: boolean }).replayed, true);
const blocked = await route(false).POST(new Request(path, { method: 'POST', headers, body: JSON.stringify({ version: f.version(), action: { type: 'advanceBots' } }) }));
assert.equal(blocked.status, 409);
assert.equal(scheduled, 0); assert.deepEqual(f.snapshot(), before);
} finally { f.sqlite.close(); }
});
+11
View File
@@ -0,0 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { renderToStaticMarkup } from 'react-dom/server';
import { SeatRecoveryClaim } from '../components/seat-recovery';
void test('saved-seat recovery renders without a browser window or ambient closed variable', () => {
assert.equal('closed' in globalThis, false);
const html = renderToStaticMarkup(<SeatRecoveryClaim onRestored={() => {}} />);
assert.match(html, /Recover a saved seat/);
assert.match(html, /Paste your private recovery kit/);
});
+95
View File
@@ -0,0 +1,95 @@
// Close/reopen only the room this verifier creates; existing games are never selected.
import assert from 'node:assert/strict';
import { randomBytes, randomUUID } from 'node:crypto';
export async function verifyRoomClosure(request, adminCookie, report) {
const secret = () => randomBytes(32).toString('hex');
let code, path, url, controls, failure;
async function closure(closed, saved) {
const current = await request(url, undefined, adminCookie); assert.equal(current.status, 200);
const input = saved ?? { operationId: randomUUID(), expectedVersion: current.data.version, expectedRevision: current.data.revision, closed, reason: 'Dedicated close and reopen acceptance' };
const response = await request(url, 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 };
}
async function control(paused, joinLocked) {
const current = await request(controls, undefined, adminCookie); assert.equal(current.status, 200);
return request(controls, { operationId: randomUUID(), expectedRevision: current.data.revision, paused, joinLocked, reason: 'Dedicated closure QA flags' }, adminCookie);
}
try {
const creation = { operationId: randomUUID(), sessionToken: secret(), name: 'Closure QA ' + Date.now(), faction: 'atreides', advanced: false,
techTokens: false, strongholdCards: false, bots: [], reason: 'Dedicated closure acceptance' };
const made = await request('/api/admin/rooms', creation, adminCookie); assert.equal(made.status, 200);
code = made.data.code; report.closureRoom = code; path = '/api/rooms/' + code; url = '/api/admin/rooms/' + code + '/closure'; controls = '/api/admin/rooms/' + code + '/control';
const oldHost = made.cookie;
const joined = await request(path, { type: 'join', name: 'Closure participant QA', faction: 'emperor' }); assert.equal(joined.status, 200);
const guest = joined.cookie;
const original = await request(path, undefined, oldHost), recoverySecret = secret();
assert.equal((await request(path + '/control', { type: 'setRecoveryKey', version: original.data.version, recoverySecret }, oldHost)).status, 200);
const recovery = { type: 'recoverSeat', playerId: made.data.hostId, recoverySecret, operationId: randomUUID(), newSessionToken: secret() };
const recovered = await request(path + '/control', recovery); assert.equal(recovered.status, 200);
const host = recovered.cookie, message = { id: randomUUID(), recipientId: null, text: 'Dedicated closure continuity QA' };
assert.equal((await request(path + '/messages', message, host)).status, 200);
for (const cookie of [host, guest]) {
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);
assert.equal((await control(true, true)).status, 200);
const before = (await request(path, undefined, host)).data, history = (await request(path + '/messages', undefined, host)).data;
const metadata = await request(url, undefined, adminCookie);
const input = { operationId: randomUUID(), expectedVersion: metadata.data.version, expectedRevision: metadata.data.revision, closed: true, reason: 'Dedicated closure acceptance' };
for (const denied of [await request(url, input, host), await request(url, input, adminCookie, 'https://foreign.invalid'), await request(url, input, adminCookie, undefined, randomUUID())]) {
assert.ok([401, 403].includes(denied.status)); assert.equal(denied.rawCookie, null);
}
const closed = await closure(true, input); assert.equal(closed.room.closed, true);
assert.equal((await closure(true, input)).replayed, true);
const directory = await request('/api/admin/rooms?availability=closed&q=' + code, undefined, adminCookie);
assert.equal(directory.status, 200); assert.equal(directory.data.total, 1); assert.equal(directory.data.rooms[0].control.closed, true);
assert.equal((await request('/api/admin/rooms?availability=running&q=' + code, undefined, adminCookie)).data.total, 0);
const view = await request(path, undefined, host); assert.equal(view.status, 200);
assert.deepEqual({ ...view.data, version: before.version, roomControl: before.roomControl }, before);
assert.equal(view.data.roomControl.closed, true);
assert.deepEqual((await request(path + '/messages', undefined, host)).data, history);
assert.equal((await request(path, undefined, guest)).data.me, joined.data.me);
assert.equal((await request(path + '/messages', message, host)).data.replayed, true);
const receipt = await request(path + '/control', recovery); assert.equal(receipt.status, 200); assert.equal(receipt.data.replayed, true); assert.equal(receipt.cookie, host);
assert.notEqual((await request(path, undefined, oldHost)).status, 200);
report.checks.push('Closure is authorized and exactly replayable; closed room and private table/history remain readable; prior message/recovery receipts cannot rotate again');
for (const blocked of [await request(path, { type: 'join', name: 'Blocked closure QA', faction: 'guild' }),
await request(path, { version: view.data.version, action: { type: 'advanceBots' } }, host),
await request(path, { version: view.data.version, action: { type: 'setAutopilot', difficulty: null } }, host),
await request(path + '/messages', { ...message, id: randomUUID() }, host),
await request(path + '/control', { type: 'setRecoveryKey', version: view.data.version, recoverySecret: secret() }, host)]) {
assert.equal(blocked.status, 409); assert.equal(blocked.data.code, 'ROOM_CLOSED'); assert.equal(blocked.rawCookie, null);
assert.deepEqual(Object.keys(blocked.data).sort(), ['code', 'error']);
}
assert.equal((await control(false, false)).status, 409);
assert.equal((await request('/api/admin/rooms/' + code + '/lobby', undefined, adminCookie)).data.editable, false);
assert.deepEqual((await request(path, undefined, host)).data, view.data);
report.checks.push('Closed room blocks joins, game/AI/takeback, fresh messages, recovery keys and admin lobby/pause changes without changing the saved table');
const reopened = await closure(false); assert.equal(reopened.room.closed, false); assert.equal(reopened.room.paused, true); assert.equal(reopened.room.joinLocked, true);
const after = (await request(path, undefined, host)).data;
assert.deepEqual({ ...after, version: before.version }, before);
const old = await closure(true, input); assert.equal(old.replayed, true); assert.equal(old.room.closed, false);
assert.deepEqual((await request(path, undefined, host)).data, after);
assert.equal((await request(path + '/messages', undefined, host)).data.messages.length, history.messages.length);
report.checks.push('Reopening preserves the full pending game, pause/join settings, discussion and current seat ownership; old close receipts cannot close it again');
} catch (error) {
report.failureRequest ??= report.lastRequest;
report.closureFailure = 'Close/reopen acceptance failed; the dedicated QA room is retained.';
report.closureFailureLocation = error instanceof Error ? error.stack?.split('\n').find(line => line.trim().startsWith('at ') && line.includes('admin-closure-verification.mjs'))?.trim() : undefined;
failure = error;
} finally {
if (code) try {
const current = await request(url, undefined, adminCookie); assert.equal(current.status, 200);
if (current.data.closed) await closure(false);
const flags = await request(controls, undefined, adminCookie); assert.equal(flags.status, 200);
if (flags.data.paused || flags.data.joinLocked) assert.equal((await control(false, false)).status, 200);
} catch (error) { report.closureCleanupFailure = { message: 'Dedicated QA reopening could not be confirmed.', request: report.lastRequest }; failure ??= error; }
}
if (failure) throw failure;
}
+3 -1
View File
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
import { verifyRoomLifecycle } from './admin-lifecycle-verification.mjs';
import { verifyRoomCreation } from './admin-creation-verification.mjs';
import { verifyLobbyConfiguration } from './admin-lobby-verification.mjs';
import { verifyRoomClosure } from './admin-closure-verification.mjs';
import { verifyRoomRemoval } from './admin-removal-verification.mjs';
const options = {};
@@ -33,7 +34,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)$/.test(path)) ? { 'X-Dune-Admin-Id': expectedAdminId } : {}) },
...(expectedAdminId && (path === '/api/admin/rooms' && body !== undefined || /^\/api\/admin\/rooms\/[^/]+\/(control|lobby|removal|closure)$/.test(path)) ? { 'X-Dune-Admin-Id': expectedAdminId } : {}) },
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15000),
});
report.lastRequest = { path, status: response.status };
@@ -89,6 +90,7 @@ try {
await verifyRoomCreation(request, session1, report);
await verifyLobbyConfiguration(request, session1, report);
await verifyRoomRemoval(request, session1, report);
await verifyRoomClosure(request, session1, report);
}
report.passed = true;
} catch (error) {