Add recoverable administrator room removal and restoration

Preserve room JSON, seats, credentials and messages while removed rooms deny ordinary reads and writes. Add deliberate room-scoped controls, removed-directory filters, persistent player notices and live-authorized atomic version/revision audit receipts. Retain uncertain room/seat requests and hidden recovery drafts through removal; restore the prior pause/join settings without reviving revoked credentials.

Fix AI readiness after a joining human or human faction change so real lobby starts remain legal.

Verified types, lint, 5314 offline tests, 52 HTTP tests and build. Fourteen authenticated HTTP groups and independent privacy/persistence review passed. Desktop/mobile browser QA confirmed a saved removal after a lost response and refresh, one audit on exact retry, persistent notices and recovery draft continuity. All 1,945 baseline rooms, 4,121 seats and related records preserved. Positive authenticated production admin acceptance remains pending authorized QA operator access; full admin and rules gates remain open.
This commit is contained in:
Quintine committed 2026-09-22 12:01:22 +10:00
1 parent c0cc4de9e1
commit 238de3860d
44 files changed
+4626 -90

No files matched your search

+15 -6
View File
@@ -11,11 +11,12 @@ import { FACTIONS } from '@/game/catalog';
import { AdminRoomControls } from '@/components/admin-room-controls';
import { AdminRoomCreation } from '@/components/admin-room-creation';
import { AdminLobbyControls } from '@/components/admin-lobby-controls';
import { AdminRoomRemoval } from '@/components/admin-room-removal';
import './admin.css';
type Account = { id: string; name: string; role: 'owner' | 'operator' | 'viewer' };
type Filters = { q: string; status: string; rules: string; sort: string; availability: string };
const initialFilters: Filters = { q: '', status: 'all', rules: 'all', sort: 'recent', availability: 'all' };
type Filters = { q: string; status: string; rules: string; sort: string; availability: string; removal: string };
const initialFilters: Filters = { q: '', status: 'all', rules: 'all', sort: 'recent', availability: 'all', removal: 'active' };
const errorMessage = (error: unknown) => error instanceof Error ? error.message : 'The request could not be completed.';
export default function Administration() {
@@ -30,6 +31,7 @@ export default function Administration() {
const [confirmAll, setConfirmAll] = useState(false);
const [selectedRoom, setSelectedRoom] = useState<string | null>(null);
const [selectedLobby, setSelectedLobby] = useState<string | null>(null);
const [selectedRemoval, setSelectedRemoval] = useState<string | null>(null);
const [creatingRoom, setCreatingRoom] = useState(false);
const [operationBusy, setOperationBusy] = useState(false);
const controlsBusy = busy || operationBusy;
@@ -37,7 +39,7 @@ export default function Administration() {
const clearSession = useCallback(() => {
epoch.current++;
setAccount(null); setDirectory(null); setConfirmAll(false);
setBusy(false); setSelectedRoom(null); setSelectedLobby(null); setCreatingRoom(false);
setBusy(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setCreatingRoom(false);
}, []);
const accessChanged = useCallback(() => {
clearSession();
@@ -113,13 +115,16 @@ export default function Administration() {
</section> : <>
<section aria-labelledby="admin-rooms-title">
<div className="admin-section-heading"><div><h2 id="admin-rooms-title">Rooms</h2><p className="admin-secondary">Public room details. Cards, sealed choices and private discussion stay hidden.</p></div>
<div className="admin-room-actions">{account.role !== 'viewer' && <Button disabled={controlsBusy} onClick={() => { setSelectedRoom(null); setSelectedLobby(null); setCreatingRoom(true); }}>Create a room</Button>}
<div className="admin-room-actions">{account.role !== 'viewer' && <Button disabled={controlsBusy} onClick={() => { setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setCreatingRoom(true); }}>Create a room</Button>}
<Button variant="outline" disabled={controlsBusy} onClick={() => void load(applied, directory?.page ?? 1)}><RefreshCw aria-hidden="true" /> Refresh</Button></div></div>
{creatingRoom && account.role !== 'viewer' && <AdminRoomCreation key={account.id} accountId={account.id}
onClose={() => setCreatingRoom(false)} onDenied={accessChanged} onBusyChange={setOperationBusy}
onCreated={code => { const next = { ...initialFilters, q: code }; setFilters(next); void load(next); }} />}
<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={controlsBusy} onChange={e => setFilters({ ...filters, q: e.target.value })} /></label>
<label>Directory<select disabled={controlsBusy} value={filters.removal} onChange={e => setFilters({ ...filters, removal: e.target.value })}>
<option value="active">Active rooms</option><option value="removed">Removed rooms</option><option value="all">All rooms</option>
</select></label>
<label>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>
@@ -140,6 +145,9 @@ export default function Administration() {
{selectedLobby && <AdminLobbyControls key={`${account.id}:${selectedLobby}`} accountId={account.id} code={selectedLobby}
canManage={account.role !== 'viewer'} onClose={() => setSelectedLobby(null)} onDenied={accessChanged}
onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />}
{selectedRemoval && <AdminRoomRemoval key={`${account.id}:${selectedRemoval}`} accountId={account.id} code={selectedRemoval}
canManage={account.role !== 'viewer'} onClose={() => setSelectedRemoval(null)} onDenied={accessChanged}
onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />}
<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>
@@ -148,6 +156,7 @@ export default function Administration() {
<TableHead>Room & players</TableHead><TableHead>Rules</TableHead><TableHead>Progress</TableHead><TableHead>Last game change</TableHead>
</TableRow></TableHeader><TableBody>{directory.rooms.map(room => <TableRow key={room.code}>
<TableCell><strong className="admin-code">{room.code}</strong> <span className="admin-tag">{room.status}</span>
{room.removed && <span className="admin-tag">Removed</span>}
{room.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>
@@ -160,7 +169,7 @@ export default function Administration() {
<TableCell>{room.turn !== null && room.status !== 'lobby' ? `Turn ${room.turn} · ` : ''}{room.phase}
<p>{room.pending.label}{room.pending.owners.length > 0 ? ': ' + room.pending.owners.map(id => room.players.find(p => p.id === id)?.name).filter(Boolean).join(', ') : ''}</p>
</TableCell>
<TableCell><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedLobby(null); setSelectedRoom(room.code); }}>Room controls · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedRoom(null); setSelectedLobby(room.code); }}>Configure lobby · {room.code}</Button><p><time dateTime={new Date(room.updatedAt).toISOString()}>{new Date(room.updatedAt).toLocaleString()}</time></p><p className="admin-secondary">Version {room.version}</p></TableCell>
<TableCell><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedLobby(null); setSelectedRemoval(null); setSelectedRoom(room.code); }}>Room controls · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedRoom(null); setSelectedRemoval(null); setSelectedLobby(room.code); }}>Configure lobby · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(room.code); }}>Remove or restore · {room.code}</Button><p><time dateTime={new Date(room.updatedAt).toISOString()}>{new Date(room.updatedAt).toLocaleString()}</time></p><p className="admin-secondary">Version {room.version}</p></TableCell>
</TableRow>)}</TableBody></Table>}
<nav className="admin-pagination" aria-label="Room pages">
<Button variant="outline" disabled={controlsBusy || directory.page <= 1} onClick={() => void load(applied, 1)}>First</Button>
@@ -176,7 +185,7 @@ export default function Administration() {
{!confirmAll ? <Button variant="outline" disabled={controlsBusy} onClick={() => setConfirmAll(true)}>Sign out everywhere…</Button> :
<div className="admin-confirm"><p>End all your administrator sessions now?</p><Button disabled={controlsBusy} onClick={() => void sessionAction('logoutAll')}>Confirm sign out everywhere</Button><Button variant="outline" disabled={controlsBusy} onClick={() => setConfirmAll(false)}>Cancel</Button></div>}
</section>
<p className="admin-secondary admin-development">Administration preview: create and configure lobbies, assign hosts, pause/resume rooms and manage joining locks. Archive/removal, participant support and backup tools are still being implemented.</p>
<p className="admin-secondary admin-development">Administration preview: create and configure lobbies, assign hosts, pause/resume rooms, manage joining locks, and recoverably remove or restore rooms. Close/end, archive, permanent removal, participant support and backup tools are still being implemented.</p>
</>}
</main>;
}
@@ -0,0 +1,35 @@
import { env, waitUntil } from 'cloudflare:workers';
import { AdminError, requireAdmin } from '@/db/admin-access';
import { applyAdminRemoval, readAdminRemoval } from '@/db/admin-removal';
import { resumeRoom } from '@/db/room-continuation';
import { validAdminRemovalInput } from '@/lib/admin-removal';
import { adminBody, adminFailure, adminResponse, adminToken } from '@/lib/admin-http';
function code(request: Request) {
const code = new URL(request.url).pathname.split('/').at(-2)?.toUpperCase() ?? '';
if (code.length !== 8 || !/^[A-Z2-9]{8}$/.test(code)) throw new AdminError('Enter the eight-character room code.', 400);
return code;
}
export async function GET(request: Request) {
try {
const identity = await requireAdmin(env.DB, adminToken(request));
if (request.headers.has('X-Dune-Admin-Id') && request.headers.get('X-Dune-Admin-Id') !== identity.id)
throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401);
return adminResponse(await readAdminRemoval(env.DB, identity, code(request)));
} catch (error) { return adminFailure(error); }
}
export async function POST(request: Request) {
try {
const input = await adminBody(request, env.DUNE_PUBLIC_ORIGIN);
const identity = await requireAdmin(env.DB, adminToken(request), ['owner', 'operator']);
if (request.headers.get('X-Dune-Admin-Id') !== identity.id)
throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401);
if (!validAdminRemovalInput(input)) throw new AdminError('Choose removal or restoration, current versions and an operational reason.', 400);
const room = code(request);
const result = await applyAdminRemoval(env.DB, identity, room, input);
if (!result.replayed && !result.room.removed && !result.room.paused) waitUntil(resumeRoom(room));
return adminResponse(result);
} catch (error) { return adminFailure(error); }
}
+4 -3
View File
@@ -6,6 +6,7 @@ import {
claimSeatHandover,
recoverSeat,
SeatControlError,
RoomEntryError,
setRecoveryKey,
setSeatAiDelegate,
revokeSeatAiDelegate,
@@ -124,7 +125,7 @@ export async function POST(req: Request) {
try {
existing = await authenticate(code, token);
} catch (error) {
if (!(error instanceof RuleError)) throw error;
if (error instanceof RoomEntryError && error.code === 'ROOM_REMOVED' || !(error instanceof RuleError)) throw error;
}
if (existing && existing.playerId !== input.playerId)
throw new SeatControlError(
@@ -173,7 +174,7 @@ export async function POST(req: Request) {
? 'Invalid JSON.'
: 'The server could not complete this seat request.',
code:
error instanceof SeatControlError
(error instanceof SeatControlError || error instanceof RoomEntryError)
? error.code
: error instanceof RuleError
? 'INVALID_SESSION'
@@ -183,7 +184,7 @@ export async function POST(req: Request) {
},
{
status:
error instanceof SeatControlError
(error instanceof SeatControlError || error instanceof RoomEntryError)
? error.status
: error instanceof RuleError
? 409
+3 -2
View File
@@ -1,6 +1,6 @@
import { env } from 'cloudflare:workers';
import { requestOrigin } from '@/lib/request-origin';
import { authenticate } from '@/db/rooms';
import { authenticate, RoomEntryError } from '@/db/rooms';
import { readTableTalk, sendTableTalk, TableTalkError } from '@/db/table-talk';
import { RuleError } from '@/game/engine';
const noStore = { 'Cache-Control': 'no-store' };
@@ -50,6 +50,7 @@ async function handle(req: Request, write: boolean) {
} catch (error) {
return Response.json(
{
...((error instanceof TableTalkError || error instanceof RoomEntryError) && error.code ? { code: error.code } : {}),
error:
error instanceof TableTalkError || error instanceof RuleError
? error.message
@@ -59,7 +60,7 @@ async function handle(req: Request, write: boolean) {
},
{
status:
error instanceof TableTalkError
(error instanceof TableTalkError || error instanceof RoomEntryError)
? error.status
: error instanceof RuleError
? 409
+1 -1
View File
@@ -89,7 +89,7 @@ export async function POST(req: Request) {
try {
auth = await authenticate(code, existing);
} catch (error) {
if (!(error instanceof RuleError)) throw error;
if (error instanceof RoomEntryError && error.code === 'ROOM_REMOVED' || !(error instanceof RuleError)) throw error;
}
}
if (body.entry === undefined && auth) {
+51 -17
View File
@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { TableTalk } from '@/components/table-talk';
import { RemovedRoomNotice } from '@/components/removed-room-notice';
import { GameTable } from '@/components/game-table';
import { RulesetControls } from '@/components/ruleset-controls';
import {
@@ -30,6 +31,8 @@ import {
ClientRequestError,
requestJson,
requestMayHaveCompleted,
isRoomRemoved,
subscribeRoomRemoval,
} from '@/lib/client-request';
import {
@@ -65,6 +68,8 @@ export default function Home() {
const [needsReconcile, setNeedsReconcile] = useState(false);
const [botRecovery, setBotRecovery] = useState(false);
const [game, setGame] = useState<GameView | null>(null);
const removedRooms = useRef(new Set<string>());
const [removedRoom, setRemovedRoom] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [connection, setConnection] = useState('');
const [selected, setSelected] = useState('atreides');
@@ -88,6 +93,15 @@ export default function Home() {
const [abandonAcknowledged, setAbandonAcknowledged] = useState(false);
const f = faction(selected);
const roomCode = game?.code;
const roomUnavailable = !!game && removedRoom === game.code;
const markRoomRemoved = useCallback((room: string) => {
if (activeRoom.current !== room || removedRooms.current.has(room)) return;
removedRooms.current.add(room);
++epoch.current;
recovery.current = null; botAttempt.current = null;
setRemovedRoom(room); setNeedsReconcile(false); setBotRecovery(false); setConnection(''); setNotice('');
}, []);
useEffect(() => subscribeRoomRemoval(markRoomRemoved), [markRoomRemoved]);
// This function is deliberately GET-only, including WebMCP reads and recovery.
const refresh = useCallback(
async (room: string, fresh = false): Promise<GameView> => {
@@ -106,6 +120,7 @@ export default function Home() {
if (
activeRoom.current !== room ||
readEpoch !== epoch.current ||
(removedRooms.current.has(room) && !fresh) ||
data.version < (knownVersions.current.get(room) ?? -1)
)
throw new ClientRequestError(
@@ -113,6 +128,8 @@ export default function Home() {
'aborted',
);
activeSeat.current = data.me;
removedRooms.current.delete(room);
setRemovedRoom(null);
knownVersions.current.set(room, data.version);
setGame((previous) =>
!previous ||
@@ -151,12 +168,13 @@ export default function Home() {
const reconcile = useCallback(
async (room: string) => {
if (removedRooms.current.has(room)) return;
recovery.current = { room, epoch: ++epoch.current };
setNeedsReconcile(true);
try {
await refresh(room, true);
} catch {
if (activeRoom.current === room)
if (activeRoom.current === room && !removedRooms.current.has(room))
setConnection(
'Reconnect to confirm the latest table before taking another action.',
);
@@ -170,6 +188,7 @@ export default function Home() {
const room = data.code;
if (
!data.botsPending ||
removedRooms.current.has(room) ||
data.roomControl?.paused ||
activeRoom.current !== room ||
data.me !== activeSeat.current ||
@@ -300,7 +319,7 @@ export default function Home() {
}, [refresh]);
useEffect(() => {
if (!roomCode) return;
if (!roomCode || removedRoom === roomCode) return;
let live = true;
let timer: ReturnType<typeof setTimeout>;
const tick = async () => {
@@ -317,7 +336,7 @@ export default function Home() {
nextPollMs = data.botsPending ? 500 : 2000;
}
} catch {
if (live && activeRoom.current === roomCode)
if (live && activeRoom.current === roomCode && !removedRooms.current.has(roomCode))
setConnection('Connection interrupted. Retrying…');
} finally {
// Schedule after completion, so slow requests cannot stack polling cycles.
@@ -329,7 +348,7 @@ export default function Home() {
live = false;
clearTimeout(timer);
};
}, [roomCode, refresh]);
}, [roomCode, refresh, removedRoom]);
async function dispatchEntry(
attempt: RoomEntryAttempt,
@@ -373,6 +392,8 @@ export default function Home() {
);
}
activeRoom.current = data.code;
removedRooms.current.delete(data.code);
setRemovedRoom(null);
activeSeat.current = data.me;
setHandoverPending(true);
knownVersions.current.set(data.code, data.version);
@@ -461,6 +482,7 @@ export default function Home() {
seatClaimUncertain.current
)
return;
if (activeRoom.current) removedRooms.current.delete(activeRoom.current);
activeRoom.current = null;
setHandoverPending(true);
setAiPermissionPending(true);
@@ -472,6 +494,8 @@ export default function Home() {
setBotRecovery(false);
setConnection('');
setGame(null);
setRemovedRoom(null);
setRestoreFailed(false);
setNotice('');
window.history.replaceState(null, '', '/');
}
@@ -501,7 +525,7 @@ export default function Home() {
}
async function send(action: Action) {
if (!game || mutationPending.current || recovery.current) return;
if (!game || removedRooms.current.has(game.code) || 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;
@@ -570,8 +594,14 @@ export default function Home() {
if (resumeBots) await advanceBots(data, true);
} catch (error) {
if (activeRoom.current === room) {
if (restoreFailed && inviteRejected(error)) {
if (isRoomRemoved(error)) {
setConnection(error.message);
} else if (restoreFailed && inviteRejected(error)) {
removedRooms.current.delete(room);
setRemovedRoom(null);
activeRoom.current = null;
activeSeat.current = null;
++epoch.current;
setRestoreFailed(false);
setCode(room);
setNotice((error as Error).message);
@@ -597,6 +627,7 @@ export default function Home() {
}
function restoredSeat(view: GameView, claim = false) {
if (removedRooms.current.has(view.code)) return false;
if (
!claim &&
(activeRoom.current !== view.code || activeSeat.current !== view.me)
@@ -629,6 +660,7 @@ export default function Home() {
setRestoreFailed(false);
setConnection('');
setNotice('');
return true;
}
useEffect(() => {
const ctx = (
@@ -668,8 +700,7 @@ export default function Home() {
<SeatHandoverClaim
onCancel={() => window.location.reload()}
onRestored={(view) => {
restoredSeat(view, true);
setShowHandover(false);
if (restoredSeat(view, true)) setShowHandover(false);
}}
/>
</main>
@@ -677,15 +708,17 @@ export default function Home() {
if (game)
return (
<>
<GameTable
{roomUnavailable ? <main className="table-shell"><RemovedRoomNotice code={game.code} busy={busy || checkingConnection} onRetry={() => void reconnect()} onExit={exitTable} exitDisabled={handoverPending || aiPermissionPending || claimUncertain} /></main> : <GameTable
game={game}
send={send}
busy={busy || needsReconcile}
onExit={exitTable}
exitDisabled={handoverPending || aiPermissionPending}
/>
/>}
<div className="table-shell">
<TableTalk key={`talk:${game.code}:${game.me}`} game={game} />
{!roomUnavailable && <TableTalk key={`talk:${game.code}:${game.me}`} game={game} />}
{/* Keep controllers mounted: recovery kits and exact attempts can be memory-only. */}
<div hidden={roomUnavailable}>
{(entryAttempt || entryProblem) && (
<section className="notice">
<p>
@@ -709,14 +742,14 @@ export default function Home() {
<SeatRecoverySetup
key={`${game.code}:${game.me}`}
game={game}
disabled={busy || needsReconcile || checkingConnection}
disabled={roomUnavailable || busy || needsReconcile || checkingConnection}
onPending={controlPending}
onRestored={(view) => restoredSeat(view)}
/>
<SeatHandoverSetup
key={`handover:${game.code}:${game.me}`}
game={game}
disabled={busy || needsReconcile || checkingConnection}
disabled={roomUnavailable || busy || needsReconcile || checkingConnection}
onPending={controlPending}
onUncertain={setHandoverPending}
onRestored={(view) => restoredSeat(view)}
@@ -724,13 +757,14 @@ export default function Home() {
<SeatAiDelegation
key={`ai-permission:${game.code}:${game.me}`}
game={game}
disabled={busy || needsReconcile || checkingConnection}
disabled={roomUnavailable || busy || needsReconcile || checkingConnection}
onPending={controlPending}
onUncertain={setAiPermissionPending}
onRestored={(view) => restoredSeat(view)}
/>
</div>
</div>
{(notice || connection || needsReconcile || botRecovery) && (
{!roomUnavailable && (notice || connection || needsReconcile || botRecovery) && (
<output className="floating-notice">
{notice && <span>{notice}</span>}
{connection && <span>{connection}</span>}
@@ -776,7 +810,7 @@ export default function Home() {
DUNE<span>ARRAKIS TABLE</span>
</div>
</header>
<section className="notice">
{removedRoom ? <RemovedRoomNotice code={removedRoom} busy={checkingConnection} onRetry={() => void reconnect()} onExit={() => { removedRooms.current.delete(removedRoom); activeRoom.current = null; activeSeat.current = null; ++epoch.current; setRestoreFailed(false); setRemovedRoom(null); setConnection(''); setNotice(''); window.history.replaceState(null, '', '/'); }} /> : <section className="notice">
<h1>
{restoring
? 'Restoring your table'
@@ -813,7 +847,7 @@ export default function Home() {
</Button>
</>
)}
</section>
</section>}
</main>
);
// A first request is progress, not recovery. Keep the actionable recovery