Add administrator pause and joining-lock controls

Provide authenticated owner/operator room controls with live account binding, durable audit and exact saved retries. Pause gates human, bot and automatic writes while preserving recovery, discussion and taking back control. Resume keeps decisions and restores AI pacing. Add directory availability filters and player notices.

Verified types, lint, 5223 offline tests, 52 HTTP tests and build. Independent persistence/privacy review and authenticated HTTP acceptance passed. Desktop/mobile browser QA confirmed lost-response refresh/retry makes one audit row and resume restores controls. Existing games preserved. Authenticated deployed admin acceptance remains pending explicit QA access confirmation.
This commit is contained in:
Quintine committed 2026-09-22 10:41:09 +10:00
1 parent f29d05620c
commit 8c922b446b
39 files changed
+3196 -104

No files matched your search

+4 -3
View File
@@ -40,9 +40,10 @@ For private NAS hosting and manually applied checkpoint image updates, see the
with new games; the requested host port is 33046.
The **Administration** link opens `/admin`, with separate administrator sign-in,
session controls and a searchable room directory. See [initial owner setup and
scope](docs/ADMIN_PANEL.md#initial-owner-and-access-recovery). Room creation,
lifecycle/removal, participant support and backup tools are still being built.
session controls and a searchable room directory. **Room controls** lets owners
and operators pause/resume rooms and lock/unlock new joins. See [room controls](docs/ADMIN_ROOM_CONTROLS.md)
and [initial owner setup](docs/ADMIN_PANEL.md#initial-owner-and-access-recovery).
Room creation, archive/removal, participant support and backup tools are still being built.
## Saved seats and reconnecting
+9
View File
@@ -30,11 +30,20 @@
.admin-confirm { border: 1px solid var(--border); padding: 16px; }
.admin-confirm button { margin-right: 10px; }
.admin-development { border-top: 1px solid var(--border); margin-top: 28px; padding-top: 20px; }
.admin-room-control { margin: 24px 0; padding: 24px; border: 1px solid var(--primary); border-radius: 8px; background: #171c16; max-width: 880px; }
.admin-room-control form { display: grid; gap: 16px; margin-top: 24px; }
.admin-room-control button { min-height: 44px; }
.admin-room-control label { display: grid; gap: 6px; }
.admin-room-control .admin-check { display: flex; align-items: center; gap: 12px; min-height: 44px; }
.admin-room-control .admin-check input { width: 22px; height: 22px; min-height: 22px; flex-shrink: 0; accent-color: var(--primary); }
.admin-room-control .admin-confirm { margin-top: 16px; }
.admin-room-control h2:focus-visible { outline: 2px solid var(--primary); outline-offset: 5px; }
@media (max-width: 700px) {
.admin-shell { padding: 20px 16px; }
.admin-header, .admin-section-heading { align-items: start; flex-direction: column; gap: 16px; }
.admin-header h1 { font-size: 1.8rem; }
.admin-login { padding: 20px; margin: 24px 0; }
.admin-room-control { padding: 18px; }
.admin-room-table { display: block; }
.admin-room-table thead { display: none; }
.admin-room-table tbody { display: block; }
+36 -20
View File
@@ -8,11 +8,12 @@ import { Shield, RefreshCw, LogOut } from 'lucide-react';
import { ClientRequestError, requestJson } from '@/lib/client-request';
import type { AdminDirectory } from '@/lib/admin-directory';
import { FACTIONS } from '@/game/catalog';
import { AdminRoomControls } from '@/components/admin-room-controls';
import './admin.css';
type Account = { id: string; name: string; role: 'owner' | 'operator' | 'viewer' };
type Filters = { q: string; status: string; rules: string; sort: string };
const initialFilters: Filters = { q: '', status: 'all', rules: 'all', sort: 'recent' };
type Filters = { q: string; status: string; rules: string; sort: string; availability: string };
const initialFilters: Filters = { q: '', status: 'all', rules: 'all', sort: 'recent', availability: 'all' };
const errorMessage = (error: unknown) => error instanceof Error ? error.message : 'The request could not be completed.';
export default function Administration() {
@@ -25,12 +26,19 @@ export default function Administration() {
const [filters, setFilters] = useState(initialFilters);
const [applied, setApplied] = useState(initialFilters);
const [confirmAll, setConfirmAll] = useState(false);
const [selectedRoom, setSelectedRoom] = useState<string | null>(null);
const [operationBusy, setOperationBusy] = useState(false);
const controlsBusy = busy || operationBusy;
const epoch = useRef(0);
const clearSession = useCallback(() => {
epoch.current++;
setAccount(null); setDirectory(null); setConfirmAll(false);
setBusy(false);
setBusy(false); setSelectedRoom(null);
}, []);
const accessChanged = useCallback(() => {
clearSession();
setNotice('Administrator access changed. Sign in again to confirm any saved room-settings request.');
}, [clearSession]);
const load = useCallback(async (next: Filters, page = 1) => {
const current = ++epoch.current;
setBusy(true); setNotice('');
@@ -55,7 +63,7 @@ export default function Administration() {
}, [load]);
async function sessionAction(action: 'login' | 'logout' | 'logoutAll') {
if (busy) return;
if (controlsBusy) return;
setBusy(true); setNotice('');
const submittedKey = key.trim();
setKey('');
@@ -84,7 +92,7 @@ export default function Administration() {
<header className="admin-header">
<div><a className="admin-return" href="/">Arrakis Table</a><h1><Shield aria-hidden="true" /> Administration</h1></div>
{account && <div className="admin-session"><span>{account.name} <span className="admin-tag">{account.role}</span></span>
<Button variant="outline" disabled={busy} onClick={() => void sessionAction('logout')}><LogOut aria-hidden="true" /> Sign out</Button></div>}
<Button variant="outline" disabled={controlsBusy} onClick={() => void sessionAction('logout')}><LogOut aria-hidden="true" /> Sign out</Button></div>}
</header>
{notice && <output className="admin-notice">{notice}</output>}
{restoring ? <output>Checking administrator access…</output> : !account ?
@@ -94,27 +102,33 @@ export default function Administration() {
<form onSubmit={event => { event.preventDefault(); void sessionAction('login'); }}>
<label htmlFor="admin-key">Access key</label>
<Input id="admin-key" type="password" autoComplete="current-password" autoCapitalize="none" spellCheck={false}
maxLength={130} value={key} onChange={event => setKey(event.target.value)} required disabled={busy} />
<Button type="submit" disabled={busy || !key.trim()}>{busy ? 'Signing in…' : 'Sign in'}</Button>
maxLength={130} value={key} onChange={event => setKey(event.target.value)} required disabled={controlsBusy} />
<Button type="submit" disabled={controlsBusy || !key.trim()}>{busy ? 'Signing in…' : 'Sign in'}</Button>
</form>
<p className="admin-secondary">First setup: the server owner creates a personal key using the installation instructions. Access is closed until an administrator is provisioned.</p>
</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>
<Button variant="outline" disabled={busy} onClick={() => void load(applied, directory?.page ?? 1)}><RefreshCw aria-hidden="true" /> Refresh</Button></div>
<Button variant="outline" disabled={controlsBusy} onClick={() => void load(applied, directory?.page ?? 1)}><RefreshCw aria-hidden="true" /> Refresh</Button></div>
<form className="admin-filters" onSubmit={event => { event.preventDefault(); void load(filters); }}>
<label className="admin-search" htmlFor="admin-search">Room code or player name<Input id="admin-search" value={filters.q} maxLength={80} disabled={busy} onChange={e => setFilters({ ...filters, q: e.target.value })} /></label>
<label>Status<select disabled={busy} value={filters.status} onChange={e => setFilters({ ...filters, status: e.target.value })}>
<label className="admin-search" htmlFor="admin-search">Room code or player name<Input id="admin-search" value={filters.q} maxLength={80} disabled={controlsBusy} onChange={e => setFilters({ ...filters, q: e.target.value })} /></label>
<label>Status<select disabled={controlsBusy} value={filters.status} onChange={e => setFilters({ ...filters, status: e.target.value })}>
<option value="all">All statuses</option><option value="lobby">Lobby</option><option value="setup">Setup</option><option value="playing">Playing</option><option value="finished">Finished</option>
</select></label>
<label>Rules<select disabled={busy} value={filters.rules} onChange={e => setFilters({ ...filters, rules: e.target.value })}>
<label>Rules<select disabled={controlsBusy} value={filters.rules} onChange={e => setFilters({ ...filters, rules: e.target.value })}>
<option value="all">All rules</option><option value="basic">Basic</option><option value="advanced">Advanced</option>
</select></label>
<label>Sort<select disabled={busy} value={filters.sort} onChange={e => setFilters({ ...filters, sort: e.target.value })}>
<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>
</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>
</select></label>
<Button type="submit" disabled={busy}>Apply filters</Button>
<Button type="submit" disabled={controlsBusy}>Apply filters</Button>
</form>
{selectedRoom && <AdminRoomControls key={`${account.id}:${selectedRoom}`} accountId={account.id} code={selectedRoom}
canManage={account.role !== 'viewer'} onClose={() => setSelectedRoom(null)} onDenied={accessChanged}
onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />}
<div aria-busy={busy}>
{!directory ? <output>{busy ? 'Loading rooms…' : 'Use Refresh to load rooms.'}</output> : <>
<p className="admin-results">{directory.total.toLocaleString()} {directory.total === 1 ? 'room' : 'rooms'} match</p>
@@ -123,6 +137,8 @@ export default function Administration() {
<TableHead>Room & players</TableHead><TableHead>Rules</TableHead><TableHead>Progress</TableHead><TableHead>Last game change</TableHead>
</TableRow></TableHeader><TableBody>{directory.rooms.map(room => <TableRow key={room.code}>
<TableCell><strong className="admin-code">{room.code}</strong> <span className="admin-tag">{room.status}</span>
{room.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>
<details><summary>{room.players.length} {room.players.length === 1 ? 'player' : 'players'}</summary><ul>{room.players.map(player => <li key={player.id}>{player.name} · {FACTIONS.find(f => f.id === player.faction)?.name ?? player.faction} · {player.control === 'ai' ? 'AI' : player.control === 'autopilot' ? 'Human seat on AI' : 'Human'}</li>)}</ul></details>
</TableCell>
@@ -133,23 +149,23 @@ 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><time dateTime={new Date(room.updatedAt).toISOString()}>{new Date(room.updatedAt).toLocaleString()}</time><p className="admin-secondary">Version {room.version}</p></TableCell>
<TableCell><Button variant="outline" disabled={controlsBusy} onClick={() => setSelectedRoom(room.code)}>Room controls · {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={busy || directory.page <= 1} onClick={() => void load(applied, 1)}>First</Button>
<Button variant="outline" disabled={busy || directory.page <= 1} onClick={() => void load(applied, directory.page - 1)}>Previous</Button>
<Button variant="outline" disabled={controlsBusy || directory.page <= 1} onClick={() => void load(applied, 1)}>First</Button>
<Button variant="outline" disabled={controlsBusy || directory.page <= 1} onClick={() => void load(applied, directory.page - 1)}>Previous</Button>
<span>Page {directory.page} of {Math.max(1, Math.ceil(directory.total / directory.pageSize))}</span>
<Button variant="outline" disabled={busy || directory.page * directory.pageSize >= directory.total} onClick={() => void load(applied, directory.page + 1)}>Next</Button>
<Button variant="outline" disabled={controlsBusy || directory.page * directory.pageSize >= directory.total} onClick={() => void load(applied, directory.page + 1)}>Next</Button>
</nav>
</>}
</div>
</section>
<section className="admin-session-control" aria-labelledby="admin-access-title"><h2 id="admin-access-title">Your access</h2>
<p>Sessions expire after eight hours. Sign out everywhere to end all your current administrator sessions, including this browser. Your personal access key remains valid.</p>
{!confirmAll ? <Button variant="outline" disabled={busy} onClick={() => setConfirmAll(true)}>Sign out everywhere…</Button> :
<div className="admin-confirm"><p>End all your administrator sessions now?</p><Button disabled={busy} onClick={() => void sessionAction('logoutAll')}>Confirm sign out everywhere</Button><Button variant="outline" disabled={busy} onClick={() => setConfirmAll(false)}>Cancel</Button></div>}
{!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: room creation, lifecycle controls, participant support and backup tools are still being implemented.</p>
<p className="admin-secondary admin-development">Administration preview: pause/resume and joining locks are available. Room creation, archive/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 { applyAdminRoomControl, readAdminRoomControl } from '@/db/admin-lifecycle';
import { resumeRoom } from '@/db/room-continuation';
import { adminBody, adminFailure, adminResponse, adminToken } from '@/lib/admin-http';
import { validAdminRoomControlInput } from '@/lib/room-control';
function roomCode(request: Request) {
const code = new URL(request.url).pathname.split('/').at(-2)?.toUpperCase() ?? '';
if (!/^[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 readAdminRoomControl(env.DB, identity, roomCode(request)));
} catch (error) { return adminFailure(error); }
}
export async function POST(request: Request) {
try {
const body = 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 (!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));
return adminResponse(result);
} catch (error) { return adminFailure(error); }
}
+1 -1
View File
@@ -85,7 +85,7 @@ export async function POST(req: Request) {
ownerId: input.ownerId,
grantId: input.grantId,
});
if (result.view.botsPending || needsAutomaticRoomRecovery(result.view))
if (!result.view.roomControl?.paused && (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.botsPending || needsAutomaticRoomRecovery(view))
if (!view.roomControl?.paused && (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.botsPending || needsAutomaticRoomRecovery(view))
if (!view.roomControl?.paused && (view.botsPending || needsAutomaticRoomRecovery(view)))
waitUntil(resumeRoom(code));
return Response.json(view, {
headers: { 'Cache-Control': 'no-store' },
+5
View File
@@ -170,6 +170,7 @@ export default function Home() {
const room = data.code;
if (
!data.botsPending ||
data.roomControl?.paused ||
activeRoom.current !== room ||
data.me !== activeSeat.current ||
data.version < (knownVersions.current.get(room) ?? -1) ||
@@ -501,6 +502,10 @@ export default function Home() {
async function send(action: Action) {
if (!game || mutationPending.current || recovery.current) 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;
}
const room = game.code,
seat = game.me,
requestEpoch = epoch.current;
+162
View File
@@ -0,0 +1,162 @@
'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 { clearAdminRoomRequest, newAdminRoomRequest, readAdminRoomRequest, retainAdminRoomRequest, saveAdminRoomRequest } from '@/lib/admin-room-control-client';
import type { AdminRoomControlInput, RoomControl } from '@/lib/room-control';
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 : 'The room settings could not be loaded.';
function controlResponse(value: RoomControl): RoomControl {
if (!value || typeof value.paused !== 'boolean' || typeof value.joinLocked !== 'boolean' ||
!Number.isSafeInteger(value.revision) || value.revision < 0 ||
!(value.updatedAt === null || Number.isSafeInteger(value.updatedAt))) throw new Error('The server returned unreadable room settings.');
return value;
}
export function AdminRoomControls({ accountId, code, canManage, onClose, onUpdated, onDenied, onBusyChange }: Props) {
const [control, setControl] = useState<RoomControl | null>(null);
const [paused, setPaused] = useState(false);
const [joinLocked, setJoinLocked] = useState(false);
const [reason, setReason] = useState('');
const [confirmed, setConfirmed] = useState(false);
const [pending, setPending] = useState<AdminRoomControlInput | 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);
const title = useRef<HTMLHeadingElement>(null);
const inFlight = useRef(false);
const url = `/api/admin/rooms/${code}/control`;
useEffect(() => {
alive.current = true;
title.current?.focus();
let canceled = false;
try {
const saved = readAdminRoomRequest(window.sessionStorage, accountId, code);
// oxlint-disable-next-line react/react-compiler -- Restore this account/room's tab-scoped request after SSR.
setPending(saved);
if (saved) setNotice('A saved request needs confirmation. Review the current settings, then retry the same request.');
} catch (error) { setStorageProblem(true); setNotice(message(error)); }
requestJson<RoomControl>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } })
.then(result => {
const current = controlResponse(result);
if (!canceled) { setControl(current); setPaused(current.paused); setJoinLocked(current.joinLocked); }
})
.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 = controlResponse(await requestJson<RoomControl>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }));
if (alive.current) { setControl(current); setPaused(current.paused); setJoinLocked(current.joinLocked); setConfirmed(false); }
} 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?: AdminRoomControlInput) {
if (inFlight.current || !control || !canManage || storageProblem || (!saved && !confirmed)) return;
let input: AdminRoomControlInput;
try {
input = saved ?? newAdminRoomRequest(control, paused, joinLocked, reason);
saveAdminRoomRequest(window.sessionStorage, accountId, code, input);
} catch (error) { setNotice(message(error)); return; }
inFlight.current = true; setBusy(true); onBusyChange(true); setPending(input); setNotice('Saving room settings…');
try {
const result = await requestJson<RoomControl & { replayed: boolean }>(url, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input),
});
const current = controlResponse(result);
if (typeof result.replayed !== 'boolean') throw new Error('The server did not confirm this operation.');
clearAdminRoomRequest(window.sessionStorage, accountId, code);
if (alive.current) {
setPending(null); setControl(current); setPaused(current.paused); setJoinLocked(current.joinLocked);
setConfirmed(false); setReason('');
setNotice(result.replayed ? 'The original request was already recorded. Current settings are shown below.' : 'Room settings saved.');
onUpdated();
}
} catch (error) {
const uncertain = requestMayHaveCompleted(error);
let cleared = false;
if (!retainAdminRoomRequest(error)) {
try { clearAdminRoomRequest(window.sessionStorage, accountId, code); cleared = true; } catch { /* Keep exact retry after storage failure. */ }
}
if (alive.current) {
if (cleared) setPending(null);
setConfirmed(false);
setNotice(uncertain ? `${message(error)} The change may have been saved. Use Retry saved request to confirm; no new request has been sent.` : message(error));
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
else {
try {
const current = controlResponse(await requestJson<RoomControl>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }));
if (alive.current) { setControl(current); setPaused(current.paused); setJoinLocked(current.joinLocked); }
} catch { /* Preserve the original outcome and retry instructions. */ }
}
}
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
}
function discardUnreadableRecord() {
if (!confirmed || busy) return;
try {
clearAdminRoomRequest(window.sessionStorage, accountId, code);
setStorageProblem(false); setConfirmed(false);
setNotice('Local retry record removed. This did not undo any server change. Review the current settings before continuing.');
} catch (error) { setNotice(message(error)); }
}
const changed = control && (control.paused !== paused || control.joinLocked !== joinLocked);
return <section className="admin-room-control" aria-labelledby="admin-room-control-title" aria-busy={loading || busy}>
<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>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>
{!canManage ? <p>Your viewer role can inspect settings. An owner or operator can change them.</p> : storageProblem ? <div className="admin-confirm">
<p>No new changes can be sent until this tab’s unreadable retry record is resolved. Removing it does not cancel a change already saved on the server.</p>
<label className="admin-check"><input type="checkbox" checked={confirmed} onChange={e => setConfirmed(e.target.checked)} />I have inspected the current settings and want to remove the local retry record.</label>
<Button disabled={busy || !confirmed} onClick={discardUnreadableRecord}>Discard unreadable local record</Button>
</div> : pending ? <div className="admin-confirm">
<p>Saved request: {pending.paused ? 'pause' : 'resume'} room {code}; {pending.joinLocked ? 'lock' : 'open'} new joins.</p>
<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(); }}>
<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>
<p className="admin-secondary">Use a short operational reason. Do not include credentials or private game information.</p>
<div className="admin-confirm">
<p>This affects every player in room <strong>{code}</strong>. Warn active players before interrupting their game. Resuming keeps the pending choices and restarts AI pacing.</p>
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy || !changed} onChange={e => setConfirmed(e.target.checked)} />I have checked this room and am ready to apply these settings.</label>
<Button type="submit" disabled={busy || !changed || !confirmed || !reason.trim()}>Apply room settings</Button>
</div>
</form>}
</>}
</section>;
}
+15 -13
View File
@@ -86,6 +86,7 @@ import {
} from './richese-allied-no-field';
import { presenceByLocation } from '@/game/force-presence';
import { AutomaticActionNotice } from './automatic-action-notice';
import { RoomControlNotice } from './room-control-notice';
import { TableSounds } from './table-sounds';
import { EcazSpice } from './ecaz-spice';
import { VictoryProgress } from './victory-progress';
@@ -285,7 +286,7 @@ export function GameTable({
: [],
);
const busy =
transportBusy || !!me.autopilot || g.automaticContinuationPending;
transportBusy || !!g.roomControl?.paused || !!me.autopilot || g.automaticContinuationPending;
const traitorBattle = g.battle;
const traitorBeneficiary =
traitorBattle &&
@@ -954,6 +955,7 @@ export function GameTable({
</Button>
</div>
</header>
<RoomControlNotice control={g.roomControl} />
<div className="game-banner">
<span className="eyebrow">
{g.status === 'lobby'
@@ -1573,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 || !!me.autopilot} />
<DiscoveryPanel game={g} act={act} busy={transportBusy || !!me.autopilot} />
<LeaderSkillsPanel skills={g.leaderSkills} leaders={g.allLeaders} players={g.players} act={act} busy={transportBusy || !!me.autopilot} />
<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} />
<RihaniHistory game={g} />
<MentatHistory game={g} />
<NexusChoamTrade game={g} act={act} busy={transportBusy || !!me.autopilot} />
<NexusTleilaxu game={g} act={act} busy={transportBusy || !!me.autopilot} />
<NexusSuboids game={g} act={act} busy={transportBusy || !!me.autopilot} />
<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} />
<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} />}
@@ -3035,9 +3037,9 @@ export function GameTable({
))}
</>
) : g.decision.kind === 'sukRescue' ? (
<SukGraduatePanel key={g.decision.event} decision={g.decision} act={act} busy={transportBusy || !!me.autopilot} />
<SukGraduatePanel key={g.decision.event} decision={g.decision} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
) : g.decision.kind === 'rihani' ? (
<RihaniChoice key={g.decision.event} game={g} act={act} busy={transportBusy || !!me.autopilot} />
<RihaniChoice key={g.decision.event} game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
) : g.decision.kind === 'battleLosses' ? (
<>
<p className="muted">
@@ -3142,13 +3144,13 @@ export function GameTable({
})}
</>
) : g.decision.kind === 'discoveryEntry' ? (
<DiscoveryEntryDecision game={g} act={act} busy={transportBusy || !!me.autopilot} />
<DiscoveryEntryDecision game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
) : g.decision.kind === 'ecologicalStorm' ? (
<DiscoveryStormDecision game={g} act={act} busy={transportBusy || !!me.autopilot} />
<DiscoveryStormDecision game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
) : g.decision.kind === 'discoveryDiscard' ? (
<DiscoveryDiscardDecision game={g} act={act} busy={transportBusy || !!me.autopilot} />
<DiscoveryDiscardDecision game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
) : g.decision.kind === 'greatMakerVote' || g.decision.kind === 'greatMakerRide' ? (
<GreatMakerDecision game={g} act={act} busy={transportBusy || !!me.autopilot} />
<GreatMakerDecision game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!me.autopilot} />
) : g.decision.kind === 'wormProtection' ? (
<>
<p className="muted">
+14
View File
@@ -0,0 +1,14 @@
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?.paused && !control?.joinLocked) return null;
return <section className="notice" aria-label="Room availability" aria-live="polite">
{control.paused ? <>
<h2 className="text-lg font-semibold">Room paused by an administrator</h2>
<p>Player decisions and automatic play are paused. Your game and pending choices are saved. An administrator can resume this room.</p>
<p>You can still read the table, use discussion, protect or recover your seat, and take back control from AI.</p>
</> : <h2 className="text-lg font-semibold">New joins are locked</h2>}
{control.joinLocked && <p>New players cannot join. Existing players can reconnect and recover their saved seats.</p>}
</section>;
}
+5 -2
View File
@@ -73,6 +73,7 @@ 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;
if (!own || own.bot) return null;
function store(next: SeatAiDelegateRequest | null) {
@@ -254,7 +255,8 @@ export function SeatAiDelegation({
)}
</section>
)}
{started && !own.autopilot && peers.length > 0 && (
{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`}>
Player allowed to start your AI
@@ -316,7 +318,7 @@ export function SeatAiDelegation({
{!started && <p>Permission can be granted after the game starts.</p>}
{own.autopilot && (
<p>
Your AI is already running. Use Take back control at the table
Your seat is assigned to AI{paused ? ', currently paused with the room' : ''}. Use Take back control at the table
before granting another permission.
</p>
)}
@@ -341,6 +343,7 @@ export function SeatAiDelegation({
className="min-h-11 whitespace-normal"
disabled={
locked ||
paused ||
!started ||
!!game.players.find((player) => player.id === grant.ownerId)
?.autopilot
+5 -2
View File
@@ -36,10 +36,10 @@ export function SeatAutopilot({ game, act, busy }: SeatAutopilotProps) {
AI autopilot · {own.autopilot}
</output>
<p className="m-0 text-sm leading-6 text-[#e0e4d8]">
AI is making this seat’s decisions using only its available
{game.roomControl?.paused ? 'AI decisions are paused with the room. On resume, AI uses only this seat’s available information, with about 1.5 seconds between decisions. You can take back control now.' : <>AI is making this seat’s decisions using only its available
information, with about 1.5 seconds between decisions. You keep the
seat and its recovery setup. Taking back control stops future AI
decisions; completed decisions remain in the game.
decisions; completed decisions remain in the game.</>}
</p>
</div>
<Button
@@ -56,6 +56,9 @@ export function SeatAutopilot({ game, act, busy }: SeatAutopilotProps) {
</section>
);
if (game.roomControl?.paused)
return <p className="notice">The room is paused. AI can be enabled after an administrator resumes play.</p>;
return (
<details className="notice min-w-0">
<summary className="min-h-11 cursor-pointer py-3 text-base font-semibold text-[#efd9a8] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ddbc77]">
+14 -7
View File
@@ -9,12 +9,14 @@ 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. */
export function projectAdminRoom(row: { code: string; version: number; updated_at: number; state: string }): AdminRoom {
type DirectoryRow = { code: string; version: number; updated_at: number; state: string; 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,
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 },
};
try {
const g = JSON.parse(row.state) as Game;
@@ -47,15 +49,16 @@ export function projectAdminRoom(row: { code: string; version: number; updated_a
}
export async function readAdminDirectory(database: D1Database, identity: AdminIdentity, search: URLSearchParams, now = Date.now()): Promise<AdminDirectory> {
const allowed = ['q', 'status', 'rules', 'sort', 'page'];
const allowed = ['q', 'status', 'rules', 'sort', 'page', 'availability'];
if ([...search.keys()].some(k => !allowed.includes(k) || search.getAll(k).length !== 1)) throw new AdminError('Invalid room filters.', 400);
const q = (search.get('q') ?? '').trim();
const status = search.get('status') ?? 'all';
const rules = search.get('rules') ?? 'all';
const sort = search.get('sort') ?? 'recent';
const rawPage = search.get('page') ?? '1';
const availability = search.get('availability') ?? 'all';
if (q.length > 80 || !['all', ...statuses].includes(status) || !['all', 'basic', 'advanced'].includes(rules) ||
!['recent', 'oldest', 'code'].includes(sort) || !/^[1-9][0-9]{0,5}$/.test(rawPage)) throw new AdminError('Invalid room filters.', 400);
!['recent', 'oldest', 'code'].includes(sort) || !['all', 'running', 'paused', 'locked'].includes(availability) || !/^[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";
@@ -76,15 +79,19 @@ 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 === 'locked') where.push('c.join_locked = 1');
const predicate = where.length ? 'WHERE ' + where.join(' AND ') : '';
const order = sort === 'code' ? 'code ASC' : `updated_at ${sort === 'oldest' ? 'ASC' : 'DESC'}, code ASC`;
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';
const results = await database.batch([
database.prepare(authority).bind(...credentials),
database.prepare(`SELECT COUNT(*) AS total FROM rooms ${predicate}`).bind(...args),
database.prepare(`SELECT code,version,updated_at,state FROM rooms ${predicate} ORDER BY ${order} LIMIT ? OFFSET ?`).bind(...args, pageSize, (page - 1) * pageSize),
database.prepare(`SELECT COUNT(*) AS total ${source} ${predicate}`).bind(...args),
database.prepare(`SELECT r.code,r.version,r.updated_at,r.state,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);
const rows = results[2].results as { code: string; version: number; updated_at: number; state: string }[];
const rows = results[2].results as DirectoryRow[];
return { rooms: rows.map(projectAdminRoom), total, page, pageSize };
}
+177
View File
@@ -0,0 +1,177 @@
import { AdminError, type AdminIdentity } from './admin-access';
import {
validAdminRoomControlInput,
type AdminRoomControlInput,
type RoomControl,
} from '../lib/room-control';
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 fields = `COALESCE(c.paused, 0) AS paused, COALESCE(c.join_locked, 0) AS join_locked,
COALESCE(c.revision, 0) AS revision, c.updated_at`;
type ControlRow = {
paused: number;
join_locked: number;
revision: number;
updated_at: number | null;
};
const project = (row: ControlRow): RoomControl => ({
paused: row.paused === 1,
joinLocked: row.join_locked === 1,
revision: row.revision,
updatedAt: row.updated_at,
});
function credentials(identity: AdminIdentity, now: number) {
if (
!identity ||
typeof identity.id !== 'string' ||
typeof identity.sessionHash !== 'string' ||
!/^[0-9a-f]{64}$/.test(identity.sessionHash)
)
throw new AdminError('Administrator sign-in required.', 401);
return [identity.sessionHash, identity.id, now];
}
function checkCode(code: string) {
if (
typeof code !== 'string' ||
code.length !== 8 ||
!/^[A-Z0-9]{8}$/.test(code)
)
throw new AdminError('Use an eight-character room code.', 400);
}
/** Read live room operations in the same transaction as administrator authority. */
export async function readAdminRoomControl(
database: D1Database,
identity: AdminIdentity,
code: string,
now = Date.now(),
): Promise<RoomControl> {
const args = credentials(identity, now);
checkCode(code);
const results = await database.batch([
database.prepare(authority).bind(...args),
database
.prepare(`SELECT ${fields} FROM rooms r LEFT JOIN room_controls c ON c.room_code = r.code
WHERE r.code = ? AND EXISTS (${authority})`)
.bind(code, ...args),
]);
if (!results[0].results.length)
throw new AdminError('Administrator sign-in required.', 401);
const row = results[1].results[0] as ControlRow | undefined;
if (!row) throw new AdminError('Room not found.', 404);
return project(row);
}
/** Receipt, audit, game-version fence and room flags commit or roll back together. */
export async function applyAdminRoomControl(
database: D1Database,
identity: AdminIdentity,
code: string,
input: AdminRoomControlInput,
now = Date.now(),
): Promise<RoomControl & { replayed: boolean }> {
const args = credentials(identity, now);
checkCode(code);
if (!validAdminRoomControlInput(input))
throw new AdminError(
'Provide room settings, a revision, a fresh operation identifier and a reason of 1–300 characters.',
400,
);
const reason = input.reason.trim();
const paused = Number(input.paused),
joinLocked = Number(input.joinLocked);
const results = await database.batch([
database.prepare(authority).bind(...args),
database
.prepare(`INSERT INTO admin_room_audit
(operation_id,actor_admin_id,room_code,reason,expected_revision,before_paused,before_join_locked,after_paused,after_join_locked,created_at)
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 admin_room_audit WHERE operation_id = ?)`)
.bind(
input.operationId,
identity.id,
reason,
input.expectedRevision,
paused,
joinLocked,
now,
code,
input.expectedRevision,
...args,
input.operationId,
),
// Pausing and join locks leave saved game JSON untouched. Resume only refreshes
// pacing for existing AI-controlled seats; it executes no game action.
database
.prepare(`UPDATE rooms SET version = version + 1,
state = CASE WHEN ? = 0 AND EXISTS (
SELECT 1 FROM admin_room_audit WHERE operation_id = ? AND before_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(paused, input.operationId, now + 1500, code),
database
.prepare(`INSERT INTO room_controls(room_code,paused,join_locked,revision,updated_at)
SELECT room_code,after_paused,after_join_locked,expected_revision + 1,created_at
FROM admin_room_audit WHERE operation_id = ? AND changes() = 1
ON CONFLICT(room_code) DO UPDATE SET paused = excluded.paused, join_locked = excluded.join_locked,
revision = excluded.revision, updated_at = excluded.updated_at`)
.bind(input.operationId),
database
.prepare(`SELECT ${fields}, a.room_code, a.expected_revision, a.after_paused, a.after_join_locked, a.reason
FROM admin_room_audit a JOIN rooms r ON r.code = a.room_code
LEFT JOIN room_controls c ON c.room_code = r.code
WHERE a.operation_id = ? AND a.actor_admin_id = ? AND EXISTS (${mutationAuthority})`)
.bind(input.operationId, identity.id, ...args),
database
.prepare(
`SELECT 1 FROM rooms WHERE code = ? AND EXISTS (${mutationAuthority})`,
)
.bind(code, ...args),
]);
const role = (results[0].results[0] as { role: string } | undefined)?.role;
if (!role) throw new AdminError('Administrator sign-in required.', 401);
if (role === 'viewer')
throw new AdminError(
'Your administrator role does not permit this action.',
403,
);
const row = results[4].results[0] as
| (ControlRow & {
room_code: string;
expected_revision: number;
after_paused: number;
after_join_locked: number;
reason: string;
})
| undefined;
if (row) {
if (
row.room_code !== code ||
row.expected_revision !== input.expectedRevision ||
row.after_paused !== paused ||
row.after_join_locked !== joinLocked ||
row.reason !== reason
)
throw new AdminError(
'This operation identifier is bound to different room settings.',
409,
);
return { ...project(row), replayed: results[1].meta.changes !== 1 };
}
if (!results[5].results.length) throw new AdminError('Room not found.', 404);
throw new AdminError(
'Room settings changed or the operation identifier was already used. Refresh before a new request.',
409,
);
}
+99 -29
View File
@@ -14,6 +14,7 @@ import {
import type { FactionId } from '@/game/catalog';
import type { Difficulty } from '@/game/bot-profiles';
import { runBots } from '@/game/bots';
import type { RoomControl } from '@/lib/room-control';
import {
validSeatAiGrantId,
validSetSeatAiDelegateInput,
@@ -74,7 +75,7 @@ type PreparedEntry = {
};
type EntryResult = {
token?: string;
view: ReturnType<typeof viewGame>;
view: GameView;
entryReceipt?: { operationId: string; replayed: boolean };
alreadySeated?: true;
};
@@ -238,15 +239,48 @@ export async function createRoom(
throw error;
}
}
type RoomControlRow = { room_control: string };
// SQLite constructs an explicit public allowlist, consistent with delegation views.
const roomControlProjection = `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`;
function publicRoomControl(row: RoomControlRow): RoomControl {
return JSON.parse(row.room_control) as RoomControl;
}
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;
}
async function readRoomSnapshot(code: string) {
const row = await db()
.prepare(`SELECT rooms.state, rooms.version, ${roomControlProjection}
FROM rooms LEFT JOIN room_controls c ON c.room_code = rooms.code WHERE rooms.code = ?`)
.bind(code)
.first<{ state: string; version: number } & RoomControlRow>();
if (!row) throw new RuleError('Room not found. Check your invite code.');
const g: Game = JSON.parse(row.state);
g.version = row.version;
return { game: g, control: publicRoomControl(row) };
}
export async function readRoom(code: string) {
const row = await db()
.prepare('SELECT state,version FROM rooms WHERE code = ?')
.bind(code)
.first<{ state: string; version: number }>();
if (!row) throw new RuleError('Room not found. Check your invite code.');
const g: Game = JSON.parse(row.state);
g.version = row.version;
return g;
const game: Game = JSON.parse(row.state);
game.version = row.version;
return game;
}
function requireUnpaused(control: RoomControl) {
if (control.paused)
throw new RuleError(
'An administrator paused this room. Game decisions and AI will continue after it resumes.',
);
}
export type SeatAuth = { playerId: string; tokenHash: string };
export async function authenticate(
@@ -294,7 +328,14 @@ export async function joinRoom(
}
}
await requireUnusedEntryToken(entry);
const g = await readRoom(code);
const { game: g, control } = await readRoomSnapshot(code);
if (control.paused || control.joinLocked)
throw new RoomEntryError(
control.paused
? 'An administrator paused this room. New players may join after it resumes.'
: 'New joins are locked by an administrator.',
control.paused ? 'ROOM_PAUSED' : 'ROOM_JOIN_LOCKED',
);
joinGame(g, player);
const old = g.version;
g.version++;
@@ -304,7 +345,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 = ?',
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? 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()
@@ -354,7 +395,10 @@ export async function act(
action: Action,
clock: RoomsClock = roomsClock,
) {
const current = await readRoom(code);
const { game: current, control } = await readRoomSnapshot(code);
const takingBackControl =
action?.type === 'setAutopilot' && action.difficulty === null;
if (!takingBackControl) requireUnpaused(control);
if (current.version !== version)
throw new RuleError(
'The table changed. Your view has refreshed; try again.',
@@ -375,13 +419,14 @@ 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 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 (? = 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),
now,
code,
version,
Number(takingBackControl),
code,
auth.playerId,
auth.tokenHash,
@@ -408,7 +453,7 @@ export async function act(
export async function readSeatView(code: string, auth: SeatAuth) {
const row = await db()
.prepare(
`SELECT rooms.state, rooms.version,
`SELECT rooms.state, rooms.version, ${roomControlProjection},
CASE WHEN COUNT(delegations.grant_id) = 0 THEN NULL ELSE
json_group_array(json_object(
'grantId', delegations.grant_id,
@@ -419,6 +464,7 @@ export async function readSeatView(code: string, auth: SeatAuth) {
'usedAt', delegations.used_at
)) END AS seat_ai_delegations
FROM rooms
LEFT JOIN room_controls c ON c.room_code = rooms.code
JOIN seats AS viewer ON viewer.room_code = rooms.code
LEFT JOIN seat_ai_delegations AS delegations
ON delegations.room_code = rooms.code
@@ -443,15 +489,18 @@ export async function readSeatView(code: string, auth: SeatAuth) {
GROUP BY rooms.code, rooms.state, rooms.version`,
)
.bind(code, auth.playerId, auth.tokenHash)
.first<{
state: string;
version: number;
seat_ai_delegations: string | null;
}>();
.first<
RoomControlRow & {
state: string;
version: number;
seat_ai_delegations: string | null;
}
>();
if (!row) throw new RuleError('Your seat could not be verified.');
const g: Game = JSON.parse(row.state);
g.version = row.version;
const view = viewGame(g, auth.playerId) as GameView;
attachRoomControl(view, row);
if (row.seat_ai_delegations) {
const delegations = JSON.parse(
row.seat_ai_delegations,
@@ -497,7 +546,8 @@ export async function continueRoomAutomatic(
clock: RoomsClock = roomsClock,
) {
for (let attempt = 0; attempt < 4; attempt++) {
const current = await readRoom(code);
const { game: current, control } = await readRoomSnapshot(code);
if (control.paused) return;
if (
!needsAutomaticRoomRecovery(current) ||
(!current.pendingTreacheryDiscard &&
@@ -512,7 +562,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 = ?',
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? 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();
@@ -530,14 +580,26 @@ export async function continueRoomBots(
clock: RoomsClock = roomsClock,
) {
for (let batch = 0; batch < batches; batch++) {
let current = await readRoom(code);
if (!current.botsPending || current.status === 'finished') return;
let snapshot = await readRoomSnapshot(code);
let current = snapshot.game;
if (
snapshot.control.paused ||
!current.botsPending ||
current.status === 'finished'
)
return;
const delay = (current.botNextActionAt ?? clock.now()) - clock.now();
if (delay > 0) {
await clock.sleep(Math.min(delay, ONLINE_BOT_INTERVAL_MS));
// A human may have taken control or another worker committed while asleep.
current = await readRoom(code);
if (!current.botsPending || current.status === 'finished') return;
snapshot = await readRoomSnapshot(code);
current = snapshot.game;
if (
snapshot.control.paused ||
!current.botsPending ||
current.status === 'finished'
)
return;
if ((current.botNextActionAt ?? 0) > clock.now()) continue;
}
// Offline simulations retain runBots' fast default. Online persistence makes
@@ -549,7 +611,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 = ?',
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? 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();
@@ -666,7 +728,8 @@ export async function setSeatAiDelegate(
);
return { view: await readSeatView(code, auth), replayed: true };
}
const current = await readRoom(code);
const { game: current, control } = await readRoomSnapshot(code);
requireUnpaused(control);
if (current.version !== version)
throw invalidDelegation(
'The table changed. Reconnect before saving this consent.',
@@ -707,6 +770,7 @@ export async function setSeatAiDelegate(
.prepare(
`UPDATE rooms SET version = version + 1, updated_at = ?
WHERE code = ? AND version = ?
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)
AND NOT EXISTS (SELECT 1 FROM seat_ai_delegations WHERE grant_id = ?)`,
@@ -897,7 +961,8 @@ export async function useSeatAiDelegate(
'This AI delegation has expired.',
'AI_DELEGATION_EXPIRED',
);
const current = await readRoom(code);
const { game: current, control } = await readRoomSnapshot(code);
requireUnpaused(control);
if (current.version !== version)
throw invalidDelegation(
'The table changed. Reconnect before activating this consent.',
@@ -935,6 +1000,7 @@ export async function useSeatAiDelegate(
.prepare(
`UPDATE rooms SET state = ?, version = version + 1, updated_at = ?
WHERE code = ? AND version = ?
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
JOIN seats AS owner_session
@@ -1103,10 +1169,10 @@ export async function recoverSeat(
// A replaced key or later rotation invalidates the receipt, including races after the first lookup.
const row = await db()
.prepare(
'SELECT rooms.state, rooms.version FROM rooms JOIN seats ON seats.room_code = rooms.code JOIN seat_recovery_keys AS keys ON keys.room_code = seats.room_code AND keys.player_id = seats.player_id WHERE rooms.code = ? AND seats.player_id = ? AND seats.token_hash = ? AND seats.revoked = 0 AND keys.recovery_hash = ? AND keys.current_operation_hash = ?',
`SELECT rooms.state, rooms.version, ${roomControlProjection} FROM rooms LEFT JOIN room_controls c ON c.room_code = rooms.code JOIN seats ON seats.room_code = rooms.code JOIN seat_recovery_keys AS keys ON keys.room_code = seats.room_code AND keys.player_id = seats.player_id WHERE rooms.code = ? AND seats.player_id = ? AND seats.token_hash = ? AND seats.revoked = 0 AND keys.recovery_hash = ? AND keys.current_operation_hash = ?`,
)
.bind(code, playerId, sessionHash, recoveryHash, operationHash)
.first<{ state: string; version: number }>();
.first<{ state: string; version: number } & RoomControlRow>();
if (!row)
throw new SeatControlError(
'This recovery receipt is no longer active.',
@@ -1114,8 +1180,10 @@ export async function recoverSeat(
);
const g: Game = JSON.parse(row.state);
g.version = row.version;
const view = viewGame(g, playerId) as GameView;
attachRoomControl(view, row);
return {
view: viewGame(g, playerId),
view,
token: input.newSessionToken as string,
recovered: true as const,
replayed: true,
@@ -1431,7 +1499,7 @@ async function replayClaimedHandover(
) {
const row = await db()
.prepare(
'SELECT rooms.state,rooms.version FROM rooms JOIN seat_handover_claim_receipts AS receipts ON receipts.room_code = rooms.code JOIN seats ON seats.room_code = receipts.room_code AND seats.player_id = receipts.player_id WHERE rooms.code = ? AND receipts.player_id = ? AND receipts.offer_hash = ? AND receipts.secret_hash = ? AND receipts.operation_hash = ? AND receipts.session_hash = ? AND seats.token_hash = ? AND seats.revoked = 0',
`SELECT rooms.state,rooms.version, ${roomControlProjection} FROM rooms LEFT JOIN room_controls c ON c.room_code = rooms.code JOIN seat_handover_claim_receipts AS receipts ON receipts.room_code = rooms.code JOIN seats ON seats.room_code = receipts.room_code AND seats.player_id = receipts.player_id WHERE rooms.code = ? AND receipts.player_id = ? AND receipts.offer_hash = ? AND receipts.secret_hash = ? AND receipts.operation_hash = ? AND receipts.session_hash = ? AND seats.token_hash = ? AND seats.revoked = 0`,
)
.bind(
code,
@@ -1442,12 +1510,14 @@ async function replayClaimedHandover(
sessionHash,
sessionHash,
)
.first<{ state: string; version: number }>();
.first<{ state: string; version: number } & RoomControlRow>();
if (!row) return null;
const game: Game = JSON.parse(row.state);
game.version = row.version;
const view = viewGame(game, playerId) as GameView;
attachRoomControl(view, row);
return {
view: viewGame(game, playerId),
view,
token: newSessionToken as string,
transferred: true as const,
replayed: true,
+40
View File
@@ -28,6 +28,46 @@ export const seats = sqliteTable('seats', {
playerId: text('player_id').notNull(),
});
export const roomControls = sqliteTable(
'room_controls',
{
roomCode: text('room_code')
.primaryKey()
.references(() => rooms.code, { onDelete: 'cascade' }),
paused: integer('paused').notNull().default(0),
joinLocked: integer('join_locked').notNull().default(0),
revision: integer('revision').notNull().default(0),
updatedAt: integer('updated_at').notNull(),
},
(table) => [
check('room_controls_paused', sql`${table.paused} IN (0, 1)`),
check('room_controls_join_locked', sql`${table.joinLocked} IN (0, 1)`),
check('room_controls_revision', sql`${table.revision} >= 0`),
],
);
// 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(
'admin_room_audit',
{
operationId: text('operation_id').primaryKey(),
actorAdminId: text('actor_admin_id').notNull(),
roomCode: text('room_code').notNull(),
action: text('action').notNull().default('room_control'),
reason: text('reason').notNull(),
expectedRevision: integer('expected_revision').notNull(),
beforePaused: integer('before_paused').notNull(),
beforeJoinLocked: integer('before_join_locked').notNull(),
afterPaused: integer('after_paused').notNull(),
afterJoinLocked: integer('after_join_locked').notNull(),
createdAt: integer('created_at').notNull(),
},
(table) => [
index('admin_room_audit_room').on(table.roomCode, table.createdAt),
],
);
export const seatRecoveryKeys = sqliteTable(
'seat_recovery_keys',
{
+9 -6
View File
@@ -10,9 +10,9 @@ recovery and persistence systems where suitable.
| Area | Required behavior | Stage |
| --- | --- | --- |
| 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, sort and paginate; show host, roster, modules, game change time and bounded decision ownership. Detailed setup/shared-window ownership and later lifecycle metadata 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 rooms with supported rules and human/AI seats, manage lobby settings and invitations, and assign a host through an explicit workflow. Preserve normal prerequisites and readiness rules. | Missing |
| Lifecycle and removal | Pause/resume, lock/unlock new joins, close/end, archive, restore and delete selected rooms. Offer recoverable removal and explicit permanent deletion with clear scope, consequences and safeguards for related records. | Missing |
| Lifecycle and removal | [Pause/resume and joining locks](ADMIN_ROOM_CONTROLS.md) have controls, durable audit, exact retries and player/AI enforcement. Close/end, archive, restore and recoverable/permanent removal 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 |
@@ -33,8 +33,9 @@ expiry. HTTPS uses Secure, HttpOnly, SameSite=Strict cookies scoped to the admin
API. Mutations require the exact configured origin and bounded JSON requests.
All three roles may read the current directory and sign out their own sessions.
Owner/operator mutation capabilities will be added with the room-management
slices. The role is checked from the live account, never accepted from a client.
Owners/operators may pause/resume rooms and lock/unlock new joins through
[Room controls](ADMIN_ROOM_CONTROLS.md). The role is checked from the live
account, never accepted from a client.
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.
@@ -98,10 +99,12 @@ node tools/verify-admin.mjs --url http://localhost:3000 --key-file /private/dune
```
The HTTP verifier requires a **dedicated QA administrator account** and explicitly
checks its ID before proceeding: it creates one named QA room and signs out all
checks its ID before proceeding: it creates a named QA room and signs out all
sessions of that QA account. Never supply a human operator's normal key. It checks
authentication, cookie properties, ordinary-host denial, directory privacy,
session revocation and unchanged game/seat continuation. Private reports retain
session revocation and unchanged game/seat continuation. Operator/owner QA keys
also create one additional room for joining locks, pause/takeback/resume and
account-bound request checks. Private reports retain
room IDs and safe outcomes, never keys/cookies. The container verifier provisions
its own disposable QA account and checks admin-session continuity across restart
and replacement, followed by the HTTP flow and HTTPS-proxy cookie behavior.
+83
View File
@@ -0,0 +1,83 @@
# Administrator pause and joining locks
The directory now offers **Room controls** and an **Availability** filter. Owners
and operators can pause/resume a selected room and lock/unlock new joins; viewers
can inspect its flags. This is a reversible lifecycle prototype. Closing,
archiving, removal, participant support and backups remain unfinished in
[the panel checklist](ADMIN_PANEL.md).
Identify the room, provide a short operational reason, warn active players if
necessary, and explicitly confirm the displayed scope before applying settings.
Reasons belong to the private audit and must not contain credentials or private
game information. Players see only public availability flags.
## Paused behavior
| Operation | While paused |
| --- | --- |
| Game decisions, lobby changes and new joins | Rejected without changing the game |
| Automatic normalization and native/human-owned AI | Stop before work and are fenced again at the database write, including sleeping workers |
| Reading, refresh, discussion and component inspection | Remain available |
| Seat protection, recovery and voluntary handover | Remain available with existing ownership/revocation safeguards |
| Taking back control and revoking AI permission | Remain available |
| New AI permission or activation | Rejected; completed saved requests can still be confirmed |
A joining lock alone prevents new seats. Existing seats can reconnect, recover
or confirm an already completed join. Neither flag removes players, cards,
forces or pending choices. Resume preserves the game and gives existing AI seats
a fresh 1.5-second pacing deadline; it does not settle a player's decision.
## Persistence, authority and retries
The additive [migration](../drizzle/0009_admin_room_controls.sql) stores flags in
`room_controls`, separate from game JSON. A control change, room-version fence
and durable `admin_room_audit` receipt commit together. The audit records actor,
room, time, reason, expected revision and previous/new flags; it contains no
cards, credentials or raw game state. Audit browsing remains unfinished.
Untouched rooms retain their existing player-view shape; an absent control field
means running with joins open. After the first control change, player views keep
the public flags and revision, including after resume. Administrator reads always
return explicit defaults. Operational settings never enter saved game JSON.
The [HTTP endpoint](../app/api/admin/rooms/[code]/control/route.ts) requires the
configured origin, administrator authentication and owner/operator permissions
for writes. `X-Dune-Admin-Id` binds a write to the displayed account, preventing
an old tab from applying that account's saved intent after another account signs
in. SQL rechecks live session, role and generation within the transaction.
Each request has an operation ID, expected control revision, desired flags and
reason. Exact retries confirm the receipt and return current settings without
reapplying old flags. Conflicting revisions or reused IDs cannot overwrite a
later change. The [client](../components/admin-room-controls.tsx) saves the exact
request in account/room-scoped tab storage before sending. Uncertain responses
keep explicit retry controls across refresh. Authentication failures retain the
original account's pending request for confirmation after sign-in. There is no
automatic mutation retry. Deliberately discarding an unreadable local record
does not cancel a server change.
## Verification and remaining gates
Focused in-memory tests execute production SQL for roles, revoked sessions,
stale/conflicting requests, replay, rollback, joined-seat receipts, worker/action
races, pause during AI sleep, recovery/handover and resume pacing. UI tests cover
pause guidance, disabled AI-start controls, available takeback, account-scoped
retry storage and directory filters.
Authenticated local HTTP acceptance uses dedicated QA rooms. A temporary local
transport loses one successful control response: the browser reads the paused
state, refresh keeps the exact request, and explicit retry confirms one audit
row. Player controls disable on pause and return on resume. Mobile controls were
inspected at 390 pixels with 16-pixel labels and a 44-pixel apply button. Existing
games were not reset or used for these operations.
`tools/verify-admin.mjs` creates one room for access checks. A dedicated operator
or owner QA key also creates one room for account binding, joining locks,
pause/takeback/resume and saved-seat checks. Cleanup restores that QA room's flags
and stops its owning human's autopilot. Original failures and cleanup failures
are recorded separately. Never pass a human operator's key. The isolated container
verifier provisions its own QA operator and runs this flow.
Source checks, container checks and deployed acceptance are separate evidence.
Production administrator provisioning still needs explicit access confirmation;
local success does not close that deployed gate.
+15 -1
View File
@@ -6,6 +6,20 @@ Expansion starts and full rules/publication acceptance remain gated.**
## Current checkpoint and work
Current implementation adds [administrator pause/resume and joining locks](ADMIN_ROOM_CONTROLS.md):
usable controls, availability filters, player guidance, stopped human/AI
decisions, durable audit and account-bound exact retries. Recovery, discussion
and AI takeback remain available. Focused persistence, authenticated local HTTP
and desktop/mobile browser checks accompany the checkpoint. Remaining lifecycle
and full-admin requirements stay open; authenticated production acceptance waits
for the required QA-access confirmation.
Checkpoint `f29d056` was pushed and deployed to the NAS instance. Readable
create/join progress and Continue notices passed actual HTTPS browser acceptance,
including mobile and saved-seat refresh; existing production records survived
the update. Administrator sign-in renders closed by default. Authenticated admin
production checks remain pending provisioning approval.
The [readable-notice fix](READABLE_NOTICES.md) replaces the flashing recovery
screen during ordinary room creation/join with concise progress. Actual recovery
instructions stay until resolved, and completed-action text waits for a local
@@ -33,7 +47,7 @@ relevant real player/admin flows in dedicated QA rooms, and record deployed
results separately from local checks. See [production verification](VERIFICATION_WORKFLOW.md#verify-the-deployed-application).
This requirements update does not claim a new deployed test result.
Latest delivered checkpoint: `0f79f28`, [forced Harkonnen exchange return](HARKONNEN_EXCHANGE.md).
Earlier completed rules checkpoint: `0f79f28`, [forced Harkonnen exchange return](HARKONNEN_EXCHANGE.md).
Types, lint, **5,150 offline tests**, **49 HTTP tests** and the production build
passed. One complete Advanced sample had no rejected actions across 158 actions
and four JSON restores. Browser inspection and exact refresh were checked;
+11
View File
@@ -1,5 +1,16 @@
# Dune implementation status
## 22 September 2026 — Administrator room pause and joining locks
Added reversible pause/resume and joining-lock controls, availability filters
and public player notices. SQL fences cover joins, player actions, automatic
normalization and AI workers, including in-flight races. Saved-seat recovery,
handover, discussion and AI takeback remain available. Control changes and exact
receipts are audited atomically, with expected account binding protecting stale
administrator tabs. Local HTTP/browser evidence includes a lost successful
response, refresh, one-record replay and resume. See [scope and verification](ADMIN_ROOM_CONTROLS.md).
Full lifecycle, admin tools and authenticated deployed acceptance remain open.
## 22 September 2026 — Readable room entry and completed-action text
Normal create/join now shows concise pending text while the first request runs;
+26
View File
@@ -0,0 +1,26 @@
CREATE TABLE `admin_room_audit` (
`operation_id` text PRIMARY KEY NOT NULL,
`actor_admin_id` text NOT NULL,
`room_code` text NOT NULL,
`action` text DEFAULT 'room_control' NOT NULL,
`reason` text NOT NULL,
`expected_revision` integer NOT NULL,
`before_paused` integer NOT NULL,
`before_join_locked` integer NOT NULL,
`after_paused` integer NOT NULL,
`after_join_locked` integer NOT NULL,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `admin_room_audit_room` ON `admin_room_audit` (`room_code`,`created_at`);--> statement-breakpoint
CREATE TABLE `room_controls` (
`room_code` text PRIMARY KEY NOT NULL,
`paused` integer DEFAULT 0 NOT NULL,
`join_locked` integer DEFAULT 0 NOT NULL,
`revision` integer DEFAULT 0 NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`room_code`) REFERENCES `rooms`(`code`) ON UPDATE no action ON DELETE cascade,
CONSTRAINT "room_controls_paused" CHECK("room_controls"."paused" IN (0, 1)),
CONSTRAINT "room_controls_join_locked" CHECK("room_controls"."join_locked" IN (0, 1)),
CONSTRAINT "room_controls_revision" CHECK("room_controls"."revision" >= 0)
);
File diff suppressed because it is too large. Load diff
+7
View File
@@ -64,6 +64,13 @@
"when": 1790032690330,
"tag": "0008_admin_access",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1790035625313,
"tag": "0009_admin_room_controls",
"breakpoints": true
}
]
}
+1
View File
@@ -25725,4 +25725,5 @@ export function viewGame(state: Game, id: string) {
}
export type GameView = ReturnType<typeof viewGame> & {
seatAiDelegations?: import('../lib/seat-ai-delegation').SeatAiDelegationView[];
roomControl?: import('../lib/room-control').RoomControl;
};
+2
View File
@@ -1,4 +1,5 @@
/** Public operational metadata only; never a player view or a saved game. */
import type { RoomControl } from './room-control';
export type AdminRoom = {
code: string;
version: number;
@@ -13,6 +14,7 @@ export type AdminRoom = {
turn: number | null;
phase: string;
pending: { label: string; owners: string[] };
control: RoomControl;
};
export type AdminDirectory = {
rooms: AdminRoom[];
+41
View File
@@ -0,0 +1,41 @@
import { randomId } from './random-id';
import { ClientRequestError, requestMayHaveCompleted } from './client-request';
import { validAdminRoomControlInput, type AdminRoomControlInput, type RoomControl } from './room-control';
type Storage = Pick<globalThis.Storage, 'getItem' | 'setItem' | 'removeItem'>;
const storageKey = (account: string, room: string) => `dune.admin-room-control.v1:${account}:${room}`;
/** Denial now cannot establish whether an earlier attempt committed under the original session. */
export function retainAdminRoomRequest(error: unknown): boolean {
return requestMayHaveCompleted(error) || (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0));
}
export function newAdminRoomRequest(control: RoomControl, paused: boolean, joinLocked: boolean, reason: string): AdminRoomControlInput {
const input = { operationId: randomId(), expectedRevision: control.revision, paused, joinLocked, reason: reason.trim() };
if (!validAdminRoomControlInput(input)) throw new Error('Enter a reason of 1–300 characters on one line.');
return input;
}
export function readAdminRoomRequest(storage: Storage, account: string, room: string): AdminRoomControlInput | null {
const raw = storage.getItem(storageKey(account, room));
if (raw === null) return null;
let value: unknown;
try { value = raw.length <= 2048 ? JSON.parse(raw) : null; } catch { value = null; }
if (!validAdminRoomControlInput(value)) throw new Error('This tab’s saved room-settings request is unreadable. Inspect the current settings before discarding the local retry record.');
return value;
}
/** Save before sending; never overwrite a different unresolved request. */
export function saveAdminRoomRequest(storage: Storage, account: string, room: string, input: AdminRoomControlInput) {
if (!validAdminRoomControlInput(input)) throw new Error('Invalid room-settings request.');
const previous = readAdminRoomRequest(storage, account, room);
const encoded = JSON.stringify(input);
if (previous && JSON.stringify(previous) !== encoded) throw new Error('Resolve the saved room-settings request before sending a new one.');
storage.setItem(storageKey(account, room), encoded);
if (storage.getItem(storageKey(account, room)) !== encoded) throw new Error('Could not preserve the room-settings request in this tab. Nothing was sent.');
}
export function clearAdminRoomRequest(storage: Storage, account: string, room: string) {
storage.removeItem(storageKey(account, room));
if (storage.getItem(storageKey(account, room)) !== null) throw new Error('The saved retry record could not be cleared.');
}
+57
View File
@@ -0,0 +1,57 @@
/** Public room operations only. Reasons and administrator identities stay private. */
export type RoomControl = {
paused: boolean;
joinLocked: boolean;
revision: number;
updatedAt: number | null;
};
export const DEFAULT_ROOM_CONTROL: Readonly<RoomControl> = Object.freeze({
paused: false,
joinLocked: false,
revision: 0,
updatedAt: null,
});
export type AdminRoomControlInput = {
operationId: string;
expectedRevision: number;
paused: boolean;
joinLocked: boolean;
reason: string;
};
export function validAdminRoomControlInput(
value: unknown,
): value is AdminRoomControlInput {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const input = value as Record<string, unknown>;
return (
Object.keys(input).length === 5 &&
Object.keys(input).every((key) =>
[
'operationId',
'expectedRevision',
'paused',
'joinLocked',
'reason',
].includes(key),
) &&
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,
) &&
Number.isSafeInteger(input.expectedRevision) &&
Number(input.expectedRevision) >= 0 &&
Number(input.expectedRevision) < Number.MAX_SAFE_INTEGER &&
typeof input.paused === 'boolean' &&
typeof input.joinLocked === 'boolean' &&
typeof input.reason === 'string' &&
input.reason.trim().length > 0 &&
input.reason.length <= 300 &&
input.reason
.split('')
.every((char) => char.charCodeAt(0) >= 32 && char.charCodeAt(0) !== 127)
);
}
+9 -1
View File
@@ -11,13 +11,21 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden
assert.equal(created.status, 201);
const cookie = created.headers.get('set-cookie')?.split(';')[0];
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']) {
for (const path of ['/api/admin/session', '/api/admin/rooms', `/api/admin/rooms/${room}/control`]) {
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');
assert.deepEqual(Object.keys(await result.json()), ['error']);
}
const write = await fetch(`${base}/api/admin/rooms/${room}/control`, {
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' },
body: JSON.stringify({ operationId: crypto.randomUUID(), expectedRevision: 0, paused: true, joinLocked: true, reason: 'Denied QA request' }),
signal: AbortSignal.timeout(15000),
});
assert.equal(write.status, 401);
assert.deepEqual(Object.keys(await write.json()), ['error']);
}
});
void test('admin HTTP rejects login CSRF, oversized bodies and invalid credentials without setting sessions', async () => {
+15
View File
@@ -66,3 +66,18 @@ void test('admin directory rechecks authority inside its snapshot after concurre
await assert.rejects(pending, (e: unknown) => e instanceof AdminError && e.status === 401);
} finally { f.sqlite.close(); }
});
void test('directory projects and filters operational flags without private audit reasons', async () => {
const f = await fixture();
try {
insert(f.sqlite, 'ROOMAAAA', state()); insert(f.sqlite, 'ROOMBBBB', state());
f.sqlite.prepare('INSERT INTO room_controls(room_code,paused,join_locked,revision,updated_at) VALUES (?,1,1,2,999)').run('ROOMAAAA');
const read = (query: string) => readAdminDirectory(f.database, f.identity, new URLSearchParams(query));
const paused = await read('availability=paused');
assert.equal(paused.total, 1);
assert.deepEqual(paused.rooms[0].control, { paused: true, joinLocked: true, revision: 2, updatedAt: 999 });
assert.equal((await read('availability=running')).rooms[0].code, 'ROOMBBBB');
assert.equal((await read('availability=locked')).rooms[0].code, 'ROOMAAAA');
await assert.rejects(read('availability=unsupported'), (e: unknown) => e instanceof AdminError && e.status === 400);
} finally { f.sqlite.close(); }
});
+941
View File
@@ -0,0 +1,941 @@
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, pauseAdminBatch } from './admin-access-fixture';
import {
adminLogin,
requireAdmin,
adminLogoutAll,
AdminError,
type AdminRole,
} from '../db/admin-access';
import {
applyAdminRoomControl,
readAdminRoomControl,
} from '../db/admin-lifecycle';
import {
DEFAULT_ROOM_CONTROL,
validAdminRoomControlInput,
type AdminRoomControlInput,
} from '../lib/room-control';
import * as engine from '../game/engine';
import * as bots from '../game/bots';
import { baseDeck } from '../game/cards';
import * as seatAiDelegation from '../lib/seat-ai-delegation';
import type * as Rooms from '../db/rooms';
const secret = () => randomBytes(32).toString('hex');
const entry = () => ({ operationId: randomUUID(), sessionToken: secret() });
const change = (
expectedRevision = 0,
paused = true,
joinLocked = false,
): AdminRoomControlInput => ({
operationId: randomUUID(),
expectedRevision,
paused,
joinLocked,
reason: 'Dedicated QA room maintenance',
});
const status = (wanted: number) => (error: unknown) =>
error instanceof AdminError && error.status === wanted;
const fakeClock = (start = 10000) => {
let now = start;
const sleeps: number[] = [];
return {
now: () => now,
advance: (ms: number) => {
now += ms;
},
sleeps,
sleep: async (ms: number) => {
sleeps.push(ms);
now += ms;
},
};
};
async function fixture(role: AdminRole = 'operator', started = false) {
const store = adminStore();
assert.ok(store.sqlite instanceof DatabaseSync);
const account = store.provision(role);
const login = await adminLogin(store.database, account.key);
const identity = await requireAdmin(store.database, login.token);
const calls = { bots: 0, normalization: 0 };
const hooks: { beforeRun?: (sql: string) => Promise<void> } = {};
const database = {
batch: (statements: D1PreparedStatement[]) =>
store.database.batch(statements),
prepare(sql: string) {
const statement = store.database.prepare(sql);
const original = statement.run.bind(statement);
statement.run = async <T>() => {
await hooks.beforeRun?.(sql);
return original<T>();
};
return statement;
},
} as D1Database;
function loadRooms() {
const exports = {};
runInNewContext(
ts.transpileModule(
readFileSync(new URL('../db/rooms.ts', 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,
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 seatAiDelegation;
throw new Error('Unexpected dependency ' + name);
},
},
);
return exports as typeof Rooms;
}
const rooms = loadRooms();
const ownerEntry = entry();
const owner = await rooms.createRoom(
'Lifecycle QA owner',
'atreides',
false,
[],
ownerEntry,
);
const code = owner.view.code;
const auth = await rooms.authenticate(code, owner.token);
const guestEntry = entry();
const guest = await rooms.joinRoom(
code,
'Lifecycle QA guest',
'emperor',
guestEntry,
);
const guestAuth = await rooms.authenticate(code, guest.token!);
const clock = fakeClock();
if (started) {
for (const seat of [auth, guestAuth])
await rooms.act(
code,
seat,
(await rooms.readRoom(code)).version,
{ type: 'ready' },
clock,
);
await rooms.act(
code,
auth,
(await rooms.readRoom(code)).version,
{ type: 'start' },
clock,
);
}
const apply = (input = change(), now = clock.now()) =>
applyAdminRoomControl(database, identity, code, input, now);
const read = () =>
readAdminRoomControl(database, identity, code, clock.now());
const row = () =>
store.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(code)!;
const version = () => Number(row().version);
return {
...store,
database,
account,
login,
identity,
calls,
runHooks: hooks,
rooms,
restart: loadRooms,
ownerEntry,
owner,
guestEntry,
guest,
auth,
guestAuth,
code,
clock,
apply,
read,
row,
version,
};
}
function pauseRun(
f: Awaited<ReturnType<typeof fixture>>,
match = (sql: string) => sql.startsWith('UPDATE rooms SET state'),
) {
let enter!: () => void, release!: () => void;
const waiting = new Promise<void>((resolve) => {
enter = resolve;
});
const gate = new Promise<void>((resolve) => {
release = resolve;
});
f.runHooks.beforeRun = async (sql) => {
if (!match(sql)) return;
delete f.runHooks.beforeRun;
enter();
await gate;
};
return { waiting, release };
}
const count = (f: Awaited<ReturnType<typeof fixture>>, table: string) =>
Number(f.sqlite.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get()!.n);
void test('room controls default for existing/new rooms and stay outside saved Game and private views', async () => {
const f = await fixture();
try {
assert.deepEqual(await f.read(), DEFAULT_ROOM_CONTROL);
assert.equal(Object.hasOwn(f.owner.view, 'roomControl'), false);
assert.equal(
count(f, 'room_controls'),
0,
'Reads do not backfill or mutate existing saves',
);
assert.equal(
Object.hasOwn(await f.rooms.readRoom(f.code), 'roomControl'),
false,
);
const before = f.row();
const applied = await f.apply();
assert.deepEqual(applied, {
paused: true,
joinLocked: false,
revision: 1,
updatedAt: 10000,
replayed: false,
});
assert.equal(f.row().state, before.state);
assert.equal(f.row().updated_at, before.updated_at);
assert.equal(f.version(), Number(before.version) + 1);
const view = await f.restart().readSeatView(f.code, f.auth);
assert.deepEqual(view.roomControl, {
paused: true,
joinLocked: false,
revision: 1,
updatedAt: 10000,
});
const publicControl = JSON.stringify(view.roomControl);
for (const privateValue of [
f.identity.id,
f.identity.sessionHash,
'maintenance',
'actor',
'reason',
])
assert.equal(publicControl.includes(privateValue), false);
await f.apply(change(1, false, false));
const resumed = await f.restart().readSeatView(f.code, f.auth);
assert.deepEqual(resumed.roomControl, {
paused: false,
joinLocked: false,
revision: 2,
updatedAt: 10000,
});
assert.equal(
JSON.stringify(view).includes(f.guestEntry.sessionToken),
false,
);
} finally {
f.sqlite.close();
}
});
void test('room control receipts replay exactly without reverting later settings; mismatched reuse and stale new operations reject', async () => {
const f = await fixture();
try {
const input = change();
await f.apply(input);
const first = f.row();
assert.equal((await f.apply(input)).replayed, true);
assert.deepEqual(f.row(), first);
assert.equal(count(f, 'admin_room_audit'), 1);
for (const patch of [
{ paused: false },
{ joinLocked: true },
{ reason: 'Different reason' },
{ expectedRevision: 1 },
])
await assert.rejects(f.apply({ ...input, ...patch }), status(409));
await assert.rejects(f.apply(change(0)), status(409));
await f.apply(change(1, false, true), 20000);
const current = f.row();
assert.deepEqual(await f.apply(input), {
paused: false,
joinLocked: true,
revision: 2,
updatedAt: 20000,
replayed: true,
});
assert.deepEqual(f.row(), current);
assert.equal(count(f, 'admin_room_audit'), 2);
const audit = f.sqlite
.prepare('SELECT * FROM admin_room_audit ORDER BY created_at')
.all();
assert.equal(audit[0].actor_admin_id, f.identity.id);
assert.equal(audit[0].room_code, f.code);
assert.equal(audit[0].before_paused, 0);
assert.equal(audit[0].after_paused, 1);
assert.equal(audit[1].before_paused, 1);
assert.equal(audit[1].after_paused, 0);
assert.equal(
JSON.stringify(audit).includes(f.ownerEntry.sessionToken),
false,
);
assert.equal(JSON.stringify(audit).includes(f.identity.sessionHash), false);
const other = f.provision('operator');
const login = await adminLogin(f.database, other.key);
const identity = await requireAdmin(f.database, login.token);
await assert.rejects(
applyAdminRoomControl(f.database, identity, f.code, input),
status(409),
);
} finally {
f.sqlite.close();
}
});
void test('room mutations use live owner/operator authority; viewers may read and cannot spoof the identity role', async () => {
for (const role of ['owner', 'operator', 'viewer'] as const) {
const f = await fixture(role);
try {
assert.deepEqual(await f.read(), DEFAULT_ROOM_CONTROL);
const mutated = applyAdminRoomControl(
f.database,
{ ...f.identity, role: 'owner' },
f.code,
change(),
);
if (role === 'viewer') {
await assert.rejects(mutated, status(403));
assert.equal(count(f, 'admin_room_audit'), 0);
} else assert.equal((await mutated).paused, true);
await assert.rejects(
applyAdminRoomControl(
f.database,
{ ...f.identity, sessionHash: secret() },
f.code,
change(),
),
status(401),
);
} finally {
f.sqlite.close();
}
}
});
void test('room read/write authorization is fenced against logout, disable, role demotion and expiry at the transaction', async () => {
for (const mode of [
'logout',
'disable',
'demote',
'expire',
'read',
] as const) {
const f = await fixture();
try {
const before = f.row();
const gate = pauseAdminBatch(f);
const pending = mode === 'read' ? f.read() : f.apply();
await gate.waiting;
if (mode === 'disable')
f.sqlite
.prepare('UPDATE admin_accounts SET enabled=0 WHERE id=?')
.run(f.identity.id);
else if (mode === 'demote')
f.sqlite
.prepare("UPDATE admin_accounts SET role='viewer' WHERE id=?")
.run(f.identity.id);
else if (mode === 'expire')
f.sqlite
.prepare('UPDATE admin_sessions SET expires_at=1 WHERE token_hash=?')
.run(f.identity.sessionHash);
else await adminLogoutAll(f.database, f.identity);
gate.release();
await assert.rejects(pending, status(mode === 'demote' ? 403 : 401));
assert.deepEqual(f.row(), before);
assert.equal(count(f, 'admin_room_audit'), 0);
} finally {
f.sqlite.close();
}
}
});
void test('concurrent same revision operations have one winner and exact queued retry gets one receipt', async () => {
const f = await fixture();
try {
const same = change();
const gate = pauseAdminBatch(f);
const waiting = f.apply(same);
await gate.waiting;
await f.apply(same);
gate.release();
assert.equal((await waiting).replayed, true);
assert.equal(count(f, 'admin_room_audit'), 1);
const gate2 = pauseAdminBatch(f);
const stale = f.apply(change(1, false));
await gate2.waiting;
await f.apply(change(1, true, true));
gate2.release();
await assert.rejects(stale, status(409));
assert.equal(count(f, 'admin_room_audit'), 2);
} finally {
f.sqlite.close();
}
});
void test('failed control persistence rolls back receipt, game fence and audit; audit survives deliberate record removal', async () => {
const f = await fixture();
try {
const before = f.row();
f.sqlite.exec(
"CREATE TRIGGER fail_control BEFORE INSERT ON room_controls BEGIN SELECT RAISE(ABORT,'test storage failure'); END;",
);
await assert.rejects(f.apply(), /test storage failure/);
assert.deepEqual(f.row(), before);
assert.equal(count(f, 'admin_room_audit'), 0);
f.sqlite.exec('DROP TRIGGER fail_control');
await f.apply();
f.sqlite.prepare('DELETE FROM rooms WHERE code=?').run(f.code);
assert.equal(count(f, 'room_controls'), 0);
assert.equal(count(f, 'admin_room_audit'), 1);
} finally {
f.sqlite.close();
}
});
void test('bounded mutation inputs reject malformed UUIDs, revisions, fields, reason controls and missing rooms', async () => {
const f = await fixture();
try {
for (const patch of [
{ operationId: randomUUID() + '\n' },
{ expectedRevision: -1 },
{ expectedRevision: 0.5 },
{ expectedRevision: Number.MAX_SAFE_INTEGER },
{ paused: 'true' },
{ reason: ' ' },
{ reason: 'a'.repeat(301) },
{ reason: 'line\nbreak' },
{ extra: true },
]) {
const input = { ...change(), ...patch };
assert.equal(validAdminRoomControlInput(input), false);
await assert.rejects(
f.apply(input as AdminRoomControlInput),
status(400),
);
}
await assert.rejects(
applyAdminRoomControl(f.database, f.identity, f.code + '\n', change()),
status(400),
);
await assert.rejects(
applyAdminRoomControl(f.database, f.identity, 'MISSING1', change()),
status(404),
);
await assert.rejects(
readAdminRoomControl(f.database, f.identity, 'MISSING1'),
status(404),
);
assert.equal(count(f, 'admin_room_audit'), 0);
} finally {
f.sqlite.close();
}
});
void test('pause and join locks block new seats but preserve completed entry retries and current-seat restoration', async () => {
const f = await fixture();
try {
await f.apply(change(0, false, true));
const before = f.row();
await assert.rejects(
f.rooms.joinRoom(f.code, 'New seat', 'guild', entry()),
/locked/i,
);
const retry = await f.rooms.joinRoom(
f.code,
'Lifecycle QA guest',
'emperor',
f.guestEntry,
);
assert.equal(retry.entryReceipt?.replayed, true);
assert.equal(retry.token, f.guest.token);
assert.equal(
(
await f.rooms.joinRoom(
f.code,
'New name',
'guild',
undefined,
f.guestAuth,
)
).alreadySeated,
true,
);
assert.deepEqual(f.row(), before);
await f.apply(change(1, true, false));
await assert.rejects(
f.rooms.joinRoom(f.code, 'New seat', 'guild', entry()),
/paused/i,
);
const replay = await f.rooms.createRoom(
'Lifecycle QA owner',
'atreides',
false,
[],
f.ownerEntry,
);
assert.equal(replay.view.code, f.code);
assert.equal(replay.view.roomControl?.paused, true);
assert.equal(count(f, 'seats'), 2);
} finally {
f.sqlite.close();
}
});
void test('pause/lock racing a prepared join rejects its room, seat and receipt write together', async () => {
for (const paused of [false, true]) {
const f = await fixture();
try {
const gate = pauseAdminBatch(f);
const joining = f.rooms.joinRoom(
f.code,
'Racing new seat',
'guild',
entry(),
);
await gate.waiting;
await f.apply(change(0, paused, !paused));
const state = f.row();
gate.release();
await assert.rejects(joining, /table changed/i);
assert.deepEqual(f.row(), state);
assert.equal(count(f, 'seats'), 2);
assert.equal(count(f, 'room_entry_receipts'), 2);
} finally {
f.sqlite.close();
}
}
});
void test('pause wins against in-flight human actions and later gameplay without modifying saved choices', async () => {
const f = await fixture();
try {
const gate = pauseRun(f);
const pending = f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'ready' },
f.clock,
);
await gate.waiting;
await f.apply();
const before = f.row();
gate.release();
await assert.rejects(pending, /another action/i);
await assert.rejects(
f.rooms.act(f.code, f.auth, f.version(), { type: 'ready' }, f.clock),
/paused/i,
);
await assert.rejects(
f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'advanceBots' },
f.clock,
),
/paused/i,
);
assert.deepEqual(f.row(), before);
await f.apply(change(1, false));
const view = await f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'ready' },
f.clock,
);
assert.equal(
view.players.find((p) => p.id === f.auth.playerId)!.ready,
true,
);
} finally {
f.sqlite.close();
}
});
void test('paused AI exits before computation or sleep, persists across restart, and resumes at a fresh deadline', async () => {
const f = await fixture('operator', true);
try {
await f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'setAutopilot', difficulty: 'Hard' },
f.clock,
);
const before = f.row().state;
await f.apply();
assert.equal(f.row().state, before);
f.clock.advance(50000);
const snapshot = f.row();
await f.restart().continueRoomBots(f.code, 4, f.clock);
await f.restart().continueRoomAutomatic(f.code, f.clock);
assert.deepEqual(f.row(), snapshot);
assert.deepEqual(f.calls, { bots: 0, normalization: 0 });
assert.deepEqual(f.clock.sleeps, []);
await f.apply(change(1, false));
const resumed = await f.rooms.readRoom(f.code);
assert.equal(resumed.botNextActionAt, f.clock.now() + 1500);
assert.deepEqual(
{ ...resumed, version: 0, botNextActionAt: 0 },
{ ...JSON.parse(String(before)), version: 0, botNextActionAt: 0 },
);
await f.rooms.continueRoomBots(f.code, 1, f.clock);
assert.deepEqual(f.clock.sleeps, [1500]);
assert.equal(f.calls.bots, 1);
assert.equal(
(await f.rooms.readRoom(f.code)).players[0].traitors.length,
1,
);
} finally {
f.sqlite.close();
}
});
void test('worker asleep when paused rereads operational authority before computing or persisting', async () => {
const f = await fixture('operator', true);
try {
await f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'setAutopilot', difficulty: 'Hard' },
f.clock,
);
let enter!: () => void, release!: () => void;
const sleeping = new Promise<void>((resolve) => {
enter = resolve;
});
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const clock = {
now: f.clock.now,
sleep: async (ms: number) => {
enter();
await gate;
f.clock.advance(ms);
},
};
const running = f.rooms.continueRoomBots(f.code, 1, clock);
await sleeping;
await f.apply();
const before = f.row();
release();
await running;
assert.deepEqual(f.row(), before);
assert.equal(f.calls.bots, 0);
} finally {
f.sqlite.close();
}
});
void test('pause after AI computation but before SQL CAS discards the computed action', async () => {
const f = await fixture('operator', true);
try {
await f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'setAutopilot', difficulty: 'Hard' },
f.clock,
);
f.clock.advance(1500);
const gate = pauseRun(f);
const running = f.rooms.continueRoomBots(f.code, 2, f.clock);
await gate.waiting;
assert.equal(f.calls.bots, 1);
await f.apply();
const before = f.row();
gate.release();
await running;
assert.deepEqual(f.row(), before);
assert.equal(f.calls.bots, 1);
} finally {
f.sqlite.close();
}
});
async function marketFixture() {
const f = await fixture();
const g = await f.rooms.readRoom(f.code);
g.players[0] = engine.newPlayer(f.auth.playerId, 'CHOAM', 'choam');
g.players.push(engine.newPlayer('guild', 'Guild', 'guild'));
g.status = 'playing';
g.phase = 3;
g.turn = 2;
g.order = g.players.map((p) => p.id);
g.active = f.auth.playerId;
for (const p of g.players) {
p.hand = [];
p.spice = 10;
}
g.auction = null;
g.response = null;
g.deck = baseDeck();
g.discard = [];
g.choamMarket = { owner: f.auth.playerId, resume: 'phase', blocked: [] };
g.decision = { kind: 'choamMarket', player: f.auth.playerId };
g.aid = { [f.auth.playerId]: { recipient: 'emperor', amount: 2 } };
f.sqlite
.prepare('UPDATE rooms SET state=? WHERE code=?')
.run(JSON.stringify(g), f.code);
return f;
}
void test('automatic normalization stays frozen while paused and loses its commit race without double settlement', async () => {
const f = await marketFixture();
try {
const gate = pauseRun(f);
const running = f.rooms.continueRoomAutomatic(f.code, f.clock);
await gate.waiting;
await f.apply();
const before = f.row();
gate.release();
await running;
assert.deepEqual(f.row(), before);
assert.equal(f.calls.normalization, 1);
await f.rooms.continueRoomAutomatic(f.code, f.clock);
assert.equal(f.calls.normalization, 1);
await f.apply(change(1, false));
await f.rooms.continueRoomAutomatic(f.code, f.clock);
const after = await f.rooms.readRoom(f.code);
assert.equal(after.phase, 4);
assert.equal(after.players[0].spice, 12);
assert.equal(after.decision, null);
await f.rooms.continueRoomAutomatic(f.code, f.clock);
assert.deepEqual(await f.rooms.readRoom(f.code), after);
} finally {
f.sqlite.close();
}
});
void test('paused seats can take back AI control and revoke consent but cannot grant, enable or activate AI', async () => {
const f = await fixture('operator', true);
try {
const grant = {
grantId: randomUUID(),
delegateId: f.guestAuth.playerId,
difficulty: 'Hard' as const,
};
await f.rooms.setSeatAiDelegate(
f.code,
f.auth,
f.version(),
grant,
f.clock,
);
await f.apply();
const before = f.row();
await assert.rejects(
f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'setAutopilot', difficulty: 'Hard' },
f.clock,
),
/paused/i,
);
await assert.rejects(
f.rooms.setSeatAiDelegate(
f.code,
f.auth,
f.version(),
{ ...grant, grantId: randomUUID() },
f.clock,
),
/paused/i,
);
await assert.rejects(
f.rooms.useSeatAiDelegate(
f.code,
f.guestAuth,
f.version(),
{ ownerId: f.auth.playerId, grantId: grant.grantId },
f.clock,
),
/paused/i,
);
assert.deepEqual(f.row(), before);
const replay = await f.rooms.setSeatAiDelegate(
f.code,
f.auth,
f.version(),
grant,
f.clock,
);
assert.equal(replay.replayed, true);
await f.rooms.revokeSeatAiDelegate(
f.code,
f.auth,
f.version(),
grant.grantId,
f.clock,
);
await f.apply(change(1, false));
await f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'setAutopilot', difficulty: 'Hard' },
f.clock,
);
await f.apply(change(2));
const game = await f.rooms.readRoom(f.code);
const view = await f.rooms.act(
f.code,
f.auth,
f.version(),
{ type: 'setAutopilot', difficulty: null },
f.clock,
);
assert.equal(view.roomControl?.paused, true);
const after = await f.rooms.readRoom(f.code);
assert.equal(after.players[0].autopilot, undefined);
assert.deepEqual(
after.players[0].traitorChoices,
game.players[0].traitorChoices,
);
assert.deepEqual(after.players[0].hand, game.players[0].hand);
assert.equal(after.setupStage, game.setupStage);
} finally {
f.sqlite.close();
}
});
void test('paused delegated activation loses the final transaction race without consuming consent', async () => {
const f = await fixture('operator', true);
try {
const grant = {
grantId: randomUUID(),
delegateId: f.guestAuth.playerId,
difficulty: 'Hard' as const,
};
await f.rooms.setSeatAiDelegate(
f.code,
f.auth,
f.version(),
grant,
f.clock,
);
const gate = pauseAdminBatch(f);
const activation = f.rooms.useSeatAiDelegate(
f.code,
f.guestAuth,
f.version(),
{ ownerId: f.auth.playerId, grantId: grant.grantId },
f.clock,
);
await gate.waiting;
await f.apply();
const before = f.row();
gate.release();
await assert.rejects(activation, /table.*changed/i);
assert.deepEqual(f.row(), before);
assert.equal(
f.sqlite.prepare('SELECT used_at FROM seat_ai_delegations').get()!
.used_at,
null,
);
} finally {
f.sqlite.close();
}
});
void test('paused room keeps recovery/key rotation and handover with exact retries and public pause metadata', async () => {
const f = await fixture('operator', true);
try {
await f.apply(change(0, true, true));
const privateState = f.row().state;
const recoverySecret = secret();
await f.rooms.setRecoveryKey(f.code, f.auth, f.version(), recoverySecret);
const recover = {
playerId: f.auth.playerId,
recoverySecret,
operationId: randomUUID(),
newSessionToken: secret(),
};
const recovered = await f.rooms.recoverSeat(f.code, recover);
const replay = await f.rooms.recoverSeat(f.code, recover);
assert.equal(replay.replayed, true);
for (const result of [recovered, replay])
assert.equal(result.view.roomControl?.paused, true);
await assert.rejects(f.rooms.readSeatView(f.code, f.auth), /verified/i);
const auth = await f.rooms.authenticate(f.code, recovered.token);
const offer = { offerId: randomUUID(), handoverSecret: secret() };
await f.rooms.createSeatHandover(f.code, auth, f.version(), offer, f.clock);
const claim = {
playerId: auth.playerId,
...offer,
operationId: randomUUID(),
newSessionToken: secret(),
};
const claimed = await f.rooms.claimSeatHandover(f.code, claim, f.clock);
const claimedAgain = await f.rooms.claimSeatHandover(
f.code,
claim,
f.clock,
);
assert.equal(claimedAgain.replayed, true);
for (const result of [claimed, claimedAgain])
assert.deepEqual(result.view.roomControl, {
paused: true,
joinLocked: true,
revision: 1,
updatedAt: 10000,
});
assert.equal(f.row().state, privateState);
await assert.rejects(f.rooms.readSeatView(f.code, auth), /verified/i);
} finally {
f.sqlite.close();
}
});
+77
View File
@@ -0,0 +1,77 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { renderToStaticMarkup } from 'react-dom/server';
import { createGame, newPlayer, viewGame, type GameView } from '../game/engine';
import { newAdminRoomRequest, readAdminRoomRequest, saveAdminRoomRequest, clearAdminRoomRequest, retainAdminRoomRequest } from '../lib/admin-room-control-client';
import { ClientRequestError } from '../lib/client-request';
import { DEFAULT_ROOM_CONTROL } from '../lib/room-control';
import { RoomControlNotice } from '../components/room-control-notice';
import { SeatAutopilot } from '../components/seat-autopilot';
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('room administration saves the exact request before retry and isolates accounts and rooms', () => {
const tab = storage();
const input = newAdminRoomRequest({ ...DEFAULT_ROOM_CONTROL, revision: 7 }, true, true, ' QA pause ');
saveAdminRoomRequest(tab, 'owner-a', 'ROOMAAAA', input);
const restored = readAdminRoomRequest(tab, 'owner-a', 'ROOMAAAA');
assert.deepEqual(restored, input);
assert.equal(restored?.expectedRevision, 7);
assert.equal(restored?.reason, 'QA pause');
assert.equal(readAdminRoomRequest(tab, 'owner-b', 'ROOMAAAA'), null);
assert.equal(readAdminRoomRequest(tab, 'owner-a', 'ROOMBBBB'), null);
const conflicting = newAdminRoomRequest({ ...DEFAULT_ROOM_CONTROL, revision: 8 }, false, true, 'Different intent');
assert.throws(() => saveAdminRoomRequest(tab, 'owner-a', 'ROOMAAAA', conflicting), /Resolve the saved/);
assert.deepEqual(readAdminRoomRequest(tab, 'owner-a', 'ROOMAAAA'), input);
saveAdminRoomRequest(tab, 'owner-a', 'ROOMAAAA', restored!);
clearAdminRoomRequest(tab, 'owner-a', 'ROOMAAAA');
assert.equal(readAdminRoomRequest(tab, 'owner-a', 'ROOMAAAA'), null);
});
void test('storage failure and corrupt saved operations cannot silently become new requests', () => {
const tab = storage();
const input = newAdminRoomRequest(DEFAULT_ROOM_CONTROL, true, false, 'QA');
assert.throws(() => saveAdminRoomRequest({ ...tab, setItem() {} }, 'a', 'ROOMAAAA', input), /Nothing was sent/);
saveAdminRoomRequest(tab, 'a', 'ROOMAAAA', input);
const key = [...tab.values.keys()][0];
tab.values.set(key, '{broken');
assert.throws(() => readAdminRoomRequest(tab, 'a', 'ROOMAAAA'), /unreadable/);
assert.throws(() => saveAdminRoomRequest(tab, 'a', 'ROOMAAAA', input), /unreadable/);
assert.equal(tab.values.get(key), '{broken');
clearAdminRoomRequest(tab, 'a', 'ROOMAAAA');
assert.equal(tab.values.size, 0);
assert.throws(() => newAdminRoomRequest(DEFAULT_ROOM_CONTROL, true, false, ' '));
});
void test('expired or switched administrator access retains an earlier uncertain operation for its original account', () => {
for (const status of [401, 403, 502]) assert.equal(retainAdminRoomRequest(new ClientRequestError('Denied now', 'http', status)), true);
assert.equal(retainAdminRoomRequest(new ClientRequestError('Stale settings', 'http', 409)), false);
assert.equal(retainAdminRoomRequest(new ClientRequestError('Bad request', 'http', 400)), false);
});
void test('public pause and joining-lock notices explain retained actions without disclosing admin metadata', () => {
const control = { ...DEFAULT_ROOM_CONTROL, paused: true, joinLocked: true, reason: 'PRIVATE_REASON', actor: 'PRIVATE_ACTOR' };
const html = renderToStaticMarkup(<RoomControlNotice control={control} />);
assert.match(html, /Player decisions and automatic play are paused/);
assert.match(html, /Existing players can reconnect and recover/);
assert.match(html, /aria-live="polite"/);
assert.doesNotMatch(html, /PRIVATE_/);
assert.equal(renderToStaticMarkup(<RoomControlNotice control={DEFAULT_ROOM_CONTROL} />), '');
});
void test('pause removes AI-start controls but preserves the current owner’s takeback button', () => {
const game = createGame('ROOMAAAA', newPlayer('a', 'Owner', 'atreides'));
game.status = 'setup';
const view: GameView = { ...viewGame(game, 'a'), roomControl: { ...DEFAULT_ROOM_CONTROL, paused: true } };
const render = () => renderToStaticMarkup(<SeatAutopilot game={view} act={() => {}} busy={false} />);
assert.doesNotMatch(render(), /Start autopilot/);
assert.match(render(), /enabled after an administrator resumes/);
view.players[0].autopilot = 'Medium';
const html = render();
assert.match(html, /AI decisions are paused/);
assert.match(html, /Take back control/);
assert.doesNotMatch(html, /disabled=""/);
});
+1 -1
View File
@@ -37,7 +37,7 @@ function unitStore(runBots = bots.runBots) {
async run() {
const continuation =
this.sql.startsWith('UPDATE rooms SET state') &&
!this.sql.includes('EXISTS');
!this.sql.includes('FROM seats');
if (continuation) await hooks.beforeWrite?.();
const changes = Number(
sqlite.prepare(this.sql).run(...this.values).changes,
+1 -1
View File
@@ -34,7 +34,7 @@ function unitStore(runBots = bots.runBots) {
async run() {
const continuation =
this.sql.startsWith('UPDATE rooms SET state') &&
!this.sql.includes('EXISTS');
!this.sql.includes('FROM seats');
if (continuation) await hooks.beforeWrite?.();
const changes = Number(
sqlite.prepare(this.sql).run(...this.values).changes,
+1 -1
View File
@@ -36,7 +36,7 @@ function unitStore(runBots = bots.runBots) {
async run() {
const continuation =
this.sql.startsWith('UPDATE rooms SET state') &&
!this.sql.includes('EXISTS');
!this.sql.includes('FROM seats');
if (continuation) await hooks.beforeWrite?.();
const changes = Number(
sqlite.prepare(this.sql).run(...this.values).changes,
+1 -1
View File
@@ -34,7 +34,7 @@ function unitStore(runBots = bots.runBots) {
async run() {
const continuation =
this.sql.startsWith('UPDATE rooms SET state') &&
!this.sql.includes('EXISTS');
!this.sql.includes('FROM seats');
if (continuation) await hooks.beforeWrite?.();
const changes = Number(
sqlite.prepare(this.sql).run(...this.values).changes,
+9 -7
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { readFileSync, readdirSync } from 'node:fs';
import type { DatabaseSync } from 'node:sqlite';
import { baseDeck } from '../game/cards';
import { createGame, newPlayer } from '../game/engine';
@@ -389,12 +389,14 @@ void test('0005 backfills an active 0004 claim for exact replay after upgrade',
(receipt.claim_fence as string).includes(body.newSessionToken),
false,
);
f.sqlite.exec(
readFileSync(
new URL('../drizzle/0006_thick_imperial_guard.sql', import.meta.url),
'utf8',
),
);
// Run current production code only after completing the schema upgrade.
// The receipt assertion above still verifies the 0005 backfill itself.
for (const file of readdirSync(new URL('../drizzle/', import.meta.url))
.filter((file) => file.endsWith('.sql') && file > '0005_salty_alice.sql')
.sort())
f.sqlite.exec(
readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'),
);
const replay = await f.restart().claimSeatHandover(code, body, clock(3000));
assert.equal(replay.replayed, true);
assert.equal(replay.token, body.newSessionToken);
+97
View File
@@ -0,0 +1,97 @@
// Dedicated QA rooms only. Never operate on a room supplied by the caller.
import assert from 'node:assert/strict';
import { randomBytes, randomUUID } from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';
export async function verifyRoomLifecycle(request, adminCookie, report) {
const made = await request('/api/rooms', { name: 'Lifecycle acceptance QA', faction: 'atreides', advanced: false, expansions: [] });
assert.equal(made.status, 201);
const code = made.data.code, host = made.cookie;
report.lifecycleRoom = code;
const path = '/api/rooms/' + code, controls = '/api/admin/rooms/' + code + '/control';
const joinBody = { type: 'join', name: 'Lifecycle participant QA', faction: 'emperor', entry: { operationId: randomUUID(), sessionToken: randomBytes(32).toString('hex') } };
const joined = await request(path, joinBody);
assert.equal(joined.status, 200);
const guest = joined.cookie;
async function control(paused, joinLocked, saved) {
const latest = await request(controls, undefined, adminCookie);
assert.equal(latest.status, 200);
const body = saved ?? { operationId: randomUUID(), expectedRevision: latest.data.revision, paused, joinLocked, reason: 'Dedicated lifecycle acceptance QA' };
const result = await request(controls, body, adminCookie);
assert.equal(result.status, 200);
return { ...result.data, body };
}
async function action(cookie, input) {
const view = await request(path, undefined, cookie);
assert.equal(view.status, 200);
return request(path, { version: view.data.version, action: input }, cookie);
}
let failure;
try {
const mismatched = await request(controls, { operationId: randomUUID(), expectedRevision: 0, paused: true, joinLocked: true, reason: 'Stale administrator QA' }, adminCookie, undefined, randomUUID());
assert.equal(mismatched.status, 401);
assert.equal((await request(controls, undefined, adminCookie)).data.revision, 0);
const locked = await control(false, true);
const replay = await control(false, true, locked.body);
assert.equal(replay.replayed, true); assert.equal(replay.revision, locked.revision);
const denial = await request(controls, { ...locked.body, operationId: randomUUID(), expectedRevision: locked.revision }, host);
assert.equal(denial.status, 401);
const retriedJoin = await request(path, joinBody);
assert.equal(retriedJoin.status, 200); assert.equal(retriedJoin.data.entryReceipt.replayed, true);
assert.equal(retriedJoin.data.me, joined.data.me);
const blockedJoin = await request(path, { type: 'join', name: 'Blocked lifecycle QA', faction: 'fremen' });
assert.equal(blockedJoin.status, 409); assert.equal(blockedJoin.data.code, 'ROOM_JOIN_LOCKED');
assert.equal((await request(path, undefined, guest)).status, 200);
report.checks.push('Joining lock rejects new seats while preserving completed join retries and current seats');
await control(false, false);
assert.equal((await action(host, { type: 'addBot', faction: 'harkonnen', difficulty: 'Medium' })).status, 200);
assert.equal((await action(host, { type: 'ready' })).status, 200);
assert.equal((await action(guest, { type: 'ready' })).status, 200);
assert.equal((await action(host, { type: 'start' })).status, 200);
assert.equal((await action(host, { type: 'setAutopilot', difficulty: 'Medium' })).status, 200);
await control(true, true);
const frozen = (await request(path, undefined, host)).data;
assert.equal(frozen.roomControl.paused, true);
const blockedAction = await action(host, { type: 'setAutopilot', difficulty: 'Hard' });
assert.equal(blockedAction.status, 409); assert.match(blockedAction.data.error, /paused/i);
await delay(2200);
assert.deepEqual((await request(path, undefined, host)).data, frozen, 'Polling must not advance paused AI or automatic work');
const takeback = await action(host, { type: 'setAutopilot', difficulty: null });
assert.equal(takeback.status, 200);
assert.equal(takeback.data.players.find(p => p.id === takeback.data.me).autopilot, undefined);
assert.equal(takeback.data.roomControl.paused, true);
await delay(1700);
assert.deepEqual((await request(path, undefined, host)).data, takeback.data, 'The native AI remains paused after human takeback');
report.checks.push('Paused player actions and AI remain frozen; human owner can take back control');
const resumed = await control(false, false);
assert.equal(resumed.paused, false);
assert.equal((await action(host, { type: 'setAutopilot', difficulty: 'Medium' })).status, 200);
assert.equal((await action(host, { type: 'setAutopilot', difficulty: null })).status, 200);
const after = (await request(path, undefined, guest)).data;
assert.equal(after.me, joined.data.me);
assert.equal(after.roomControl.paused, false);
assert.equal(after.players.length, 3);
report.checks.push('Resume preserves player seats and permits new authoritative decisions');
} catch (error) {
report.failureRequest ??= report.lastRequest;
report.lifecycleFailure = 'Room lifecycle acceptance failed before cleanup.';
failure = error;
} finally {
// A failed QA run must not leave its new room administratively paused.
try {
const current = await request(controls, undefined, adminCookie);
assert.equal(current.status, 200);
if (current.data.paused || current.data.joinLocked) await control(false, false);
const view = await request(path, undefined, host);
assert.equal(view.status, 200);
if (view.data.players.find(p => p.id === view.data.me)?.autopilot)
assert.equal((await action(host, { type: 'setAutopilot', difficulty: null })).status, 200);
} catch (error) {
report.lifecycleCleanupFailure = { message: 'The QA room cleanup could not be confirmed.', request: report.lastRequest };
failure ??= error;
}
}
if (failure) throw failure;
}
+14 -5
View File
@@ -1,10 +1,11 @@
// Explicit operator-run HTTP acceptance. Use a dedicated QA administrator key:
// this signs out ALL sessions for that account. Never use a human operator's key.
// Uses one new QA room; never modifies an existing game.
// Uses new QA rooms only; operator/owner QA keys also exercise room lifecycle.
import assert from 'node:assert/strict';
import { readFile, mkdir, realpath, writeFile } from 'node:fs/promises';
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { verifyRoomLifecycle } from './admin-lifecycle-verification.mjs';
const options = {};
for (let n = 2; n < process.argv.length; n += 2) {
@@ -24,11 +25,12 @@ await mkdir(out, { mode: 0o700 });
const key = (await readFile(options['--key-file'], 'utf8')).trim();
const base = url.origin;
const report = { origin: base, startedAt: new Date().toISOString(), passed: false, checks: [], room: null };
let session1, session2;
async function request(path, body, cookie, origin = base) {
let session1, session2, adminId;
async function request(path, body, cookie, origin = base, expectedAdminId = adminId) {
const response = await fetch(base + path, {
method: body === undefined ? 'GET' : 'POST',
headers: { ...(body === undefined ? {} : { 'content-type': 'application/json', origin }), ...(cookie ? { cookie } : {}) },
headers: { ...(body === undefined ? {} : { 'content-type': 'application/json', origin }), ...(cookie ? { cookie } : {}),
...(expectedAdminId && /^\/api\/admin\/rooms\/[^/]+\/control$/.test(path) ? { 'X-Dune-Admin-Id': expectedAdminId } : {}) },
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15000),
});
report.lastRequest = { path, status: response.status };
@@ -44,6 +46,7 @@ try {
assert.equal(login.status, 200); assert.ok(login.cookie);
session1 = login.cookie;
assert.equal(login.data.admin.id, options['--qa-account'], 'Use the explicitly designated QA account.');
adminId = login.data.admin.id;
for (const attribute of ['HttpOnly', 'SameSite=Strict', 'Path=/api/admin']) assert.ok(login.rawCookie.includes(attribute));
if (url.protocol === 'https:') assert.ok(login.rawCookie.includes('; Secure'));
assert.equal((await request('/api/admin/session', undefined, session1)).data.admin.id, login.data.admin.id);
@@ -75,11 +78,17 @@ try {
for (const cookie of [session1, session2]) assert.equal((await request('/api/admin/rooms', undefined, cookie)).status, 401);
assert.deepEqual((await request(path, undefined, created.cookie)).data, before);
report.checks.push('Foreign-origin denial; individual and all-session revocation; saved-seat continuity');
if (['owner', 'operator'].includes(login.data.admin.role)) {
const lifecycleSession = await request('/api/admin/session', { action: 'login', key });
assert.equal(lifecycleSession.status, 200);
session1 = lifecycleSession.cookie;
await verifyRoomLifecycle(request, session1, report);
}
report.passed = true;
} catch (error) {
// Do not serialize assertion payloads: failed authentication responses might contain credentials.
report.failure = 'Administrator HTTP verification failed. Investigate the last completed check.';
report.failureRequest = report.lastRequest;
report.failureRequest ??= report.lastRequest;
report.failureLocation = error instanceof Error ? error.stack?.split('\n').find(line => line.trim().startsWith('at ') && line.includes('verify-admin.mjs'))?.trim() : undefined;
process.exitCode = 1;
} finally {
+1 -1
View File
@@ -36,7 +36,7 @@ try {
docker('volume', 'create', volume);
start();
await ready();
execFileSync('node', ['tools/admin-access.mjs', '--name', 'Container QA', '--role', 'viewer', '--out', joinPath(adminFiles, 'key')]);
execFileSync('node', ['tools/admin-access.mjs', '--name', 'Container QA', '--role', 'operator', '--out', joinPath(adminFiles, 'key')]);
execFileSync('docker', ['exec', '-i', name, 'node', '-e', "require('node:fs').writeFileSync('/tmp/dune-admin-provision.sql', require('node:fs').readFileSync(0), {mode:0o600})"], {
input: readFileSync(joinPath(adminFiles, 'key/provision.sql')), // Container USER owns this private file.
});