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:
1 parent
c0cc4de9e1
commit
238de3860d
44 files changed
+4626
-90
No files matched your search
@@ -46,8 +46,10 @@ and [initial owner setup](docs/ADMIN_PANEL.md#initial-owner-and-access-recovery)
|
||||
Owners/operators can [create a configured lobby](docs/ADMIN_ROOM_CREATION.md) with
|
||||
an explicitly owned new host seat and initial AI players.
|
||||
[Configure lobby](docs/ADMIN_LOBBY_CONFIGURATION.md) manages supported rules, AI
|
||||
seats and host assignment without private seat access. Archive/removal, participant
|
||||
support and backup tools are still being built.
|
||||
seats and host assignment without private seat access.
|
||||
[Remove or restore](docs/ADMIN_ROOM_REMOVAL.md) recoverably removes selected rooms;
|
||||
use the Directory filter to find them for restoration. Close/end, archive,
|
||||
permanent deletion, participant support and backup tools are still being built.
|
||||
|
||||
## Saved seats and reconnecting
|
||||
|
||||
|
||||
+15
-6
@@ -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); }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Input } from './ui/input';
|
||||
import { FACTIONS, type FactionId } from '@/game/catalog';
|
||||
import { ClientRequestError, requestJson, requestMayHaveCompleted } from '@/lib/client-request';
|
||||
import { retainAdminRoomRequest } from '@/lib/admin-room-control-client';
|
||||
import { clearAdminRoomCreation, completeAdminRoomCreation, newAdminRoomCreationRequest, readAdminRoomCreation, saveAdminRoomCreation, type AdminRoomCreationRecord } from '@/lib/admin-room-creation-client';
|
||||
import { clearAdminRoomCreation, completeAdminRoomCreation, newAdminRoomCreationRequest, readAdminRoomCreation, saveAdminRoomCreation, validAdminRoomCreationResult, type AdminRoomCreationRecord } from '@/lib/admin-room-creation-client';
|
||||
import type { AdminRoomCreationInput, AdminRoomCreationResult } from '@/lib/admin-room-creation';
|
||||
|
||||
type Props = { accountId: string; onClose: () => void; onCreated: (code: string) => void; onDenied: () => void; onBusyChange: (busy: boolean) => void };
|
||||
@@ -57,6 +57,14 @@ export function AdminRoomCreation({ accountId, onClose, onCreated, onDenied, onB
|
||||
const result = await requestJson<AdminRoomCreationResult>('/api/admin/rooms', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input),
|
||||
});
|
||||
if (validAdminRoomCreationResult(result) && result.operationId === input.operationId && result.roomRemoved) {
|
||||
if (alive.current) {
|
||||
setNotice(`Room ${result.code} exists but has been removed. Its exact creation proof is still saved in this tab. Restore the room in Administration, then retry this same request to confirm host access.`);
|
||||
setConfirmed(false);
|
||||
onCreated(result.code);
|
||||
}
|
||||
return;
|
||||
}
|
||||
completeAdminRoomCreation(window.sessionStorage, accountId, input, result);
|
||||
if (alive.current) {
|
||||
setRecord({ kind: 'completed', result }); setConfirmed(false);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { ClientRequestError, requestJson, requestMayHaveCompleted } from '@/lib/client-request';
|
||||
import { retainAdminRoomRequest } from '@/lib/admin-room-control-client';
|
||||
import { adminRemovalResponse, adminRemovalConfirmation, clearAdminRemovalRequest, newAdminRemovalRequest, readAdminRemovalRequest, saveAdminRemovalRequest } from '@/lib/admin-removal-client';
|
||||
import type { AdminRemovalInput, AdminRemovalView } from '@/lib/admin-removal';
|
||||
|
||||
type Props = {
|
||||
accountId: string; code: string; canManage: boolean; onClose: () => void;
|
||||
onUpdated: () => void; onDenied: () => void; onBusyChange: (busy: boolean) => void;
|
||||
};
|
||||
const message = (error: unknown) => error instanceof Error ? error.message : 'Room availability could not be loaded.';
|
||||
|
||||
export function AdminRoomRemoval({ accountId, code, canManage, onClose, onUpdated, onDenied, onBusyChange }: Props) {
|
||||
const [room, setRoom] = useState<AdminRemovalView | null>(null);
|
||||
const [reason, setReason] = useState('');
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [confirmationCode, setConfirmationCode] = useState('');
|
||||
const [pending, setPending] = useState<AdminRemovalInput | null>(null);
|
||||
const [storageProblem, setStorageProblem] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const alive = useRef(false), inFlight = useRef(false);
|
||||
const title = useRef<HTMLHeadingElement>(null);
|
||||
const url = `/api/admin/rooms/${code}/removal`;
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true; title.current?.focus();
|
||||
let canceled = false;
|
||||
try {
|
||||
const saved = readAdminRemovalRequest(window.sessionStorage, accountId, code);
|
||||
// oxlint-disable-next-line react/react-compiler -- Restore the account/room's tab-scoped request after SSR.
|
||||
setPending(saved);
|
||||
if (saved) setNotice('A saved removal or restoration request needs confirmation. Review it below, then retry the same request.');
|
||||
} catch (error) { setStorageProblem(true); setNotice(message(error)); }
|
||||
requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } })
|
||||
.then(result => { const current = adminRemovalResponse(result, code); if (!canceled) setRoom(current); })
|
||||
.catch(error => { if (!canceled) {
|
||||
setNotice(message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
} })
|
||||
.finally(() => { if (!canceled) setLoading(false); });
|
||||
return () => { canceled = true; alive.current = false; };
|
||||
}, [accountId, code, url, onDenied]);
|
||||
|
||||
async function refresh() {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true; setBusy(true); onBusyChange(true);
|
||||
try {
|
||||
const current = adminRemovalResponse(await requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code);
|
||||
if (alive.current) { setRoom(current); setConfirmed(false); setConfirmationCode(''); }
|
||||
} catch (error) {
|
||||
if (alive.current) {
|
||||
setNotice(message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
}
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
|
||||
}
|
||||
|
||||
async function submit(saved?: AdminRemovalInput) {
|
||||
if (inFlight.current || loading || !canManage || storageProblem ||
|
||||
(!saved && (!room || pending || !confirmed || (!room.removed && confirmationCode !== code)))) return;
|
||||
let input: AdminRemovalInput;
|
||||
try {
|
||||
input = saved ?? newAdminRemovalRequest(room!, !room!.removed, reason);
|
||||
saveAdminRemovalRequest(window.sessionStorage, accountId, code, input);
|
||||
} catch (error) { setNotice(message(error)); return; }
|
||||
inFlight.current = true; setBusy(true); onBusyChange(true); setPending(input); setNotice(input.removed ? 'Removing room…' : 'Restoring room…');
|
||||
try {
|
||||
const result = adminRemovalConfirmation(await requestJson<unknown>(url, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input),
|
||||
}), code, input);
|
||||
clearAdminRemovalRequest(window.sessionStorage, accountId, code);
|
||||
if (alive.current) {
|
||||
setPending(null); setRoom(result.room); setConfirmed(false); setConfirmationCode(''); setReason('');
|
||||
setNotice(result.replayed ? 'The original request was already recorded. Current room availability is shown below.' :
|
||||
input.removed ? 'Room removed from ordinary access. Its saved game and seats are preserved for restoration.' : 'Room restored with its saved game, seats and previous pause and joining settings.');
|
||||
onUpdated();
|
||||
}
|
||||
} catch (error) {
|
||||
let cleared = false;
|
||||
if (!retainAdminRoomRequest(error)) {
|
||||
try { clearAdminRemovalRequest(window.sessionStorage, accountId, code); cleared = true; } catch { /* Keep the exact retry after storage failure. */ }
|
||||
}
|
||||
if (alive.current) {
|
||||
if (cleared) setPending(null);
|
||||
setConfirmed(false); setConfirmationCode('');
|
||||
setNotice(requestMayHaveCompleted(error) ? `${message(error)} The change may have been saved. Use Retry saved request to confirm.` : message(error));
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||
else try {
|
||||
const current = adminRemovalResponse(await requestJson<unknown>(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code);
|
||||
if (alive.current) setRoom(current);
|
||||
} catch { /* Preserve the original outcome and saved retry. */ }
|
||||
}
|
||||
} finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); }
|
||||
}
|
||||
|
||||
function discardUnreadableRecord() {
|
||||
if (!canManage || !room || !confirmed || busy) return;
|
||||
try {
|
||||
clearAdminRemovalRequest(window.sessionStorage, accountId, code); setStorageProblem(false); setConfirmed(false);
|
||||
setNotice('Local retry record removed. This did not undo any server change. Review current room availability before continuing.');
|
||||
} catch (error) { setNotice(message(error)); }
|
||||
}
|
||||
|
||||
return <section className="admin-room-control" aria-labelledby="admin-removal-title" aria-busy={loading || busy}>
|
||||
<div className="admin-section-heading"><h2 id="admin-removal-title" ref={title} tabIndex={-1}>Remove or restore · {code}</h2><Button variant="outline" disabled={busy} onClick={onClose}>Close removal controls</Button></div>
|
||||
{notice && <output className="admin-notice">{notice}</output>}
|
||||
{loading ? <output>Loading room availability…</output> : <>
|
||||
{room ? <>
|
||||
<p>Current availability: <strong>{room.removed ? 'Removed' : 'Active'}</strong>. Saved pause setting: <strong>{room.paused ? 'Paused' : 'Running'}</strong> · New joins <strong>{room.joinLocked ? 'locked' : 'open'}</strong>.</p>
|
||||
{room.removed && <p>Ordinary room access is unavailable. The game, seats and messages remain saved.</p>}
|
||||
{room.removedAt !== null && <p className="admin-secondary">Last removal: <time dateTime={new Date(room.removedAt).toISOString()}>{new Date(room.removedAt).toLocaleString()}</time>.</p>}
|
||||
<Button variant="outline" disabled={busy} onClick={() => void refresh()}>Refresh room availability</Button>
|
||||
</> : <Button disabled={busy} onClick={() => void refresh()}>Retry loading room availability</Button>}
|
||||
{!canManage ? <p>Your viewer role can inspect room availability. An owner or operator can remove or restore rooms.</p> : storageProblem ? <div className="admin-confirm">
|
||||
<p>No new changes can be sent until this tab’s unreadable retry record is resolved. Removing the record does not cancel a change already saved on the server.</p>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy || !room} onChange={e => setConfirmed(e.target.checked)} />I have inspected room {code} and want to remove its local retry record.</label>
|
||||
<Button disabled={busy || !room || !confirmed} onClick={discardUnreadableRecord}>Discard unreadable local record</Button>
|
||||
</div> : pending ? <div className="admin-confirm">
|
||||
<p>Saved request: <strong>{pending.removed ? 'Remove' : 'Restore'} room {code}</strong>.</p><p>Reason: {pending.reason}</p>
|
||||
<p>The exact request stays in this tab across refresh and reopening these controls. Retrying confirms a completed change without applying it again. Current availability can differ if another administrator changed the room afterward.</p>
|
||||
<p>After refreshing the page or signing in again, choose All rooms in the Directory filter to find this room and reopen these controls.</p>
|
||||
<Button disabled={busy} onClick={() => void submit(pending)}>Retry saved request</Button>
|
||||
</div> : room && <form onSubmit={event => { event.preventDefault(); void submit(); }}>
|
||||
<label htmlFor="admin-removal-reason">Reason for the audit history<Input id="admin-removal-reason" value={reason} maxLength={300} required disabled={busy} onChange={e => { setReason(e.target.value); setConfirmed(false); }} /></label>
|
||||
<p className="admin-secondary">Use a short operational reason. Do not include credentials or private game information.</p>
|
||||
<div className="admin-confirm">
|
||||
{room.removed ? <>
|
||||
<p>Restore ordinary access to room <strong>{code}</strong>. Saved progress and seats are retained, and current credentials and access revocations remain in effect.</p>
|
||||
<p>Restoration preserves the previous pause and joining settings. {room.paused ? 'This room will remain paused.' : 'This room will resume with the existing AI pace.'} New joins will remain {room.joinLocked ? 'locked' : 'open'}.</p>
|
||||
</> : <>
|
||||
<p>Remove room <strong>{code}</strong> from ordinary access. This interrupts every player: private room access, joining, game actions, AI play, seat security changes and messages stop until restoration.</p>
|
||||
<p>Warn active players before interrupting their game. This recoverable removal keeps the saved game and seats for restoration.</p>
|
||||
<label htmlFor="admin-removal-code">Type {code} to confirm this room<Input id="admin-removal-code" value={confirmationCode} maxLength={8} autoComplete="off" autoCapitalize="characters" spellCheck={false} disabled={busy} onChange={e => { setConfirmationCode(e.target.value); setConfirmed(false); }} /></label>
|
||||
</>}
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy} onChange={e => setConfirmed(e.target.checked)} />{room.removed ? `I have reviewed room ${code} and its saved pause setting and am ready to restore it.` : `I have checked room ${code}, warned active players, and am ready to remove it.`}</label>
|
||||
<Button type="submit" disabled={busy || !confirmed || !reason.trim() || (!room.removed && confirmationCode !== code)}>{room.removed ? 'Restore room' : 'Remove room'}</Button>
|
||||
</div>
|
||||
</form>}
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export function RemovedRoomNotice({ code, busy, onRetry, onExit, exitDisabled }: { code: string; busy: boolean; onRetry: () => void; onExit: () => void; exitDisabled?: boolean }) {
|
||||
return <section className="notice" aria-labelledby="removed-room-title">
|
||||
<h1 id="removed-room-title">Room {code} has been removed</h1>
|
||||
<p>An administrator removed this room from active play. Its game, saved seats and pending choices are retained for restoration.</p>
|
||||
<p>Only an administrator can restore the room. Restoring it keeps existing seat ownership and access restrictions.</p>
|
||||
<p>Keep this tab open if a seat or room request is unconfirmed. Its exact retry details remain here; check for restoration before continuing.</p>
|
||||
<Button disabled={busy} onClick={onRetry}>{busy ? 'Checking room…' : 'Check for restoration'}</Button>
|
||||
<Button variant="outline" disabled={busy || exitDisabled} onClick={onExit}>Return home</Button>
|
||||
</section>;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type { GameView } from '@/game/engine';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { requestJson, requestMayHaveCompleted } from '@/lib/client-request';
|
||||
import { isRoomRemoved, requestJson, requestMayHaveCompleted } from '@/lib/client-request';
|
||||
import { clearHandoverOwner } from '@/lib/seat-handover';
|
||||
import {
|
||||
createRecoveryAttempt,
|
||||
@@ -96,7 +96,9 @@ export function SeatRecoverySetup({
|
||||
const mayHaveSaved = requestMayHaveCompleted(error);
|
||||
setUncertain(mayHaveSaved);
|
||||
setMessage(
|
||||
mayHaveSaved
|
||||
isRoomRemoved(error)
|
||||
? `${(error as Error).message} Keep this same recovery kit and this page open. Retry saving after the room is restored.`
|
||||
: mayHaveSaved
|
||||
? 'Saving was not confirmed and may have completed. Keep this same kit. Retry saving to confirm it; no retry has been sent automatically.'
|
||||
: `${(error as Error).message} Your kit remains below. Review the current table and retry when ready.`,
|
||||
);
|
||||
@@ -307,7 +309,9 @@ export function SeatRecoveryClaim({
|
||||
setUncertain(mayHaveRecovered);
|
||||
onUncertain?.(mayHaveRecovered);
|
||||
setMessage(
|
||||
mayHaveRecovered
|
||||
isRoomRemoved(error)
|
||||
? `${(error as Error).message} Your exact recovery request is retained. Keep this page open and retry it after restoration.`
|
||||
: mayHaveRecovered
|
||||
? 'Recovery was not confirmed and may have completed. Retry this same request below. Keep this page open to preserve its exact retry details; no retry is automatic.'
|
||||
: `${(error as Error).message} You can retry the same request or inspect a different saved kit.`,
|
||||
);
|
||||
|
||||
@@ -9,10 +9,11 @@ const text = (value: unknown, max = 80) => typeof value === 'string' ? value.sli
|
||||
const integer = (value: unknown) => Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : null;
|
||||
|
||||
/** Only emit allowlisted fields. Never call viewGame with an administrator's or host's seat. */
|
||||
type DirectoryRow = { code: string; version: number; updated_at: number; state: string; control_paused?: number; control_join_locked?: number; control_revision?: number; control_updated_at?: number | null };
|
||||
type DirectoryRow = { code: string; version: number; updated_at: number; state: string; removed?: number; control_paused?: number; control_join_locked?: number; control_revision?: number; control_updated_at?: number | null };
|
||||
export function projectAdminRoom(row: DirectoryRow): AdminRoom {
|
||||
const base: AdminRoom = {
|
||||
code: row.code, version: row.version, updatedAt: row.updated_at,
|
||||
removed: row.removed === 1,
|
||||
status: 'unreadable', host: null, players: [], advanced: false, preview: false,
|
||||
expansions: [], modules: [], turn: null, phase: 'Unavailable',
|
||||
pending: { label: 'Saved room needs inspection', owners: [] },
|
||||
@@ -49,7 +50,7 @@ export function projectAdminRoom(row: DirectoryRow): AdminRoom {
|
||||
}
|
||||
|
||||
export async function readAdminDirectory(database: D1Database, identity: AdminIdentity, search: URLSearchParams, now = Date.now()): Promise<AdminDirectory> {
|
||||
const allowed = ['q', 'status', 'rules', 'sort', 'page', 'availability'];
|
||||
const allowed = ['q', 'status', 'rules', 'sort', 'page', 'availability', 'removal'];
|
||||
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';
|
||||
@@ -57,8 +58,9 @@ export async function readAdminDirectory(database: D1Database, identity: AdminId
|
||||
const sort = search.get('sort') ?? 'recent';
|
||||
const rawPage = search.get('page') ?? '1';
|
||||
const availability = search.get('availability') ?? 'all';
|
||||
const removal = search.get('removal') ?? 'active';
|
||||
if (q.length > 80 || !['all', ...statuses].includes(status) || !['all', 'basic', 'advanced'].includes(rules) ||
|
||||
!['recent', 'oldest', 'code'].includes(sort) || !['all', 'running', 'paused', 'locked'].includes(availability) || !/^[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) || !['active', 'removed', 'all'].includes(removal) || !/^[1-9][0-9]{0,5}$/.test(rawPage)) throw new AdminError('Invalid room filters.', 400);
|
||||
const page = Number(rawPage), pageSize = 25;
|
||||
// json_valid protects the rest of the directory if a legacy save is malformed.
|
||||
const state = "CASE WHEN json_valid(state) THEN state ELSE '{}' END";
|
||||
@@ -82,13 +84,14 @@ export async function readAdminDirectory(database: D1Database, identity: AdminId
|
||||
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');
|
||||
if (removal !== 'all') { where.push('COALESCE(m.removed,0) = ?'); args.push(removal === 'removed' ? 1 : 0); }
|
||||
const predicate = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
||||
const order = sort === 'code' ? 'r.code ASC' : `r.updated_at ${sort === 'oldest' ? 'ASC' : 'DESC'}, r.code ASC`;
|
||||
const source = 'FROM rooms r LEFT JOIN room_controls c ON c.room_code = r.code';
|
||||
const source = 'FROM rooms r LEFT JOIN room_controls c ON c.room_code = r.code LEFT JOIN room_removals m ON m.room_code = r.code';
|
||||
const results = await database.batch([
|
||||
database.prepare(authority).bind(...credentials),
|
||||
database.prepare(`SELECT COUNT(*) AS total ${source} ${predicate}`).bind(...args),
|
||||
database.prepare(`SELECT r.code,r.version,r.updated_at,r.state,COALESCE(c.paused,0) AS control_paused,COALESCE(c.join_locked,0) AS control_join_locked,COALESCE(c.revision,0) AS control_revision,c.updated_at AS control_updated_at ${source} ${predicate} ORDER BY ${order} LIMIT ? OFFSET ?`).bind(...args, pageSize, (page - 1) * pageSize),
|
||||
database.prepare(`SELECT r.code,r.version,r.updated_at,r.state,COALESCE(m.removed,0) AS removed,COALESCE(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);
|
||||
|
||||
@@ -92,6 +92,7 @@ export async function applyAdminRoomControl(
|
||||
SELECT ?,?,r.code,?,?,COALESCE(c.paused,0),COALESCE(c.join_locked,0),?,?,?
|
||||
FROM rooms r LEFT JOIN room_controls c ON c.room_code = r.code
|
||||
WHERE r.code = ? AND COALESCE(c.revision,0) = ? AND EXISTS (${mutationAuthority})
|
||||
AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1)
|
||||
AND NOT EXISTS (SELECT 1 FROM admin_room_audit WHERE operation_id = ?)`)
|
||||
.bind(
|
||||
input.operationId,
|
||||
|
||||
@@ -18,6 +18,7 @@ const authority = `SELECT a.role FROM admin_sessions s JOIN admin_accounts a ON
|
||||
AND a.role IN ('owner','operator','viewer')`;
|
||||
const mutationAuthority = authority + " AND a.role IN ('owner','operator')";
|
||||
const roomSelect = `SELECT r.code,r.state,r.version,COALESCE(c.paused,0) AS paused,
|
||||
EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1) AS removed,
|
||||
COALESCE(c.join_locked,0) AS join_locked,COALESCE(c.revision,0) AS control_revision,c.updated_at AS control_updated_at,
|
||||
(SELECT json_group_array(DISTINCT s.player_id) FROM seats s WHERE s.room_code = r.code AND s.revoked = 0) AS active_humans
|
||||
FROM rooms r LEFT JOIN room_controls c ON c.room_code = r.code WHERE r.code = ? AND EXISTS (${authority})`;
|
||||
@@ -27,6 +28,7 @@ type Row = {
|
||||
code: string;
|
||||
state: string;
|
||||
version: number;
|
||||
removed: number;
|
||||
paused: number;
|
||||
join_locked: number;
|
||||
control_revision: number;
|
||||
@@ -80,10 +82,22 @@ function decode(row: Row) {
|
||||
revision: row.control_revision,
|
||||
updatedAt: row.control_updated_at,
|
||||
};
|
||||
const view = projectAdminLobby(
|
||||
row.code,
|
||||
row.version,
|
||||
game,
|
||||
activeHumans,
|
||||
control,
|
||||
);
|
||||
if (row.removed === 1) {
|
||||
view.editable = false;
|
||||
view.blockedReason =
|
||||
'Restore this removed room before configuring its lobby.';
|
||||
}
|
||||
return {
|
||||
game: game as Game,
|
||||
activeHumans,
|
||||
view: projectAdminLobby(row.code, row.version, game, activeHumans, control),
|
||||
view,
|
||||
};
|
||||
}
|
||||
function missing(code: string, version: number): AdminLobbyView {
|
||||
@@ -246,6 +260,7 @@ export async function configureAdminLobby(
|
||||
.prepare(`UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ?
|
||||
AND json_extract(state, '$.status') = 'lobby' AND EXISTS (${mutationAuthority})
|
||||
AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)
|
||||
AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1)
|
||||
AND NOT EXISTS (SELECT 1 FROM admin_lobby_operations WHERE operation_id = ?)
|
||||
AND (? = '' OR EXISTS (SELECT 1 FROM seats WHERE room_code = rooms.code AND player_id = ? AND revoked = 0))`)
|
||||
.bind(
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { AdminError, type AdminIdentity } from './admin-access';
|
||||
import {
|
||||
validAdminRemovalInput,
|
||||
type AdminRemovalInput,
|
||||
type AdminRemovalResult,
|
||||
type AdminRemovalView,
|
||||
} from '../lib/admin-removal';
|
||||
|
||||
const authority = `SELECT a.role FROM admin_sessions s JOIN admin_accounts a ON a.id = s.admin_id
|
||||
WHERE s.token_hash = ? AND a.id = ? AND a.enabled = 1 AND s.revoked_at IS NULL
|
||||
AND s.expires_at > ? AND s.generation = a.session_generation
|
||||
AND a.role IN ('owner','operator','viewer')`;
|
||||
const mutationAuthority = authority + " AND a.role IN ('owner','operator')";
|
||||
const roomSelect = `SELECT r.code,r.version,COALESCE(m.removed,0) AS removed,
|
||||
COALESCE(m.revision,0) AS revision,m.removed_at,m.updated_at,
|
||||
COALESCE(c.paused,0) AS paused,COALESCE(c.join_locked,0) AS join_locked
|
||||
FROM rooms r LEFT JOIN room_removals m ON m.room_code = r.code
|
||||
LEFT JOIN room_controls c ON c.room_code = r.code
|
||||
WHERE r.code = ? AND EXISTS (${authority})`;
|
||||
type Row = {
|
||||
code: string;
|
||||
version: number;
|
||||
removed: number;
|
||||
revision: number;
|
||||
removed_at: number | null;
|
||||
updated_at: number | null;
|
||||
paused: number;
|
||||
join_locked: number;
|
||||
};
|
||||
type Receipt = {
|
||||
operation_id: string;
|
||||
actor_admin_id: string;
|
||||
room_code: string;
|
||||
request_hash: string;
|
||||
applied_version: number;
|
||||
applied_revision: number;
|
||||
};
|
||||
const project = (row: Row): AdminRemovalView => ({
|
||||
code: row.code,
|
||||
version: row.version,
|
||||
removed: row.removed === 1,
|
||||
revision: row.revision,
|
||||
removedAt: row.removed_at,
|
||||
updatedAt: row.updated_at,
|
||||
paused: row.paused === 1,
|
||||
joinLocked: row.join_locked === 1,
|
||||
});
|
||||
function credentials(identity: AdminIdentity, code: string, now: number) {
|
||||
if (
|
||||
!identity ||
|
||||
typeof identity.id !== 'string' ||
|
||||
typeof identity.sessionHash !== 'string' ||
|
||||
identity.sessionHash.length !== 64 ||
|
||||
!/^[0-9a-f]{64}$/.test(identity.sessionHash)
|
||||
)
|
||||
throw new AdminError('Administrator sign-in required.', 401);
|
||||
if (
|
||||
typeof code !== 'string' ||
|
||||
code.length !== 8 ||
|
||||
!/^[A-Z0-9]{8}$/.test(code)
|
||||
)
|
||||
throw new AdminError('Use an eight-character room code.', 400);
|
||||
return [identity.sessionHash, identity.id, now];
|
||||
}
|
||||
function authorize(result: D1Result, mutation = false) {
|
||||
const role = (result.results[0] as { role: string } | undefined)?.role;
|
||||
if (!role) throw new AdminError('Administrator sign-in required.', 401);
|
||||
if (mutation && role === 'viewer')
|
||||
throw new AdminError(
|
||||
'Your administrator role does not permit this action.',
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
/** Public operational metadata remains available for removed and unreadable saves. */
|
||||
export async function readAdminRemoval(
|
||||
database: D1Database,
|
||||
identity: AdminIdentity,
|
||||
code: string,
|
||||
now = Date.now(),
|
||||
): Promise<AdminRemovalView> {
|
||||
const args = credentials(identity, code, now);
|
||||
const results = await database.batch([
|
||||
database.prepare(authority).bind(...args),
|
||||
database.prepare(roomSelect).bind(code, ...args),
|
||||
]);
|
||||
authorize(results[0]);
|
||||
const row = results[1].results[0] as Row | undefined;
|
||||
if (!row) throw new AdminError('Room not found.', 404);
|
||||
return project(row);
|
||||
}
|
||||
|
||||
/** An exact receipt, room-version fence and removal transition commit together.
|
||||
* Neither transition replaces state or credentials; restore only refreshes AI pacing. */
|
||||
export async function applyAdminRemoval(
|
||||
database: D1Database,
|
||||
identity: AdminIdentity,
|
||||
code: string,
|
||||
input: AdminRemovalInput,
|
||||
now = Date.now(),
|
||||
): Promise<AdminRemovalResult> {
|
||||
const args = credentials(identity, code, now);
|
||||
if (!validAdminRemovalInput(input))
|
||||
throw new AdminError(
|
||||
'Provide the room version, removal revision, requested state, a fresh operation identifier and a reason of 1–300 characters.',
|
||||
400,
|
||||
);
|
||||
const { operationId, expectedVersion, expectedRevision, removed } = input;
|
||||
const actorId = identity.id,
|
||||
reason = input.reason.trim();
|
||||
const digest = await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({ expectedVersion, expectedRevision, removed, reason }),
|
||||
),
|
||||
);
|
||||
const requestHash = Array.from(new Uint8Array(digest), (byte) =>
|
||||
byte.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
const results = await database.batch([
|
||||
database.prepare(authority).bind(...args),
|
||||
// Existing receipt IDs cannot enter this write, regardless of later room state.
|
||||
database
|
||||
.prepare(`INSERT INTO admin_room_removals
|
||||
(operation_id,actor_admin_id,room_code,request_hash,expected_version,expected_revision,
|
||||
applied_version,applied_revision,removed,reason,before_metadata,after_metadata,created_at)
|
||||
SELECT ?,?,r.code,?,?,?,r.version + 1,COALESCE(m.revision,0) + 1,?,?,
|
||||
json_object('code',r.code,'version',r.version,'removed',json(CASE WHEN m.removed = 1 THEN 'true' ELSE 'false' END),
|
||||
'revision',COALESCE(m.revision,0),'removedAt',m.removed_at,'updatedAt',m.updated_at,
|
||||
'paused',json(CASE WHEN c.paused = 1 THEN 'true' ELSE 'false' END),
|
||||
'joinLocked',json(CASE WHEN c.join_locked = 1 THEN 'true' ELSE 'false' END)),
|
||||
json_object('code',r.code,'version',r.version + 1,'removed',json(CASE WHEN ? = 1 THEN 'true' ELSE 'false' END),
|
||||
'revision',COALESCE(m.revision,0) + 1,'removedAt',?,'updatedAt',?,
|
||||
'paused',json(CASE WHEN c.paused = 1 THEN 'true' ELSE 'false' END),
|
||||
'joinLocked',json(CASE WHEN c.join_locked = 1 THEN 'true' ELSE 'false' END)),?
|
||||
FROM rooms r LEFT JOIN room_removals m ON m.room_code = r.code
|
||||
LEFT JOIN room_controls c ON c.room_code = r.code
|
||||
WHERE r.code = ? AND r.version = ? AND COALESCE(m.revision,0) = ? AND COALESCE(m.removed,0) != ?
|
||||
AND EXISTS (${mutationAuthority})
|
||||
AND NOT EXISTS (SELECT 1 FROM admin_room_removals WHERE operation_id = ?)`)
|
||||
.bind(
|
||||
operationId,
|
||||
actorId,
|
||||
requestHash,
|
||||
expectedVersion,
|
||||
expectedRevision,
|
||||
Number(removed),
|
||||
reason,
|
||||
Number(removed),
|
||||
removed ? now : null,
|
||||
now,
|
||||
now,
|
||||
code,
|
||||
expectedVersion,
|
||||
expectedRevision,
|
||||
Number(removed),
|
||||
...args,
|
||||
operationId,
|
||||
),
|
||||
// Removing never parses or changes JSON. Restore tolerates malformed saved state.
|
||||
database
|
||||
.prepare(`UPDATE rooms SET version = version + 1,
|
||||
state = CASE WHEN ? = 0 AND NOT EXISTS (
|
||||
SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1
|
||||
) THEN CASE WHEN json_valid(state) THEN CASE WHEN
|
||||
json_extract(state, '$.status') IN ('setup','playing') AND
|
||||
EXISTS (SELECT 1 FROM json_each(CASE WHEN json_type(state, '$.players') = 'array' THEN json_extract(state, '$.players') ELSE '[]' END) p
|
||||
WHERE json_extract(CASE WHEN p.type = 'object' THEN p.value ELSE '{}' END, '$.bot') IS NOT NULL
|
||||
OR json_extract(CASE WHEN p.type = 'object' THEN p.value ELSE '{}' END, '$.autopilot') IS NOT NULL)
|
||||
THEN json_set(state, '$.botsPending', json('true'), '$.botNextActionAt', ?)
|
||||
ELSE state END ELSE state END ELSE state END
|
||||
WHERE code = ? AND changes() = 1`)
|
||||
.bind(Number(removed), now + 1500, code),
|
||||
database
|
||||
.prepare(`INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at)
|
||||
SELECT room_code,removed,applied_revision,CASE WHEN removed = 1 THEN created_at ELSE NULL END,created_at
|
||||
FROM admin_room_removals WHERE operation_id = ? AND changes() = 1
|
||||
ON CONFLICT(room_code) DO UPDATE SET removed = excluded.removed,revision = excluded.revision,
|
||||
removed_at = excluded.removed_at,updated_at = excluded.updated_at`)
|
||||
.bind(operationId),
|
||||
database
|
||||
.prepare(`SELECT operation_id,actor_admin_id,room_code,request_hash,applied_version,applied_revision
|
||||
FROM admin_room_removals WHERE operation_id = ? AND EXISTS (${mutationAuthority})`)
|
||||
.bind(operationId, ...args),
|
||||
database.prepare(roomSelect).bind(code, ...args),
|
||||
]);
|
||||
authorize(results[0], true);
|
||||
const receipt = results[4].results[0] as Receipt | undefined;
|
||||
const row = results[5].results[0] as Row | undefined;
|
||||
if (receipt) {
|
||||
if (
|
||||
receipt.actor_admin_id !== actorId ||
|
||||
receipt.room_code !== code ||
|
||||
receipt.request_hash !== requestHash
|
||||
)
|
||||
throw new AdminError(
|
||||
'This removal operation is bound to a different administrator or request.',
|
||||
409,
|
||||
);
|
||||
if (!row) throw new AdminError('Room not found.', 404);
|
||||
return {
|
||||
operationId,
|
||||
replayed: results[1].meta.changes !== 1,
|
||||
appliedRevision: receipt.applied_revision,
|
||||
appliedVersion: receipt.applied_version,
|
||||
room: project(row),
|
||||
};
|
||||
}
|
||||
if (!row) throw new AdminError('Room not found.', 404);
|
||||
if (row.version !== expectedVersion || row.revision !== expectedRevision)
|
||||
throw new AdminError(
|
||||
'The room or removal settings changed. Refresh before choosing a new operation.',
|
||||
409,
|
||||
);
|
||||
throw new AdminError(
|
||||
removed
|
||||
? 'This room is already removed.'
|
||||
: 'This room is already available.',
|
||||
409,
|
||||
);
|
||||
}
|
||||
@@ -137,8 +137,10 @@ export async function createAdminRoom(
|
||||
),
|
||||
database
|
||||
.prepare(`SELECT c.operation_id,c.actor_admin_id,c.room_code,c.host_id,c.request_hash,c.session_hash,
|
||||
EXISTS (SELECT 1 FROM room_removals WHERE room_code = c.room_code AND removed = 1) AS room_removed,
|
||||
EXISTS (SELECT 1 FROM seats s JOIN rooms r ON r.code = s.room_code
|
||||
WHERE s.token_hash = c.session_hash AND s.room_code = c.room_code AND s.player_id = c.host_id AND s.revoked = 0
|
||||
AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1)
|
||||
AND CASE WHEN json_valid(r.state) THEN EXISTS (
|
||||
SELECT 1 FROM json_each(CASE WHEN json_type(r.state, '$.players') = 'array' THEN json_extract(r.state, '$.players') ELSE '[]' END) p
|
||||
WHERE json_extract(CASE WHEN p.type = 'object' THEN p.value ELSE '{}' END, '$.id') = c.host_id
|
||||
@@ -168,6 +170,7 @@ export async function createAdminRoom(
|
||||
request_hash: string;
|
||||
session_hash: string;
|
||||
host_access: number;
|
||||
room_removed: number;
|
||||
}
|
||||
| undefined;
|
||||
if (receipt) {
|
||||
@@ -186,6 +189,7 @@ export async function createAdminRoom(
|
||||
hostId: receipt.host_id,
|
||||
replayed: results[1].meta.changes !== 1,
|
||||
hostAccess: receipt.host_access === 1,
|
||||
...(receipt.room_removed === 1 ? { roomRemoved: true as const } : {}),
|
||||
};
|
||||
}
|
||||
const conflict = results[5].results[0] as {
|
||||
|
||||
+112
-34
@@ -66,6 +66,25 @@ export class RoomEntryError extends RuleError {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
/** Temporary room unavailability must never invalidate a saved seat or retry proof. */
|
||||
export class RoomRemovedError extends RoomEntryError {
|
||||
constructor() {
|
||||
super(
|
||||
'An administrator recoverably removed this room. Keep your saved seat and retry details; access returns if the room is restored.',
|
||||
'ROOM_REMOVED',
|
||||
410,
|
||||
);
|
||||
}
|
||||
}
|
||||
async function requireRoomNotRemoved(code: string) {
|
||||
const removed = await db()
|
||||
.prepare(
|
||||
'SELECT 1 AS removed FROM room_removals WHERE room_code = ? AND removed = 1',
|
||||
)
|
||||
.bind(code)
|
||||
.first();
|
||||
if (removed) throw new RoomRemovedError();
|
||||
}
|
||||
type PreparedEntry = {
|
||||
operationId: string;
|
||||
operationHash: string;
|
||||
@@ -150,7 +169,8 @@ async function replayEntry(
|
||||
entryReceipt: { operationId: entry.operationId, replayed: true },
|
||||
};
|
||||
} catch (error) {
|
||||
if (!(error instanceof RuleError)) throw error;
|
||||
if (error instanceof RoomRemovedError || !(error instanceof RuleError))
|
||||
throw error;
|
||||
throw new RoomEntryError(
|
||||
'This room-entry session is no longer active. Use the saved seat recovery kit.',
|
||||
'ENTRY_RECEIPT_INVALID',
|
||||
@@ -258,20 +278,28 @@ function attachRoomControl(view: GameView, row: RoomControlRow) {
|
||||
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 = ?`)
|
||||
FROM rooms LEFT JOIN room_controls c ON c.room_code = rooms.code WHERE rooms.code = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1)`)
|
||||
.bind(code)
|
||||
.first<{ state: string; version: number } & RoomControlRow>();
|
||||
if (!row) throw new RuleError('Room not found. Check your invite code.');
|
||||
if (!row) {
|
||||
await requireRoomNotRemoved(code);
|
||||
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 = ?')
|
||||
.prepare(
|
||||
'SELECT state,version FROM rooms WHERE code = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1)',
|
||||
)
|
||||
.bind(code)
|
||||
.first<{ state: string; version: number }>();
|
||||
if (!row) throw new RuleError('Room not found. Check your invite code.');
|
||||
if (!row) {
|
||||
await requireRoomNotRemoved(code);
|
||||
throw new RuleError('Room not found. Check your invite code.');
|
||||
}
|
||||
const game: Game = JSON.parse(row.state);
|
||||
game.version = row.version;
|
||||
return game;
|
||||
@@ -287,15 +315,21 @@ export async function authenticate(
|
||||
code: string,
|
||||
token: string,
|
||||
): Promise<SeatAuth> {
|
||||
if (!token) throw new RuleError('Join this room first.');
|
||||
if (!token) {
|
||||
await requireRoomNotRemoved(code);
|
||||
throw new RuleError('Join this room first.');
|
||||
}
|
||||
const tokenHash = await hash(token);
|
||||
const seat = await db()
|
||||
.prepare(
|
||||
'SELECT player_id FROM seats WHERE token_hash = ? AND room_code = ? AND revoked = 0',
|
||||
'SELECT player_id FROM seats WHERE token_hash = ? AND room_code = ? AND revoked = 0 AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = seats.room_code AND removed = 1)',
|
||||
)
|
||||
.bind(tokenHash, code)
|
||||
.first<{ player_id: string }>();
|
||||
if (!seat) throw new RuleError('Your seat could not be verified.');
|
||||
if (!seat) {
|
||||
await requireRoomNotRemoved(code);
|
||||
throw new RuleError('Your seat could not be verified.');
|
||||
}
|
||||
return { playerId: seat.player_id, tokenHash };
|
||||
}
|
||||
export async function joinRoom(
|
||||
@@ -324,7 +358,8 @@ export async function joinRoom(
|
||||
alreadySeated: true,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!(error instanceof RuleError)) throw error;
|
||||
if (error instanceof RoomRemovedError || !(error instanceof RuleError))
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await requireUnusedEntryToken(entry);
|
||||
@@ -345,7 +380,7 @@ export async function joinRoom(
|
||||
const results = await db().batch([
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND (paused = 1 OR join_locked = 1))',
|
||||
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND (paused = 1 OR join_locked = 1))',
|
||||
)
|
||||
.bind(JSON.stringify(g), Date.now(), code, old),
|
||||
db()
|
||||
@@ -369,11 +404,13 @@ export async function joinRoom(
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
if (results[0].meta.changes !== 1)
|
||||
if (results[0].meta.changes !== 1) {
|
||||
await requireRoomNotRemoved(code);
|
||||
throw new RoomEntryError(
|
||||
'The table changed. Retry the same room-entry request.',
|
||||
'ENTRY_CONFLICT',
|
||||
);
|
||||
}
|
||||
return {
|
||||
token,
|
||||
view: await readSeatView(code, { playerId: id, tokenHash }),
|
||||
@@ -419,7 +456,7 @@ export async function act(
|
||||
const now = clock.now();
|
||||
const update = db()
|
||||
.prepare(
|
||||
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND (? = 1 OR NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0)',
|
||||
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND (? = 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),
|
||||
@@ -442,10 +479,12 @@ export async function act(
|
||||
.bind(now, code, auth.playerId, auth.tokenHash),
|
||||
])
|
||||
: [await update.run()];
|
||||
if (results[0].meta.changes !== 1)
|
||||
if (results[0].meta.changes !== 1) {
|
||||
await requireRoomNotRemoved(code);
|
||||
throw new RuleError(
|
||||
'Another action arrived first. Your view has refreshed; try again.',
|
||||
);
|
||||
}
|
||||
return readSeatView(code, auth);
|
||||
}
|
||||
|
||||
@@ -484,7 +523,7 @@ export async function readSeatView(code: string, auth: SeatAuth) {
|
||||
AND delegate_session.token_hash = delegations.delegate_session_hash
|
||||
AND delegate_session.revoked = 0
|
||||
)
|
||||
WHERE rooms.code = ? AND viewer.player_id = ?
|
||||
WHERE rooms.code = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND viewer.player_id = ?
|
||||
AND viewer.token_hash = ? AND viewer.revoked = 0
|
||||
GROUP BY rooms.code, rooms.state, rooms.version`,
|
||||
)
|
||||
@@ -496,7 +535,10 @@ export async function readSeatView(code: string, auth: SeatAuth) {
|
||||
seat_ai_delegations: string | null;
|
||||
}
|
||||
>();
|
||||
if (!row) throw new RuleError('Your seat could not be verified.');
|
||||
if (!row) {
|
||||
await requireRoomNotRemoved(code);
|
||||
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;
|
||||
@@ -540,13 +582,24 @@ export function needsAutomaticRoomRecovery(
|
||||
);
|
||||
}
|
||||
|
||||
async function readWorkerRoomSnapshot(code: string) {
|
||||
try {
|
||||
return await readRoomSnapshot(code);
|
||||
} catch (error) {
|
||||
if (error instanceof RoomRemovedError) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist an old automatic continuation through the same version fence as current actions. */
|
||||
export async function continueRoomAutomatic(
|
||||
code: string,
|
||||
clock: RoomsClock = roomsClock,
|
||||
) {
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
const { game: current, control } = await readRoomSnapshot(code);
|
||||
const snapshot = await readWorkerRoomSnapshot(code);
|
||||
if (!snapshot) return;
|
||||
const { game: current, control } = snapshot;
|
||||
if (control.paused) return;
|
||||
if (
|
||||
!needsAutomaticRoomRecovery(current) ||
|
||||
@@ -562,7 +615,7 @@ export async function continueRoomAutomatic(
|
||||
next.version = current.version + 1;
|
||||
const result = await db()
|
||||
.prepare(
|
||||
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)',
|
||||
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)',
|
||||
)
|
||||
.bind(JSON.stringify(next), clock.now(), code, current.version)
|
||||
.run();
|
||||
@@ -580,7 +633,8 @@ export async function continueRoomBots(
|
||||
clock: RoomsClock = roomsClock,
|
||||
) {
|
||||
for (let batch = 0; batch < batches; batch++) {
|
||||
let snapshot = await readRoomSnapshot(code);
|
||||
let snapshot = await readWorkerRoomSnapshot(code);
|
||||
if (!snapshot) return;
|
||||
let current = snapshot.game;
|
||||
if (
|
||||
snapshot.control.paused ||
|
||||
@@ -592,7 +646,8 @@ export async function continueRoomBots(
|
||||
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.
|
||||
snapshot = await readRoomSnapshot(code);
|
||||
snapshot = await readWorkerRoomSnapshot(code);
|
||||
if (!snapshot) return;
|
||||
current = snapshot.game;
|
||||
if (
|
||||
snapshot.control.paused ||
|
||||
@@ -611,7 +666,7 @@ export async function continueRoomBots(
|
||||
next.version = current.version + 1;
|
||||
const result = await db()
|
||||
.prepare(
|
||||
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)',
|
||||
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)',
|
||||
)
|
||||
.bind(JSON.stringify(next), clock.now(), code, current.version)
|
||||
.run();
|
||||
@@ -703,6 +758,7 @@ export async function setSeatAiDelegate(
|
||||
input: SetSeatAiDelegateInput,
|
||||
clock: ControlClock = roomsClock,
|
||||
) {
|
||||
await requireRoomNotRemoved(code);
|
||||
controlVersion(version);
|
||||
if (!validSetSeatAiDelegateInput(input))
|
||||
throw invalidDelegation(
|
||||
@@ -769,7 +825,7 @@ export async function setSeatAiDelegate(
|
||||
db()
|
||||
.prepare(
|
||||
`UPDATE rooms SET version = version + 1, updated_at = ?
|
||||
WHERE code = ? AND version = ?
|
||||
WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1)
|
||||
AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)
|
||||
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)
|
||||
@@ -820,6 +876,7 @@ export async function setSeatAiDelegate(
|
||||
.bind(now, code, owner.id, input.grantId, code, owner.id, input.grantId),
|
||||
]);
|
||||
if (results[0].meta.changes !== 1 || results[1].meta.changes !== 1) {
|
||||
await requireRoomNotRemoved(code);
|
||||
const won = await seatAiDelegation(input.grantId);
|
||||
if (
|
||||
won &&
|
||||
@@ -847,6 +904,7 @@ export async function revokeSeatAiDelegate(
|
||||
grantId: unknown,
|
||||
clock: ControlClock = roomsClock,
|
||||
) {
|
||||
await requireRoomNotRemoved(code);
|
||||
controlVersion(version);
|
||||
if (!validSeatAiGrantId(grantId))
|
||||
throw invalidDelegation('Choose the current AI delegation identifier.');
|
||||
@@ -878,7 +936,7 @@ export async function revokeSeatAiDelegate(
|
||||
db()
|
||||
.prepare(
|
||||
`UPDATE rooms SET version = version + 1, updated_at = ?
|
||||
WHERE code = ? AND version = ?
|
||||
WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1)
|
||||
AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM seat_ai_delegations AS consent
|
||||
@@ -913,11 +971,13 @@ export async function revokeSeatAiDelegate(
|
||||
)
|
||||
.bind(now, code, auth.playerId, grantId, auth.tokenHash),
|
||||
]);
|
||||
if (results[0].meta.changes !== 1 || results[1].meta.changes !== 1)
|
||||
if (results[0].meta.changes !== 1 || results[1].meta.changes !== 1) {
|
||||
await requireRoomNotRemoved(code);
|
||||
throw invalidDelegation(
|
||||
'The table, owner, delegate or consent changed. Reconnect before revoking it.',
|
||||
'STALE_VERSION',
|
||||
);
|
||||
}
|
||||
return { view: await readSeatView(code, auth), replayed: false };
|
||||
}
|
||||
|
||||
@@ -928,6 +988,7 @@ export async function useSeatAiDelegate(
|
||||
input: UseSeatAiDelegateInput,
|
||||
clock: RoomsClock = roomsClock,
|
||||
): Promise<{ view: GameView; replayed?: boolean }> {
|
||||
await requireRoomNotRemoved(code);
|
||||
controlVersion(version);
|
||||
if (!validUseSeatAiDelegateInput(input))
|
||||
throw invalidDelegation('Choose the current owner and AI delegation.');
|
||||
@@ -999,7 +1060,7 @@ export async function useSeatAiDelegate(
|
||||
db()
|
||||
.prepare(
|
||||
`UPDATE rooms SET state = ?, version = version + 1, updated_at = ?
|
||||
WHERE code = ? AND version = ?
|
||||
WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1)
|
||||
AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM seat_ai_delegations AS consent
|
||||
@@ -1053,6 +1114,7 @@ export async function useSeatAiDelegate(
|
||||
),
|
||||
]);
|
||||
if (results[0].meta.changes !== 1 || results[1].meta.changes !== 1) {
|
||||
await requireRoomNotRemoved(code);
|
||||
const won = await seatAiDelegation(input.grantId);
|
||||
if (
|
||||
won?.used_at !== null &&
|
||||
@@ -1077,6 +1139,7 @@ export async function setRecoveryKey(
|
||||
version: unknown,
|
||||
secret: unknown,
|
||||
) {
|
||||
await requireRoomNotRemoved(code);
|
||||
controlVersion(version);
|
||||
const recoveryHash = await secretHash(secret);
|
||||
if (recoveryHash === auth.tokenHash)
|
||||
@@ -1088,7 +1151,7 @@ export async function setRecoveryKey(
|
||||
const results = await db().batch([
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE rooms SET 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 version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0)',
|
||||
)
|
||||
.bind(Date.now(), code, version, code, auth.playerId, auth.tokenHash),
|
||||
db()
|
||||
@@ -1097,11 +1160,13 @@ export async function setRecoveryKey(
|
||||
)
|
||||
.bind(code, auth.playerId, recoveryHash),
|
||||
]);
|
||||
if (results[0].meta.changes !== 1)
|
||||
if (results[0].meta.changes !== 1) {
|
||||
await requireRoomNotRemoved(code);
|
||||
throw new SeatControlError(
|
||||
'The table or seat changed. Reconnect before saving the recovery key.',
|
||||
'STALE_VERSION',
|
||||
);
|
||||
}
|
||||
return {
|
||||
view: await readSeatView(code, auth),
|
||||
recoveryConfigured: true as const,
|
||||
@@ -1117,6 +1182,7 @@ export async function recoverSeat(
|
||||
newSessionToken: unknown;
|
||||
},
|
||||
) {
|
||||
await requireRoomNotRemoved(code);
|
||||
if (
|
||||
typeof input.playerId !== 'string' ||
|
||||
!input.playerId ||
|
||||
@@ -1169,15 +1235,17 @@ 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, ${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 = ?`,
|
||||
`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 NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) 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 } & RoomControlRow>();
|
||||
if (!row)
|
||||
if (!row) {
|
||||
await requireRoomNotRemoved(code);
|
||||
throw new SeatControlError(
|
||||
'This recovery receipt is no longer active.',
|
||||
'RECOVERY_RECEIPT_INVALID',
|
||||
);
|
||||
}
|
||||
const g: Game = JSON.parse(row.state);
|
||||
g.version = row.version;
|
||||
const view = viewGame(g, playerId) as GameView;
|
||||
@@ -1203,7 +1271,7 @@ export async function recoverSeat(
|
||||
const results = await db().batch([
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND EXISTS (SELECT 1 FROM seat_recovery_keys WHERE room_code = ? AND player_id = ? AND recovery_hash = ?) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND revoked = 0) AND NOT EXISTS (SELECT 1 FROM seat_recovery_receipts WHERE room_code = ? AND player_id = ? AND operation_hash = ?) AND NOT EXISTS (SELECT 1 FROM seats WHERE token_hash = ?)',
|
||||
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND EXISTS (SELECT 1 FROM seat_recovery_keys WHERE room_code = ? AND player_id = ? AND recovery_hash = ?) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND revoked = 0) AND NOT EXISTS (SELECT 1 FROM seat_recovery_receipts WHERE room_code = ? AND player_id = ? AND operation_hash = ?) AND NOT EXISTS (SELECT 1 FROM seats WHERE token_hash = ?)',
|
||||
)
|
||||
.bind(
|
||||
Date.now(),
|
||||
@@ -1241,6 +1309,7 @@ export async function recoverSeat(
|
||||
.bind(operationHash, code, playerId),
|
||||
]);
|
||||
if (results[0].meta.changes !== 1) {
|
||||
await requireRoomNotRemoved(code);
|
||||
// A concurrent exact request may have completed: retrying the same proof is safe.
|
||||
throw new SeatControlError(
|
||||
'The table or recovery operation changed. Retry the exact recovery request.',
|
||||
@@ -1318,6 +1387,7 @@ export async function createSeatHandover(
|
||||
input: { offerId: unknown; handoverSecret: unknown },
|
||||
clock: HandoverClock = roomsClock,
|
||||
) {
|
||||
await requireRoomNotRemoved(code);
|
||||
controlVersion(version);
|
||||
const offerId = controlUuid(
|
||||
input.offerId,
|
||||
@@ -1347,7 +1417,7 @@ export async function createSeatHandover(
|
||||
const results = await db().batch([
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE rooms SET 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) AND NOT EXISTS (SELECT 1 FROM seat_handover_offers WHERE offer_hash = ? AND (room_code != ? OR player_id != ?))',
|
||||
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0) AND NOT EXISTS (SELECT 1 FROM seat_handover_offers WHERE offer_hash = ? AND (room_code != ? OR player_id != ?))',
|
||||
)
|
||||
.bind(
|
||||
now,
|
||||
@@ -1374,6 +1444,7 @@ export async function createSeatHandover(
|
||||
),
|
||||
]);
|
||||
if (results[0].meta.changes !== 1) {
|
||||
await requireRoomNotRemoved(code);
|
||||
const won = await replayCreatedHandover(
|
||||
code,
|
||||
auth,
|
||||
@@ -1412,6 +1483,7 @@ export async function revokeSeatHandover(
|
||||
offerIdValue: unknown,
|
||||
clock: HandoverClock = roomsClock,
|
||||
) {
|
||||
await requireRoomNotRemoved(code);
|
||||
controlVersion(version);
|
||||
const offerHash = await hash(
|
||||
controlUuid(offerIdValue, 'Choose the handover offer to revoke.'),
|
||||
@@ -1432,7 +1504,7 @@ export async function revokeSeatHandover(
|
||||
const results = await db().batch([
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE rooms SET 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) AND EXISTS (SELECT 1 FROM seat_handover_offers WHERE room_code = ? AND player_id = ? AND offer_hash = ? AND claim_operation_hash IS NULL)',
|
||||
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0) AND EXISTS (SELECT 1 FROM seat_handover_offers WHERE room_code = ? AND player_id = ? AND offer_hash = ? AND claim_operation_hash IS NULL)',
|
||||
)
|
||||
.bind(
|
||||
clock.now(),
|
||||
@@ -1452,6 +1524,7 @@ export async function revokeSeatHandover(
|
||||
.bind(code, auth.playerId, offerHash),
|
||||
]);
|
||||
if (results[0].meta.changes !== 1) {
|
||||
await requireRoomNotRemoved(code);
|
||||
const current = await handoverOffer(code, auth.playerId);
|
||||
if (!current)
|
||||
return {
|
||||
@@ -1499,7 +1572,7 @@ async function replayClaimedHandover(
|
||||
) {
|
||||
const row = await db()
|
||||
.prepare(
|
||||
`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`,
|
||||
`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 NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) 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,
|
||||
@@ -1511,7 +1584,10 @@ async function replayClaimedHandover(
|
||||
sessionHash,
|
||||
)
|
||||
.first<{ state: string; version: number } & RoomControlRow>();
|
||||
if (!row) return null;
|
||||
if (!row) {
|
||||
await requireRoomNotRemoved(code);
|
||||
return null;
|
||||
}
|
||||
const game: Game = JSON.parse(row.state);
|
||||
game.version = row.version;
|
||||
const view = viewGame(game, playerId) as GameView;
|
||||
@@ -1535,6 +1611,7 @@ export async function claimSeatHandover(
|
||||
},
|
||||
clock: HandoverClock = roomsClock,
|
||||
) {
|
||||
await requireRoomNotRemoved(code);
|
||||
if (
|
||||
typeof input.playerId !== 'string' ||
|
||||
!input.playerId ||
|
||||
@@ -1639,7 +1716,7 @@ export async function claimSeatHandover(
|
||||
const results = await db().batch([
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND EXISTS (SELECT 1 FROM seat_handover_offers WHERE room_code = ? AND player_id = ? AND offer_hash = ? AND secret_hash = ? AND issuer_session_hash = ? AND expires_at > ? AND claim_operation_hash IS NULL) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0) AND NOT EXISTS (SELECT 1 FROM seats WHERE token_hash = ?) AND NOT EXISTS (SELECT 1 FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ? AND operation_hash = ?)',
|
||||
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND EXISTS (SELECT 1 FROM seat_handover_offers WHERE room_code = ? AND player_id = ? AND offer_hash = ? AND secret_hash = ? AND issuer_session_hash = ? AND expires_at > ? AND claim_operation_hash IS NULL) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0) AND NOT EXISTS (SELECT 1 FROM seats WHERE token_hash = ?) AND NOT EXISTS (SELECT 1 FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ? AND operation_hash = ?)',
|
||||
)
|
||||
.bind(
|
||||
now,
|
||||
@@ -1708,6 +1785,7 @@ export async function claimSeatHandover(
|
||||
.bind(code, playerId, code, playerId, claimFence),
|
||||
]);
|
||||
if (results[0].meta.changes !== 1) {
|
||||
await requireRoomNotRemoved(code);
|
||||
const won = await replayClaimedHandover(
|
||||
code,
|
||||
playerId,
|
||||
|
||||
@@ -46,6 +46,51 @@ export const roomControls = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
// Removal is an access boundary, independent of saved game state and pause settings.
|
||||
export const roomRemovals = sqliteTable(
|
||||
'room_removals',
|
||||
{
|
||||
roomCode: text('room_code')
|
||||
.primaryKey()
|
||||
.references(() => rooms.code, { onDelete: 'cascade' }),
|
||||
removed: integer('removed').notNull().default(0),
|
||||
revision: integer('revision').notNull().default(0),
|
||||
removedAt: integer('removed_at'),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
},
|
||||
(table) => [
|
||||
check('room_removals_removed', sql`${table.removed} IN (0, 1)`),
|
||||
check('room_removals_revision', sql`${table.revision} >= 0`),
|
||||
check(
|
||||
'room_removals_time',
|
||||
sql`(${table.removed} = 1 AND ${table.removedAt} IS NOT NULL) OR (${table.removed} = 0 AND ${table.removedAt} IS NULL)`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// Exact operation receipts also retain safe audit evidence independently of rooms/accounts.
|
||||
export const adminRoomRemovals = sqliteTable(
|
||||
'admin_room_removals',
|
||||
{
|
||||
operationId: text('operation_id').primaryKey(),
|
||||
actorAdminId: text('actor_admin_id').notNull(),
|
||||
roomCode: text('room_code').notNull(),
|
||||
requestHash: text('request_hash').notNull(),
|
||||
expectedVersion: integer('expected_version').notNull(),
|
||||
expectedRevision: integer('expected_revision').notNull(),
|
||||
appliedVersion: integer('applied_version').notNull(),
|
||||
appliedRevision: integer('applied_revision').notNull(),
|
||||
removed: integer('removed').notNull(),
|
||||
reason: text('reason').notNull(),
|
||||
beforeMetadata: text('before_metadata').notNull(),
|
||||
afterMetadata: text('after_metadata').notNull(),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('admin_room_removals_room').on(table.roomCode, table.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
// Durable receipts are also the room operations audit. They intentionally survive
|
||||
// account/room removal, and contain no credentials, game state or private cards.
|
||||
export const adminRoomAudit = sqliteTable(
|
||||
|
||||
+23
-2
@@ -13,6 +13,7 @@ export class TableTalkError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status = 409,
|
||||
readonly code?: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
@@ -21,11 +22,26 @@ function database() {
|
||||
if (!env.DB) throw new Error('Game storage is unavailable.');
|
||||
return env.DB;
|
||||
}
|
||||
async function requireRoomNotRemoved(code: string) {
|
||||
const removed = await database()
|
||||
.prepare(
|
||||
'SELECT 1 AS removed FROM room_removals WHERE room_code = ? AND removed = 1',
|
||||
)
|
||||
.bind(code)
|
||||
.first();
|
||||
if (removed)
|
||||
throw new TableTalkError(
|
||||
'An administrator recoverably removed this room. Keep your saved seat and retry details; access returns if the room is restored.',
|
||||
410,
|
||||
'ROOM_REMOVED',
|
||||
);
|
||||
}
|
||||
// Every read and write checks the exact active session AND room membership.
|
||||
// A separate message table keeps private activity out of game versions/logs.
|
||||
const viewer = `FROM rooms r JOIN seats s ON s.room_code = r.code
|
||||
JOIN json_each(r.state, '$.players') p ON json_extract(p.value, '$.id') = s.player_id
|
||||
WHERE r.code = ? AND s.player_id = ? AND s.token_hash = ? AND s.revoked = 0
|
||||
AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1)
|
||||
AND json_extract(p.value, '$.bot') IS NULL`;
|
||||
const channel = `(m.recipient_id IS NULL AND ? IS NULL) OR
|
||||
(? IS NOT NULL AND ((m.sender_id = s.player_id AND m.recipient_id = ?) OR
|
||||
@@ -71,7 +87,10 @@ export async function readTableTalk(
|
||||
auth.tokenHash,
|
||||
)
|
||||
.first<{ messages: string }>();
|
||||
if (!row) throw new TableTalkError('Your seat could not be verified.');
|
||||
if (!row) {
|
||||
await requireRoomNotRemoved(code);
|
||||
throw new TableTalkError('Your seat could not be verified.');
|
||||
}
|
||||
const newestFirst: TalkMessage[] = JSON.parse(row.messages);
|
||||
const messages = newestFirst.slice(0, TALK_PAGE_SIZE).reverse();
|
||||
return {
|
||||
@@ -134,9 +153,11 @@ export async function sendTableTalk(
|
||||
draft.text,
|
||||
)
|
||||
.first<{ accepted: number }>();
|
||||
if (!receipt)
|
||||
if (!receipt) {
|
||||
await requireRoomNotRemoved(code);
|
||||
throw new TableTalkError(
|
||||
'Message not confirmed. Check your seat and recipient, wait a second, then try again.',
|
||||
);
|
||||
}
|
||||
return { id: draft.id, replayed: result.meta.changes === 0 };
|
||||
}
|
||||
+7
-2
@@ -12,7 +12,7 @@ recovery and persistence systems where suitable.
|
||||
| Administrator access | Personal access keys, server-enforced owner/operator/viewer roles, persistent eight-hour sessions, individual/all-session sign-out and operator provisioning/revocation. A room host is not a site administrator. Account-management UI and further operational permissions remain. | Prototyped |
|
||||
| Room directory | Search room codes/public player names, filter status/rules/availability, sort and paginate; show host, roster, modules, game change time, pause/join flags and bounded decision ownership. Detailed setup/shared-window ownership remains. | Prototyped |
|
||||
| Create and configure | [Create a lobby](ADMIN_ROOM_CREATION.md) with an explicitly owned new host seat, Basic/Advanced preview and initial AI configuration; invite humans and use ordinary lobby controls/voluntary host-seat handover. [Neutral lobby configuration](ADMIN_LOBBY_CONFIGURATION.md) adds rules/modules, AI seats and host assignment to an existing human without private access. Reserved human seats remain. | Prototyped, partial |
|
||||
| Lifecycle and removal | [Pause/resume and joining locks](ADMIN_ROOM_CONTROLS.md) have controls, durable audit, exact retries and player/AI enforcement. Close/end, archive, restore and recoverable/permanent removal remain missing. | Prototyped, partial |
|
||||
| Lifecycle and removal | [Pause/resume and joining locks](ADMIN_ROOM_CONTROLS.md), plus [recoverable removal/restoration](ADMIN_ROOM_REMOVAL.md), have controls, durable audit, exact retries and player/AI enforcement. Close/end, archive, permanent deletion and bulk actions remain missing. | Prototyped, partial |
|
||||
| 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 |
|
||||
@@ -40,6 +40,9 @@ Owners/operators can also [create a configured lobby](ADMIN_ROOM_CREATION.md),
|
||||
with explicit new-host ownership and saved exact retries.
|
||||
[Configure lobby](ADMIN_LOBBY_CONFIGURATION.md) changes supported rules, AI seats
|
||||
and the host without granting private seat access.
|
||||
[Remove or restore](ADMIN_ROOM_REMOVAL.md) blocks ordinary room access while
|
||||
preserving the game, seats and access revocations. Use the Directory filter to
|
||||
find removed rooms; restoration retains prior pause and joining settings.
|
||||
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.
|
||||
@@ -109,7 +112,9 @@ authentication, cookie properties, ordinary-host denial, directory privacy,
|
||||
session revocation and unchanged game/seat continuation. Operator/owner QA keys
|
||||
also create dedicated rooms for joining locks, pause/takeback/resume,
|
||||
administrator creation, invitations, voluntary host handover and neutral lobby
|
||||
configuration with existing-human host assignment and a real game start. Private reports retain
|
||||
configuration with existing-human host assignment and a real game start. They
|
||||
also remove/restore a separate QA game, preserving its paused state, discussion,
|
||||
saved seats and revoked credentials. Private reports retain
|
||||
room IDs and safe outcomes, never keys/cookies. The container verifier provisions
|
||||
its own disposable QA account and checks admin-session continuity across restart
|
||||
and replacement, followed by the HTTP flow and HTTPS-proxy cookie behavior.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Recoverable room removal
|
||||
|
||||
In **Administration**, owners and operators can choose **Remove or restore**
|
||||
for a room. Removal requires an operational reason, the exact room code and
|
||||
confirmation that active players have been warned. It immediately blocks
|
||||
ordinary private access, invitations, player actions, AI continuation, seat
|
||||
security operations and discussion. The saved game, seats, messages, pending
|
||||
choices and existing credential revocations remain stored.
|
||||
|
||||
The directory defaults to active rooms. Choose **Removed rooms** or **All rooms**
|
||||
to find a removed room and restore it. Restoration retains the previous pause
|
||||
and joining-lock settings. Running AI resumes at the existing pace; a paused
|
||||
room stays paused. Removed rooms remain readable through the administrator's
|
||||
strict public metadata view, without granting private-seat access.
|
||||
|
||||
## Uncertain responses and player notices
|
||||
|
||||
Each removal/restoration saves an account- and room-bound request in this tab
|
||||
before submission. If its response is lost, **Retry saved request** confirms the
|
||||
same operation. After refresh, find the room using **All rooms** and reopen its
|
||||
controls. A replay never applies an old removal again after later restoration.
|
||||
Success and recovery text remain visible until the operator takes another action.
|
||||
|
||||
Players see a persistent removal notice with **Check for restoration** and
|
||||
**Return home**. Polling and automatic requests stop after removal is observed;
|
||||
the table and discussion are hidden. Keep the tab open for any unconfirmed room,
|
||||
recovery, handover or permission request. Its exact proof is retained. Existing
|
||||
seat controllers remain mounted but hidden/disabled so an in-memory recovery
|
||||
draft is not discarded. This is not a claim to erase data already delivered to
|
||||
a browser. A successful explicit restoration check restores the same saved seat.
|
||||
An invitation without a saved seat returns to joining once restoration is confirmed.
|
||||
|
||||
An administrator's unconfirmed creation receipt also preserves its private retry
|
||||
proof while the room is removed. Temporary inaccessibility must not discard the
|
||||
only proof needed to reclaim the newly created host seat after restoration.
|
||||
|
||||
## Authority and storage
|
||||
|
||||
Migration [0012](../drizzle/0012_admin_room_removal.sql) adds room availability
|
||||
metadata and durable administrator removal receipts; it does not delete or
|
||||
replace existing saved records. Live authorization, expected room version and
|
||||
removal revision guard an atomic transition and its audit. Both removal and
|
||||
restoration advance the room version, rejecting stale in-flight actions.
|
||||
Ordinary state reads/writes and AI work check availability, including after
|
||||
asynchronous work. Player endpoints return `ROOM_REMOVED`/410 without a cookie
|
||||
or private state. Restoring never revives a revoked credential.
|
||||
|
||||
See [backend](../db/admin-removal.ts), [controls](../components/admin-room-removal.tsx),
|
||||
[client proof validation](../lib/admin-removal-client.ts) and
|
||||
[HTTP boundary](../app/api/admin/rooms/[code]/removal/route.ts).
|
||||
|
||||
## Evidence and remaining scope
|
||||
|
||||
Focused tests cover live permission changes, stale/concurrent transitions,
|
||||
exact receipts, physical-state and related-record preservation, player/AI/seat
|
||||
and message fences, strict public projections, and temporary-removal proof
|
||||
retention. The dedicated administrator HTTP verifier exercises a real paused
|
||||
game with two human seats, an AI and a revoked old host cookie, then restores it
|
||||
and compares the full private game view and discussion. It creates its own room
|
||||
and restores its flags afterward. Independent review and source-bound checks
|
||||
accompany the checkpoint; browser and deployed results are recorded separately.
|
||||
|
||||
These checks also exposed a lobby readiness bug: a joining human or human
|
||||
faction change could clear AI readiness and block start. Those changes now clear
|
||||
only human readiness; focused JSON continuation tests and the HTTP start cover it.
|
||||
|
||||
Close/end, archive, permanent deletion, bulk operations, participant support and
|
||||
backup/import workflows remain unfinished. Recoverable removal does not satisfy
|
||||
those separate [administration requirements](ADMIN_PANEL.md). No existing human
|
||||
room is used for removal tests, and no database reset is authorized.
|
||||
+15
-5
@@ -6,11 +6,21 @@ Expansion starts and full rules/publication acceptance remain gated.**
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
Current work adds [neutral administrator lobby configuration](ADMIN_LOBBY_CONFIGURATION.md):
|
||||
supported rules/modules, AI seat management and host assignment to an existing
|
||||
human without private seat access. Exact saved requests, live authority and
|
||||
version fences preserve concurrent play and saved continuation. Reserved human
|
||||
seats, full participant support, archive/removal and backups remain unfinished.
|
||||
Current work adds [recoverable administrator removal/restoration](ADMIN_ROOM_REMOVAL.md):
|
||||
live authority and atomic version/revision fences block ordinary room access,
|
||||
player/AI work, seat operations and discussion while preserving saved records.
|
||||
Persistent notices and exact saved retries retain proof through temporary removal.
|
||||
Restoration retains prior pause/join settings and credential revocations.
|
||||
Close/end, archive, permanent deletion, bulk actions and full support remain open.
|
||||
|
||||
Checkpoint `c0cc4de` added [neutral administrator lobby configuration](ADMIN_LOBBY_CONFIGURATION.md)
|
||||
and passed types/lint, 5,260 offline tests, 52 HTTP tests and build, with eleven
|
||||
dedicated administrator HTTP groups and lost-response desktop/mobile browser QA.
|
||||
It is deployed on the NAS: container verification passed, migration 0011 applied,
|
||||
all eight prior rooms and ten seats were preserved, and public HTTPS/browser
|
||||
saved-seat restoration passed. Anonymous creation, control and configuration
|
||||
requests remain denied. Positive production administrator acceptance remains
|
||||
pending authorized QA operator access.
|
||||
|
||||
Checkpoint `e7d4efc` added [administrator room creation](ADMIN_ROOM_CREATION.md)
|
||||
and passed types/lint, 5,240 offline tests, 52 HTTP tests and build, with independent
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 22 September 2026 — Recoverable room removal and restoration
|
||||
|
||||
Added [administrator removal/restoration](ADMIN_ROOM_REMOVAL.md) with a typed-room
|
||||
confirmation, persistent operator/player notices, removed-room directory filters,
|
||||
live authorization and atomic version/revision audit receipts. Removed rooms deny
|
||||
private reads, joins, actions, AI, seat changes and discussion; restoration keeps
|
||||
saved state, previous pause/join flags and credential revocations. Exact retries
|
||||
retain proof across refresh, lost responses and temporary removal. Independent
|
||||
review repaired stale client removal markers without discarding recovery drafts.
|
||||
The dedicated HTTP flow also exposed and fixed AI lobby readiness after a human
|
||||
joins or changes faction. Focused, browser and required source-bound evidence
|
||||
accompany this checkpoint. Full administration and authenticated deployed
|
||||
acceptance remain unfinished.
|
||||
|
||||
## 22 September 2026 — Neutral administrator lobby configuration
|
||||
|
||||
Added [rules/modules, AI configuration and existing-human host assignment](ADMIN_LOBBY_CONFIGURATION.md)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE `admin_room_removals` (
|
||||
`operation_id` text PRIMARY KEY NOT NULL,
|
||||
`actor_admin_id` text NOT NULL,
|
||||
`room_code` text NOT NULL,
|
||||
`request_hash` text NOT NULL,
|
||||
`expected_version` integer NOT NULL,
|
||||
`expected_revision` integer NOT NULL,
|
||||
`applied_version` integer NOT NULL,
|
||||
`applied_revision` integer NOT NULL,
|
||||
`removed` integer NOT NULL,
|
||||
`reason` text NOT NULL,
|
||||
`before_metadata` text NOT NULL,
|
||||
`after_metadata` text NOT NULL,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `admin_room_removals_room` ON `admin_room_removals` (`room_code`,`created_at`);--> statement-breakpoint
|
||||
CREATE TABLE `room_removals` (
|
||||
`room_code` text PRIMARY KEY NOT NULL,
|
||||
`removed` integer DEFAULT 0 NOT NULL,
|
||||
`revision` integer DEFAULT 0 NOT NULL,
|
||||
`removed_at` integer,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`room_code`) REFERENCES `rooms`(`code`) ON UPDATE no action ON DELETE cascade,
|
||||
CONSTRAINT "room_removals_removed" CHECK("room_removals"."removed" IN (0, 1)),
|
||||
CONSTRAINT "room_removals_revision" CHECK("room_removals"."revision" >= 0),
|
||||
CONSTRAINT "room_removals_time" CHECK(("room_removals"."removed" = 1 AND "room_removals"."removed_at" IS NOT NULL) OR ("room_removals"."removed" = 0 AND "room_removals"."removed_at" IS NULL))
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -85,6 +85,13 @@
|
||||
"when": 1790039439940,
|
||||
"tag": "0011_admin_lobby_configuration",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1790040786205,
|
||||
"tag": "0012_admin_room_removal",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+2
-2
@@ -3497,7 +3497,7 @@ export function joinGame(g: Game, p: Player) {
|
||||
);
|
||||
g.players.push(p);
|
||||
g.playerPositions = normalizedPlayerPositions(g);
|
||||
g.players.forEach((x) => (x.ready = false));
|
||||
g.players.forEach((x) => (x.ready = !!x.bot));
|
||||
log(g, `${p.name} joined as ${faction(p.faction).name}.`);
|
||||
}
|
||||
function draw(g: Game) {
|
||||
@@ -22851,7 +22851,7 @@ function applyActionInner(
|
||||
? { reserves: 7, tanks: 0, forces: {}, revived: 0 }
|
||||
: undefined;
|
||||
p.leaders = leaders(f);
|
||||
g.players.forEach((x) => (x.ready = false));
|
||||
g.players.forEach((x) => (x.ready = !!x.bot));
|
||||
return g;
|
||||
}
|
||||
throw new RuleError('That action is not available in the lobby.');
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RoomControl } from './room-control';
|
||||
export type AdminRoom = {
|
||||
code: string;
|
||||
version: number;
|
||||
removed: boolean;
|
||||
updatedAt: number;
|
||||
status: 'lobby' | 'setup' | 'playing' | 'finished' | 'unreadable';
|
||||
host: string | null;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { randomId } from './random-id';
|
||||
import { validAdminRemovalInput, type AdminRemovalInput, type AdminRemovalView, type AdminRemovalResult } from './admin-removal';
|
||||
|
||||
type Storage = Pick<globalThis.Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
||||
const key = (account: string, code: string) => `dune.admin-removal-request.v1:${account}:${code}`;
|
||||
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
const keys = (value: Record<string, unknown>, names: string[]) => Object.keys(value).length === names.length && names.every(name => Object.hasOwn(value, name));
|
||||
const integer = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0;
|
||||
const timestamp = (value: unknown) => value === null || integer(value) && value <= 8_640_000_000_000_000;
|
||||
|
||||
export function validAdminRemovalView(value: unknown): value is AdminRemovalView {
|
||||
return object(value) && keys(value, ['code', 'version', 'removed', 'revision', 'removedAt', 'updatedAt', 'paused', 'joinLocked']) &&
|
||||
typeof value.code === 'string' && /^[A-Z2-9]{8}$/.test(value.code) && integer(value.version) && integer(value.revision) &&
|
||||
typeof value.removed === 'boolean' && typeof value.paused === 'boolean' && typeof value.joinLocked === 'boolean' &&
|
||||
timestamp(value.removedAt) && timestamp(value.updatedAt);
|
||||
}
|
||||
|
||||
export function adminRemovalResponse(value: unknown, code: string): AdminRemovalView {
|
||||
if (!validAdminRemovalView(value) || value.code !== code) throw new Error('The server returned unreadable room availability.');
|
||||
return value;
|
||||
}
|
||||
|
||||
export function adminRemovalConfirmation(value: unknown, code: string, input: AdminRemovalInput): AdminRemovalResult {
|
||||
if (!object(value) || !keys(value, ['operationId', 'replayed', 'appliedRevision', 'appliedVersion', 'room']) ||
|
||||
value.operationId !== input.operationId || typeof value.replayed !== 'boolean' ||
|
||||
!integer(value.appliedVersion) || value.appliedVersion !== input.expectedVersion + 1 ||
|
||||
!integer(value.appliedRevision) || value.appliedRevision !== input.expectedRevision + 1)
|
||||
throw new Error('The server did not confirm this removal or restoration request.');
|
||||
const room = adminRemovalResponse(value.room, code);
|
||||
if (room.version < value.appliedVersion || room.revision < value.appliedRevision)
|
||||
throw new Error('The server returned room availability older than the confirmed change.');
|
||||
return value as AdminRemovalResult;
|
||||
}
|
||||
|
||||
export function newAdminRemovalRequest(view: AdminRemovalView, removed: boolean, reason: string): AdminRemovalInput {
|
||||
const input = { operationId: randomId(), expectedVersion: view.version, expectedRevision: view.revision, removed, reason: reason.trim() };
|
||||
if (!validAdminRemovalInput(input)) throw new Error('Enter a reason of 1–300 characters on one line.');
|
||||
if (removed === view.removed) throw new Error('Refresh the room before choosing a removal or restoration.');
|
||||
return input;
|
||||
}
|
||||
|
||||
export function readAdminRemovalRequest(storage: Storage, account: string, code: string): AdminRemovalInput | null {
|
||||
const raw = storage.getItem(key(account, code));
|
||||
if (raw === null) return null;
|
||||
let value: unknown;
|
||||
try { value = raw.length <= 2048 ? JSON.parse(raw) : null; } catch { value = null; }
|
||||
if (!validAdminRemovalInput(value)) throw new Error('This tab’s saved removal or restoration request is unreadable. Inspect the current room before discarding its local retry record.');
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Save before sending; a different unresolved request must never be replaced. */
|
||||
export function saveAdminRemovalRequest(storage: Storage, account: string, code: string, input: AdminRemovalInput) {
|
||||
if (!validAdminRemovalInput(input)) throw new Error('Invalid removal or restoration request.');
|
||||
const previous = readAdminRemovalRequest(storage, account, code), encoded = JSON.stringify(input);
|
||||
if (previous && JSON.stringify(previous) !== encoded) throw new Error('Resolve the saved removal or restoration request before sending a different change.');
|
||||
storage.setItem(key(account, code), encoded);
|
||||
if (storage.getItem(key(account, code)) !== encoded) throw new Error('The removal or restoration request could not be preserved in this tab. Nothing was sent.');
|
||||
}
|
||||
|
||||
export function clearAdminRemovalRequest(storage: Storage, account: string, code: string) {
|
||||
storage.removeItem(key(account, code));
|
||||
if (storage.getItem(key(account, code)) !== null) throw new Error('The saved removal or restoration request could not be cleared.');
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export type AdminRemovalInput = {
|
||||
operationId: string;
|
||||
expectedVersion: number;
|
||||
expectedRevision: number;
|
||||
removed: boolean;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type AdminRemovalView = {
|
||||
code: string;
|
||||
version: number;
|
||||
removed: boolean;
|
||||
revision: number;
|
||||
removedAt: number | null;
|
||||
updatedAt: number | null;
|
||||
paused: boolean;
|
||||
joinLocked: boolean;
|
||||
};
|
||||
|
||||
export type AdminRemovalResult = {
|
||||
operationId: string;
|
||||
replayed: boolean;
|
||||
appliedRevision: number;
|
||||
appliedVersion: number;
|
||||
room: AdminRemovalView;
|
||||
};
|
||||
|
||||
export function validAdminRemovalInput(
|
||||
value: unknown,
|
||||
): value is AdminRemovalInput {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const input = value as Record<string, unknown>;
|
||||
const fields = [
|
||||
'operationId',
|
||||
'expectedVersion',
|
||||
'expectedRevision',
|
||||
'removed',
|
||||
'reason',
|
||||
];
|
||||
return (
|
||||
Object.keys(input).length === fields.length &&
|
||||
fields.every((field) => Object.hasOwn(input, field)) &&
|
||||
typeof input.operationId === 'string' &&
|
||||
input.operationId.length === 36 &&
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
|
||||
input.operationId,
|
||||
) &&
|
||||
[input.expectedVersion, input.expectedRevision].every(
|
||||
(version) =>
|
||||
Number.isSafeInteger(version) &&
|
||||
Number(version) >= 0 &&
|
||||
Number(version) < Number.MAX_SAFE_INTEGER,
|
||||
) &&
|
||||
typeof input.removed === 'boolean' &&
|
||||
typeof input.reason === 'string' &&
|
||||
!!input.reason.trim() &&
|
||||
input.reason.length <= 300 &&
|
||||
!input.reason
|
||||
.split('')
|
||||
.some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,8 @@ const uuid = (value: unknown) => typeof value === 'string' && value.length === 3
|
||||
export function validAdminRoomCreationResult(value: unknown): value is AdminRoomCreationResult {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const result = value as Record<string, unknown>;
|
||||
return Object.keys(result).length === 5 && typeof result.code === 'string' && result.code.length === 8 && /^[A-Z2-9]{8}$/.test(result.code) &&
|
||||
return Object.keys(result).length === (result.roomRemoved === true ? 6 : 5) &&
|
||||
(!Object.hasOwn(result, 'roomRemoved') || result.roomRemoved === true && result.hostAccess === false) && typeof result.code === 'string' && result.code.length === 8 && /^[A-Z2-9]{8}$/.test(result.code) &&
|
||||
uuid(result.hostId) && uuid(result.operationId) && typeof result.replayed === 'boolean' && typeof result.hostAccess === 'boolean';
|
||||
}
|
||||
|
||||
@@ -49,6 +50,7 @@ export function saveAdminRoomCreation(storage: Storage, account: string, input:
|
||||
|
||||
export function completeAdminRoomCreation(storage: Storage, account: string, input: AdminRoomCreationInput, result: AdminRoomCreationResult) {
|
||||
if (!validAdminRoomCreationResult(result) || result.operationId !== input.operationId) throw new Error('The server did not confirm this room-creation request.');
|
||||
if (result.roomRemoved) throw new Error('The room is temporarily removed. Keep the exact creation proof until an administrator restores it.');
|
||||
const previous = readAdminRoomCreation(storage, account);
|
||||
if (!previous || previous.kind !== 'pending' || JSON.stringify(previous.input) !== JSON.stringify(input))
|
||||
throw new Error('The saved room-creation record changed. Keep the current record and inspect the room directory.');
|
||||
|
||||
@@ -20,6 +20,7 @@ export type AdminRoomCreationResult = {
|
||||
hostId: string;
|
||||
replayed: boolean;
|
||||
hostAccess: boolean;
|
||||
roomRemoved?: true;
|
||||
};
|
||||
|
||||
const object = (value: unknown): value is Record<string, unknown> =>
|
||||
|
||||
+25
-1
@@ -10,14 +10,30 @@ export class ClientRequestError extends Error {
|
||||
| 'http'
|
||||
| 'invalid-response',
|
||||
readonly status?: number,
|
||||
readonly code?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ClientRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
export const isRoomRemoved = (error: unknown): error is ClientRequestError =>
|
||||
error instanceof ClientRequestError && error.code === 'ROOM_REMOVED';
|
||||
|
||||
// Controllers subscribe to a public room identifier, never response bodies or
|
||||
// credentials. A removal observed by discussion or seat controls also closes the
|
||||
// table. Pending controllers can stay mounted to retain their private retry proof.
|
||||
const removalListeners = new Set<(room: string) => void>();
|
||||
export function subscribeRoomRemoval(listener: (room: string) => void) {
|
||||
removalListeners.add(listener);
|
||||
return () => { removalListeners.delete(listener); };
|
||||
}
|
||||
|
||||
/** Gateways can report failure after the server has already committed a mutation. */
|
||||
export function requestMayHaveCompleted(error: unknown): boolean {
|
||||
// Creation/recovery can commit before their final private read loses to removal.
|
||||
// A temporary removal is never proof that a preceding mutation did not commit.
|
||||
if (isRoomRemoved(error)) return true;
|
||||
if (!(error instanceof ClientRequestError) || error.kind !== 'http')
|
||||
return true;
|
||||
return (
|
||||
@@ -92,7 +108,15 @@ export async function requestJson<T>(
|
||||
typeof data.error === 'string'
|
||||
? data.error
|
||||
: `Request failed (${response.status}).`;
|
||||
throw new ClientRequestError(message, 'http', response.status);
|
||||
const code = data && typeof data === 'object' && 'code' in data &&
|
||||
typeof data.code === 'string' && /^[A-Z_]{1,80}$/.test(data.code) ? data.code : undefined;
|
||||
if (code === 'ROOM_REMOVED') {
|
||||
const room = new URL(url, 'http://local.invalid').pathname.match(/^\/api\/rooms\/([A-Z2-9]{8})(?:\/|$)/)?.[1];
|
||||
if (room) for (const listener of removalListeners) {
|
||||
try { listener(room); } catch { /* A controller cannot change request semantics. */ }
|
||||
}
|
||||
}
|
||||
throw new ClientRequestError(message, 'http', response.status, code);
|
||||
}
|
||||
return data as T;
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden
|
||||
assert.ok(cookie);
|
||||
const room = (await created.json() as { code: string }).code;
|
||||
for (const headers of [{}, { cookie }, { cookie: 'dune_admin_session=' + 'a'.repeat(64) }, { 'oai-authenticated-user-id': 'owner', 'oai-authenticated-user-email': 'owner@example.test' }] as Record<string, string>[]) {
|
||||
for (const path of ['/api/admin/session', '/api/admin/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`]) {
|
||||
for (const path of ['/api/admin/session', '/api/admin/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`, `/api/admin/rooms/${room}/removal`]) {
|
||||
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');
|
||||
@@ -34,6 +34,14 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden
|
||||
assert.equal(lobby.status, 401);
|
||||
assert.equal(lobby.headers.get('set-cookie'), null);
|
||||
assert.deepEqual(Object.keys(await lobby.json()), ['error']);
|
||||
const removal = await fetch(`${base}/api/admin/rooms/${room}/removal`, {
|
||||
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ operationId: crypto.randomUUID(), expectedVersion: 0, expectedRevision: 0, removed: true, reason: 'Denied removal QA' }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
assert.equal(removal.status, 401);
|
||||
assert.equal(removal.headers.get('set-cookie'), null);
|
||||
assert.deepEqual(Object.keys(await removal.json()), ['error']);
|
||||
const create = await fetch(base + '/api/admin/rooms', {
|
||||
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ operationId: crypto.randomUUID(), sessionToken: 'b'.repeat(64), name: 'Denied creation QA', faction: 'atreides', advanced: false, techTokens: false, strongholdCards: false, bots: [], reason: 'Must not create a room' }),
|
||||
|
||||
@@ -12,6 +12,24 @@ const state = (name = 'Directory QA', status = 'lobby', advanced = false) => ({
|
||||
log: ['SECRET_HISTORY'], unknownFuturePrivateField: 'SECRET_NEW_FIELD',
|
||||
});
|
||||
const insert = (sqlite: DatabaseSync, code: string, value: unknown, updated = 100) => sqlite.prepare('INSERT INTO rooms (code,state,version,updated_at) VALUES (?,?,0,?)').run(code, typeof value === 'string' ? value : JSON.stringify(value), updated);
|
||||
|
||||
void test('recoverably removed rooms leave the default directory and remain explicitly searchable for restoration', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
insert(f.sqlite, 'ACTIVEAA', state('Active QA'));
|
||||
insert(f.sqlite, 'REMOVEDA', state('Removed QA'));
|
||||
f.sqlite.prepare('INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES (?,1,1,100,100)').run('REMOVEDA');
|
||||
const read = (query = '') => readAdminDirectory(f.database, f.identity, new URLSearchParams(query));
|
||||
const active = await read(); assert.equal(active.total, 1); assert.equal(active.rooms[0].code, 'ACTIVEAA'); assert.equal(active.rooms[0].removed, false);
|
||||
assert.equal((await read('q=Removed')).total, 0);
|
||||
const removed = await read('removal=removed&q=Removed'); assert.equal(removed.total, 1); assert.equal(removed.rooms[0].removed, true);
|
||||
assert.equal(JSON.stringify(removed).includes('SECRET'), false);
|
||||
assert.equal((await read('removal=all')).total, 2);
|
||||
await assert.rejects(read('removal=invalid'), (e: unknown) => e instanceof AdminError && e.status === 400);
|
||||
f.sqlite.prepare('UPDATE room_removals SET removed=0,revision=2,removed_at=NULL WHERE room_code=?').run('REMOVEDA');
|
||||
assert.equal((await read()).total, 2); assert.equal((await read('removal=removed')).total, 0);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
async function fixture() {
|
||||
const store = adminStore();
|
||||
const account = store.provision('viewer');
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { adminRemovalConfirmation, adminRemovalResponse, clearAdminRemovalRequest, newAdminRemovalRequest, readAdminRemovalRequest, saveAdminRemovalRequest } from '../lib/admin-removal-client';
|
||||
import { retainAdminRoomRequest } from '../lib/admin-room-control-client';
|
||||
import { ClientRequestError } from '../lib/client-request';
|
||||
import type { AdminRemovalView } from '../lib/admin-removal';
|
||||
|
||||
const view = (): AdminRemovalView => ({ code: 'ABCD2345', version: 7, removed: false, revision: 2, removedAt: null, updatedAt: null, paused: false, joinLocked: true });
|
||||
function storage() {
|
||||
const values = new Map<string, string>();
|
||||
return { values, getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); }, removeItem: (key: string) => { values.delete(key); } };
|
||||
}
|
||||
|
||||
void test('removal retry preserves exact intent/version/revision/reason across refresh and isolates account and room', () => {
|
||||
const tab = storage(), room = view(), input = newAdminRemovalRequest(room, true, ' QA removal ');
|
||||
saveAdminRemovalRequest(tab, 'a', room.code, input);
|
||||
assert.deepEqual(readAdminRemovalRequest(tab, 'a', room.code), input);
|
||||
assert.equal(input.expectedVersion, 7); assert.equal(input.expectedRevision, 2); assert.equal(input.reason, 'QA removal');
|
||||
assert.equal(readAdminRemovalRequest(tab, 'b', room.code), null);
|
||||
assert.equal(readAdminRemovalRequest(tab, 'a', 'DIFF2345'), null);
|
||||
for (const change of [{ operationId: crypto.randomUUID() }, { expectedVersion: 8 }, { expectedRevision: 3 }, { reason: 'Different' }, { removed: false }])
|
||||
assert.throws(() => saveAdminRemovalRequest(tab, 'a', room.code, { ...input, ...change }), /Resolve the saved/);
|
||||
saveAdminRemovalRequest(tab, 'a', room.code, input);
|
||||
clearAdminRemovalRequest(tab, 'a', room.code);
|
||||
assert.equal(readAdminRemovalRequest(tab, 'a', room.code), null);
|
||||
const restore = newAdminRemovalRequest({ ...room, removed: true, version: 8, revision: 3 }, false, 'Restore');
|
||||
saveAdminRemovalRequest(tab, 'a', room.code, restore);
|
||||
assert.deepEqual(readAdminRemovalRequest(tab, 'a', room.code), restore);
|
||||
});
|
||||
|
||||
void test('corrupt or unwritable removal retry storage fails closed and never replaces the saved record', () => {
|
||||
const tab = storage(), room = view(), input = newAdminRemovalRequest(room, true, 'QA');
|
||||
assert.throws(() => saveAdminRemovalRequest({ ...tab, setItem() {} }, 'a', room.code, input), /Nothing was sent/);
|
||||
saveAdminRemovalRequest(tab, 'a', room.code, input);
|
||||
const key = [...tab.values.keys()][0];
|
||||
for (const raw of ['{broken', JSON.stringify({ ...input, sessionToken: 'secret' }), JSON.stringify({ ...input, expectedRevision: -1 }), ' '.repeat(2049)]) {
|
||||
tab.values.set(key, raw);
|
||||
assert.throws(() => readAdminRemovalRequest(tab, 'a', room.code), /unreadable/);
|
||||
assert.throws(() => saveAdminRemovalRequest(tab, 'a', room.code, input), /unreadable/);
|
||||
assert.equal(tab.values.get(key), raw);
|
||||
}
|
||||
assert.throws(() => clearAdminRemovalRequest({ ...tab, removeItem() {} }, 'a', room.code), /could not be cleared/);
|
||||
clearAdminRemovalRequest(tab, 'a', room.code);
|
||||
for (const reason of [' ', 'a'.repeat(301), 'two\nlines', 'bad\u007freason'])
|
||||
assert.throws(() => newAdminRemovalRequest(room, true, reason));
|
||||
assert.throws(() => newAdminRemovalRequest(room, false, 'No change'));
|
||||
assert.throws(() => newAdminRemovalRequest({ ...room, revision: Number.MAX_SAFE_INTEGER }, true, 'Overflow'));
|
||||
});
|
||||
|
||||
void test('confirmation binds exact operation and both applied versions while replay accepts later current availability', () => {
|
||||
const room = view(), input = newAdminRemovalRequest(room, true, 'QA');
|
||||
const current = { ...room, version: 12, revision: 4, removed: false, removedAt: 100, updatedAt: 200, paused: true };
|
||||
const receipt = { operationId: input.operationId, appliedVersion: 8, appliedRevision: 3, replayed: true, room: current };
|
||||
assert.deepEqual(adminRemovalConfirmation(receipt, room.code, input), receipt);
|
||||
const fresh = { ...receipt, replayed: false, room: { ...room, version: 8, revision: 3, removed: true, removedAt: 100, updatedAt: 100 } };
|
||||
assert.deepEqual(adminRemovalConfirmation(fresh, room.code, input), fresh);
|
||||
for (const invalid of [{ ...receipt, operationId: crypto.randomUUID() }, { ...receipt, appliedVersion: 9 }, { ...receipt, appliedRevision: 4 },
|
||||
{ ...receipt, room: { ...current, version: 7 } }, { ...receipt, room: { ...current, revision: 2 } },
|
||||
{ ...receipt, room: { ...current, code: 'DIFF2345' } }, { ...receipt, sessionToken: 'secret' }, { ...receipt, replayed: undefined }])
|
||||
assert.throws(() => adminRemovalConfirmation(invalid, room.code, input));
|
||||
});
|
||||
|
||||
void test('room availability projection excludes private data and rejects malformed metadata', () => {
|
||||
const room = view();
|
||||
assert.deepEqual(adminRemovalResponse(room, room.code), room);
|
||||
assert.doesNotThrow(() => adminRemovalResponse({ ...room, removed: true, removedAt: 100, updatedAt: 100 }, room.code));
|
||||
for (const invalid of [null, [], { ...room, state: {} }, { ...room, reason: 'Private audit' }, { ...room, seats: [] },
|
||||
{ ...room, version: 1.5 }, { ...room, revision: -1 }, { ...room, code: 'bad code' }, { ...room, removed: 'true' },
|
||||
{ ...room, removedAt: -1 }, { ...room, removedAt: Number.MAX_SAFE_INTEGER }, { ...room, updatedAt: '100' },
|
||||
{ ...room, paused: undefined }, { ...room, joinLocked: null }])
|
||||
assert.throws(() => adminRemovalResponse(invalid, room.code));
|
||||
});
|
||||
|
||||
void test('uncertain removal and changed-access responses keep the exact saved request', () => {
|
||||
const tab = storage(), room = view(), input = newAdminRemovalRequest(room, true, 'QA');
|
||||
saveAdminRemovalRequest(tab, 'a', room.code, input);
|
||||
for (const error of [new ClientRequestError('Offline', 'network'), new ClientRequestError('Timeout', 'timeout'),
|
||||
new ClientRequestError('Unreadable', 'invalid-response', 200), ...[401, 403, 408, 500, 502, 503].map(status => new ClientRequestError('Unavailable', 'http', status)), new Error('Wrong confirmation')]) {
|
||||
if (!retainAdminRoomRequest(error)) clearAdminRemovalRequest(tab, 'a', room.code);
|
||||
assert.deepEqual(readAdminRemovalRequest(tab, 'a', room.code), input);
|
||||
}
|
||||
for (const status of [400, 404, 409, 422]) assert.equal(retainAdminRoomRequest(new ClientRequestError('Rejected', 'http', status)), false);
|
||||
});
|
||||
@@ -0,0 +1,887 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { adminStore, pauseAdminBatch, sha256 } from './admin-access-fixture';
|
||||
import {
|
||||
AdminError,
|
||||
adminLogin,
|
||||
requireAdmin,
|
||||
type AdminRole,
|
||||
} from '../db/admin-access';
|
||||
import { applyAdminRemoval, readAdminRemoval } from '../db/admin-removal';
|
||||
import {
|
||||
validAdminRemovalInput,
|
||||
type AdminRemovalInput,
|
||||
} from '../lib/admin-removal';
|
||||
import {
|
||||
applyAdminRoomControl,
|
||||
readAdminRoomControl,
|
||||
} from '../db/admin-lifecycle';
|
||||
import {
|
||||
configureAdminLobby,
|
||||
readAdminLobby,
|
||||
} from '../db/admin-lobby-configuration';
|
||||
import { createAdminRoom } from '../db/admin-room-creation';
|
||||
import { createGame, newPlayer } from '../game/engine';
|
||||
|
||||
const now = 10000;
|
||||
const secret = () => randomBytes(32).toString('hex');
|
||||
const status = (value: number) => (error: unknown) =>
|
||||
error instanceof AdminError && error.status === value;
|
||||
type Store = ReturnType<typeof adminStore>;
|
||||
const rows = (store: Store, table: string) =>
|
||||
store.sqlite.prepare(`SELECT * FROM ${table}`).all();
|
||||
const secureTables = [
|
||||
'seats',
|
||||
'seat_recovery_keys',
|
||||
'seat_recovery_receipts',
|
||||
'seat_handover_offers',
|
||||
'seat_handover_claim_receipts',
|
||||
'seat_ai_delegations',
|
||||
'room_entry_receipts',
|
||||
'room_messages',
|
||||
];
|
||||
const secureRows = (store: Store) =>
|
||||
secureTables.map((table) => rows(store, table));
|
||||
const change = (
|
||||
expectedVersion = 0,
|
||||
expectedRevision = 0,
|
||||
removed = true,
|
||||
): AdminRemovalInput => ({
|
||||
operationId: randomUUID(),
|
||||
expectedVersion,
|
||||
expectedRevision,
|
||||
removed,
|
||||
reason: 'Dedicated QA room removal',
|
||||
});
|
||||
function seed(store: Store, code = 'REMOVALS') {
|
||||
const host = newPlayer(randomUUID(), 'Private player name', 'atreides');
|
||||
const game = createGame(code, host, false);
|
||||
const token = secret(),
|
||||
tokenHash = sha256(token),
|
||||
revokedHash = sha256(secret());
|
||||
store.sqlite
|
||||
.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)')
|
||||
.run(code, JSON.stringify(game), 0, 1000);
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO seats(token_hash,room_code,player_id,revoked) VALUES(?,?,?,?)',
|
||||
)
|
||||
.run(tokenHash, code, host.id, 0);
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO seats(token_hash,room_code,player_id,revoked) VALUES(?,?,?,?)',
|
||||
)
|
||||
.run(revokedHash, code, host.id, 1);
|
||||
return { host, game, token, tokenHash, revokedHash, code };
|
||||
}
|
||||
function seedDependents(store: Store, room: ReturnType<typeof seed>) {
|
||||
const { code, host, tokenHash } = room;
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO room_entry_receipts(operation_hash,request_hash,session_hash,room_code,player_id) VALUES(?,?,?,?,?)',
|
||||
)
|
||||
.run(sha256(secret()), sha256(secret()), tokenHash, code, host.id);
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO seat_recovery_keys(room_code,player_id,recovery_hash,current_operation_hash) VALUES(?,?,?,?)',
|
||||
)
|
||||
.run(code, host.id, sha256(secret()), sha256(secret()));
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO seat_recovery_receipts(room_code,player_id,operation_hash,session_hash,recovery_hash) VALUES(?,?,?,?,?)',
|
||||
)
|
||||
.run(code, host.id, sha256(secret()), tokenHash, sha256(secret()));
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO seat_handover_offers(room_code,player_id,offer_hash,secret_hash,issuer_session_hash,expires_at) VALUES(?,?,?,?,?,?)',
|
||||
)
|
||||
.run(code, host.id, sha256(secret()), sha256(secret()), tokenHash, now + 1);
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO seat_handover_claim_receipts(room_code,player_id,operation_hash,offer_hash,secret_hash,session_hash,claim_fence,claimed_at) VALUES(?,?,?,?,?,?,?,?)',
|
||||
)
|
||||
.run(
|
||||
code,
|
||||
host.id,
|
||||
sha256(secret()),
|
||||
sha256(secret()),
|
||||
sha256(secret()),
|
||||
tokenHash,
|
||||
sha256(secret()),
|
||||
now,
|
||||
);
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO seat_ai_delegations(room_code,owner_id,grant_id,delegate_id,difficulty,owner_session_hash,delegate_session_hash,expires_at,revoked_at) VALUES(?,?,?,?,?,?,?,?,?)',
|
||||
)
|
||||
.run(
|
||||
code,
|
||||
host.id,
|
||||
randomUUID(),
|
||||
randomUUID(),
|
||||
'Hard',
|
||||
tokenHash,
|
||||
sha256(secret()),
|
||||
now + 1,
|
||||
now,
|
||||
);
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO room_messages(room_code,id,sender_id,sender_session_hash,sender_name,sender_faction,recipient_id,recipient_name,body,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)',
|
||||
)
|
||||
.run(
|
||||
code,
|
||||
randomUUID(),
|
||||
host.id,
|
||||
tokenHash,
|
||||
host.name,
|
||||
host.faction,
|
||||
randomUUID(),
|
||||
'Private recipient',
|
||||
'Private saved discussion',
|
||||
now,
|
||||
);
|
||||
}
|
||||
async function fixture(role: AdminRole = 'operator') {
|
||||
const store = adminStore();
|
||||
const account = store.provision(role, 'Private administrator');
|
||||
const login = await adminLogin(store.database, account.key, now);
|
||||
const identity = await requireAdmin(
|
||||
store.database,
|
||||
login.token,
|
||||
undefined,
|
||||
now,
|
||||
);
|
||||
const room = seed(store);
|
||||
const row = () =>
|
||||
store.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(room.code)!;
|
||||
const version = () => Number(row().version);
|
||||
const read = () => readAdminRemoval(store.database, identity, room.code, now);
|
||||
const apply = (input = change(version()), at = now) =>
|
||||
applyAdminRemoval(store.database, identity, room.code, input, at);
|
||||
return {
|
||||
...store,
|
||||
...room,
|
||||
account,
|
||||
login,
|
||||
identity,
|
||||
row,
|
||||
version,
|
||||
read,
|
||||
apply,
|
||||
};
|
||||
}
|
||||
function gateBatch(store: Store, number = 1) {
|
||||
let enter!: () => void, release!: () => void;
|
||||
const waiting = new Promise<void>((resolve) => {
|
||||
enter = resolve;
|
||||
});
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
store.hooks.beforeBatch = async () => {
|
||||
if (--number) return;
|
||||
delete store.hooks.beforeBatch;
|
||||
enter();
|
||||
await gate;
|
||||
};
|
||||
return { waiting, release };
|
||||
}
|
||||
|
||||
void test('removal migration is additive and preserves all existing rooms, credentials and audit records', () => {
|
||||
const store = adminStore(':memory:', false);
|
||||
try {
|
||||
for (const file of readdirSync(new URL('../drizzle/', import.meta.url))
|
||||
.filter((file) => file.endsWith('.sql') && file < '0012')
|
||||
.sort())
|
||||
store.sqlite.exec(
|
||||
readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'),
|
||||
);
|
||||
const room = seed(store);
|
||||
seedDependents(store, room);
|
||||
store.provision();
|
||||
const tables = [
|
||||
'rooms',
|
||||
...secureTables,
|
||||
'room_controls',
|
||||
'admin_accounts',
|
||||
'admin_sessions',
|
||||
'admin_audit',
|
||||
'admin_room_audit',
|
||||
'admin_room_creations',
|
||||
'admin_lobby_operations',
|
||||
];
|
||||
const before = tables.map((table) => rows(store, table));
|
||||
store.sqlite.exec(
|
||||
readFileSync(
|
||||
new URL('../drizzle/0012_admin_room_removal.sql', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.deepEqual(
|
||||
tables.map((table) => rows(store, table)),
|
||||
before,
|
||||
);
|
||||
assert.deepEqual(rows(store, 'room_removals'), []);
|
||||
assert.deepEqual(rows(store, 'admin_room_removals'), []);
|
||||
assert.throws(
|
||||
() =>
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO room_removals(room_code,removed,revision,updated_at) VALUES(?,1,0,?)',
|
||||
)
|
||||
.run(room.code, now),
|
||||
/CHECK/,
|
||||
);
|
||||
} finally {
|
||||
store.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('removal inputs require exact bounded fields and both safe version counters', () => {
|
||||
const valid = change();
|
||||
assert.equal(validAdminRemovalInput(valid), true);
|
||||
for (const invalid of [
|
||||
null,
|
||||
[],
|
||||
{},
|
||||
{ ...valid, extra: true },
|
||||
{ ...valid, removed: 1 },
|
||||
...['', ' ', 'x'.repeat(301), 'line\n', 'a\u007f'].map((reason) => ({
|
||||
...valid,
|
||||
reason,
|
||||
})),
|
||||
...['', valid.operationId + '\n', valid.operationId.toUpperCase()].map(
|
||||
(operationId) => ({ ...valid, operationId }),
|
||||
),
|
||||
...[-1, 0.5, Infinity, NaN, Number.MAX_SAFE_INTEGER, '0'].flatMap(
|
||||
(value) => [
|
||||
{ ...valid, expectedVersion: value },
|
||||
{ ...valid, expectedRevision: value },
|
||||
],
|
||||
),
|
||||
Object.assign(Object.create({ removed: true }), {
|
||||
operationId: valid.operationId,
|
||||
expectedVersion: 0,
|
||||
expectedRevision: 0,
|
||||
reason: 'Reason',
|
||||
other: true,
|
||||
}),
|
||||
])
|
||||
assert.equal(validAdminRemovalInput(invalid), false);
|
||||
});
|
||||
|
||||
void test('live roles can read only operational defaults without backfilling or disclosing room and seat contents', async () => {
|
||||
for (const role of ['owner', 'operator', 'viewer'] as const) {
|
||||
const f = await fixture(role);
|
||||
try {
|
||||
const before = f.row();
|
||||
assert.deepEqual(await f.read(), {
|
||||
code: f.code,
|
||||
version: 0,
|
||||
removed: false,
|
||||
revision: 0,
|
||||
removedAt: null,
|
||||
updatedAt: null,
|
||||
paused: false,
|
||||
joinLocked: false,
|
||||
});
|
||||
assert.deepEqual(f.row(), before);
|
||||
assert.deepEqual(rows(f, 'room_removals'), []);
|
||||
if (role === 'viewer') await assert.rejects(f.apply(), status(403));
|
||||
else assert.equal((await f.apply()).room.removed, true);
|
||||
await assert.rejects(
|
||||
readAdminRemoval(f.database, f.identity, f.code + '\n', now),
|
||||
status(400),
|
||||
);
|
||||
await assert.rejects(
|
||||
readAdminRemoval(f.database, f.identity, 'ABSENTAA', now),
|
||||
status(404),
|
||||
);
|
||||
await assert.rejects(
|
||||
readAdminRemoval(
|
||||
f.database,
|
||||
{ ...f.identity, sessionHash: f.tokenHash },
|
||||
f.code,
|
||||
now,
|
||||
),
|
||||
status(401),
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('remove and restore preserve saved JSON, every dependent record, revoked access and unrelated rooms', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
seedDependents(f, f);
|
||||
const other = seed(f, 'KEEPSAFE');
|
||||
const old = f.row(),
|
||||
secure = secureRows(f);
|
||||
const untouched = f.sqlite
|
||||
.prepare('SELECT * FROM rooms WHERE code=?')
|
||||
.get(other.code);
|
||||
const result = await f.apply();
|
||||
assert.equal(result.replayed, false);
|
||||
assert.equal(result.appliedVersion, 1);
|
||||
assert.equal(result.appliedRevision, 1);
|
||||
assert.deepEqual(result.room, {
|
||||
code: f.code,
|
||||
version: 1,
|
||||
removed: true,
|
||||
revision: 1,
|
||||
removedAt: now,
|
||||
updatedAt: now,
|
||||
paused: false,
|
||||
joinLocked: false,
|
||||
});
|
||||
assert.equal(f.row().state, old.state);
|
||||
const restored = await f.apply(change(1, 1, false), now + 5000);
|
||||
assert.deepEqual(restored.room, {
|
||||
...result.room,
|
||||
version: 2,
|
||||
removed: false,
|
||||
revision: 2,
|
||||
removedAt: null,
|
||||
updatedAt: now + 5000,
|
||||
});
|
||||
assert.equal(f.row().state, old.state);
|
||||
assert.deepEqual(secureRows(f), secure);
|
||||
assert.deepEqual(
|
||||
f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(other.code),
|
||||
untouched,
|
||||
);
|
||||
assert.equal(rows(f, 'room_removals').length, 1);
|
||||
const audit = rows(f, 'admin_room_removals');
|
||||
assert.equal(audit.length, 2);
|
||||
for (const row of audit) {
|
||||
const metadata = JSON.stringify([
|
||||
JSON.parse(String(row.before_metadata)),
|
||||
JSON.parse(String(row.after_metadata)),
|
||||
]);
|
||||
for (const privateValue of [
|
||||
f.host.id,
|
||||
f.host.name,
|
||||
f.token,
|
||||
f.tokenHash,
|
||||
'Private saved discussion',
|
||||
'spice',
|
||||
'deck',
|
||||
f.account.name,
|
||||
])
|
||||
assert.equal(metadata.includes(privateValue), false);
|
||||
assert.equal(row.actor_admin_id, f.identity.id);
|
||||
assert.equal(row.reason, change().reason);
|
||||
}
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('removal and restoration tolerate unreadable saves without parsing diagnostics or repairing JSON', async () => {
|
||||
for (const state of [
|
||||
'{invalid private save',
|
||||
'null',
|
||||
'[]',
|
||||
'{"status":"playing","players":[null,7,"private"]}',
|
||||
]) {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.sqlite
|
||||
.prepare('UPDATE rooms SET state=? WHERE code=?')
|
||||
.run(state, f.code);
|
||||
await f.apply();
|
||||
assert.equal((await f.read()).removed, true);
|
||||
await f.apply(change(1, 1, false));
|
||||
assert.equal(f.row().state, state);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('restoration refreshes existing AI pacing only when unpaused and preserves pause and joining flags', async () => {
|
||||
for (const paused of [false, true])
|
||||
for (const controller of ['bot', 'autopilot', 'human'] as const) {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const game = structuredClone(f.game);
|
||||
game.status = 'playing';
|
||||
if (controller !== 'human') game.players[0][controller] = 'Hard';
|
||||
game.botsPending = controller !== 'human';
|
||||
game.botNextActionAt = 5;
|
||||
f.sqlite
|
||||
.prepare('UPDATE rooms SET state=? WHERE code=?')
|
||||
.run(JSON.stringify(game), f.code);
|
||||
const controls = {
|
||||
operationId: randomUUID(),
|
||||
expectedRevision: 0,
|
||||
paused,
|
||||
joinLocked: true,
|
||||
reason: 'QA controls',
|
||||
};
|
||||
await applyAdminRoomControl(
|
||||
f.database,
|
||||
f.identity,
|
||||
f.code,
|
||||
controls,
|
||||
now,
|
||||
);
|
||||
const state = String(f.row().state),
|
||||
beforeControls = rows(f, 'room_controls');
|
||||
await f.apply(change(f.version()));
|
||||
assert.equal(f.row().state, state);
|
||||
const restored = await f.apply(
|
||||
change(f.version(), 1, false),
|
||||
now + 3000,
|
||||
);
|
||||
assert.equal(restored.room.paused, paused);
|
||||
assert.equal(restored.room.joinLocked, true);
|
||||
const expected = JSON.parse(state);
|
||||
if (!paused && controller !== 'human') {
|
||||
expected.botsPending = true;
|
||||
expected.botNextActionAt = now + 4500;
|
||||
} else assert.equal(f.row().state, state);
|
||||
assert.deepEqual(JSON.parse(String(f.row().state)), expected);
|
||||
assert.deepEqual(rows(f, 'room_controls'), beforeControls);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('exact receipts bind actor, room and normalized intent and report current state after later transitions', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const input = { ...change(), reason: ' Exact QA reason ' };
|
||||
const first = await f.apply(input);
|
||||
const restore = await f.apply(change(1, 1, false), now + 1);
|
||||
const replay = await f.apply(
|
||||
{ ...input, reason: 'Exact QA reason' },
|
||||
now + 2,
|
||||
);
|
||||
assert.deepEqual(replay, { ...first, replayed: true, room: restore.room });
|
||||
const before = f.row();
|
||||
for (const mismatch of [
|
||||
{ ...input, expectedVersion: 1 },
|
||||
{ ...input, expectedRevision: 1 },
|
||||
{ ...input, removed: false },
|
||||
{ ...input, reason: 'Other reason' },
|
||||
])
|
||||
await assert.rejects(f.apply(mismatch), status(409));
|
||||
const account = f.provision('operator');
|
||||
const login = await adminLogin(f.database, account.key, now);
|
||||
const identity = await requireAdmin(
|
||||
f.database,
|
||||
login.token,
|
||||
undefined,
|
||||
now,
|
||||
);
|
||||
await assert.rejects(
|
||||
applyAdminRemoval(f.database, identity, f.code, input, now),
|
||||
status(409),
|
||||
);
|
||||
seed(f, 'KEEPSAFE');
|
||||
await assert.rejects(
|
||||
applyAdminRemoval(f.database, f.identity, 'KEEPSAFE', input, now),
|
||||
status(409),
|
||||
);
|
||||
assert.deepEqual(f.row(), before);
|
||||
assert.equal(rows(f, 'admin_room_removals').length, 2);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('new no-op and stale requests reject without writes; both room version and removal revision fence races', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const before = f.row();
|
||||
for (const input of [change(0, 0, false), change(1), change(0, 1)])
|
||||
await assert.rejects(f.apply(input), status(409));
|
||||
assert.deepEqual(f.row(), before);
|
||||
assert.deepEqual(rows(f, 'admin_room_removals'), []);
|
||||
const pending = change();
|
||||
const gate = pauseAdminBatch(f);
|
||||
const result = f.apply(pending).then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
await gate.waiting;
|
||||
f.sqlite
|
||||
.prepare('UPDATE rooms SET version=version+1 WHERE code=?')
|
||||
.run(f.code);
|
||||
gate.release();
|
||||
assert.ok(status(409)(await result));
|
||||
await f.apply(change(1));
|
||||
await assert.rejects(f.apply(change(2, 1, true)), status(409));
|
||||
await assert.rejects(f.apply(change(2, 0, false)), status(409));
|
||||
assert.equal(rows(f, 'admin_room_removals').length, 1);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('live session, account, role, expiry and generation authority fences reads, writes and exact replay', async () => {
|
||||
for (const operation of ['read', 'write', 'replay'] as const)
|
||||
for (const revoke of [
|
||||
'disabled',
|
||||
'role',
|
||||
'session',
|
||||
'generation',
|
||||
'expiry',
|
||||
] as const) {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const input = change();
|
||||
if (operation === 'replay') await f.apply(input);
|
||||
const before = f.row(),
|
||||
audit = rows(f, 'admin_room_removals');
|
||||
const gate = pauseAdminBatch(f);
|
||||
const pending = (operation === 'read' ? f.read() : f.apply(input)).then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
await gate.waiting;
|
||||
if (revoke === 'disabled')
|
||||
f.sqlite
|
||||
.prepare('UPDATE admin_accounts SET enabled=0 WHERE id=?')
|
||||
.run(f.identity.id);
|
||||
if (revoke === 'role')
|
||||
f.sqlite
|
||||
.prepare("UPDATE admin_accounts SET role='viewer' WHERE id=?")
|
||||
.run(f.identity.id);
|
||||
if (revoke === 'session')
|
||||
f.sqlite
|
||||
.prepare(
|
||||
'UPDATE admin_sessions SET revoked_at=? WHERE token_hash=?',
|
||||
)
|
||||
.run(now, f.identity.sessionHash);
|
||||
if (revoke === 'generation')
|
||||
f.sqlite
|
||||
.prepare(
|
||||
'UPDATE admin_accounts SET session_generation=session_generation+1 WHERE id=?',
|
||||
)
|
||||
.run(f.identity.id);
|
||||
if (revoke === 'expiry')
|
||||
f.sqlite
|
||||
.prepare(
|
||||
'UPDATE admin_sessions SET expires_at=? WHERE token_hash=?',
|
||||
)
|
||||
.run(now, f.identity.sessionHash);
|
||||
gate.release();
|
||||
const result = await pending;
|
||||
if (revoke === 'role' && operation === 'read')
|
||||
assert.equal(result, null);
|
||||
else assert.ok(status(revoke === 'role' ? 403 : 401)(result));
|
||||
assert.deepEqual(f.row(), before);
|
||||
assert.deepEqual(rows(f, 'admin_room_removals'), audit);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('queued identical requests commit once while competing operations cannot reuse the old version', async () => {
|
||||
for (const same of [true, false]) {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const input = change(),
|
||||
gate = pauseAdminBatch(f);
|
||||
const pending = f.apply(input).then(
|
||||
(value) => value,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
await gate.waiting;
|
||||
const first = await f.apply(same ? input : change());
|
||||
gate.release();
|
||||
const second = await pending;
|
||||
if (same) assert.deepEqual(second, { ...first, replayed: true });
|
||||
else assert.ok(status(409)(second));
|
||||
assert.equal(f.version(), 1);
|
||||
assert.equal(rows(f, 'admin_room_removals').length, 1);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('audit, game fence and removal metadata roll back together at every failed persistence stage', async () => {
|
||||
for (const table of ['admin_room_removals', 'rooms', 'room_removals']) {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const before = f.row(),
|
||||
secure = secureRows(f);
|
||||
f.sqlite.exec(
|
||||
`CREATE TRIGGER qa_failure BEFORE ${table === 'rooms' ? 'UPDATE' : 'INSERT'} ON ${table} BEGIN SELECT RAISE(ABORT,'QA removal failure'); END;`,
|
||||
);
|
||||
await assert.rejects(f.apply(), /QA removal failure/);
|
||||
assert.deepEqual(f.row(), before);
|
||||
assert.deepEqual(secureRows(f), secure);
|
||||
assert.deepEqual(rows(f, 'admin_room_removals'), []);
|
||||
assert.deepEqual(rows(f, 'room_removals'), []);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('removed rooms keep public admin reads and old receipts while new lobby and pause mutations reject', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const lobbyInput = {
|
||||
operationId: randomUUID(),
|
||||
expectedVersion: 0,
|
||||
action: { type: 'rules' as const, advanced: true },
|
||||
reason: 'QA rules',
|
||||
};
|
||||
const lobby = await configureAdminLobby(
|
||||
f.database,
|
||||
f.identity,
|
||||
f.code,
|
||||
lobbyInput,
|
||||
now,
|
||||
);
|
||||
const controls = {
|
||||
operationId: randomUUID(),
|
||||
expectedRevision: 0,
|
||||
paused: false,
|
||||
joinLocked: true,
|
||||
reason: 'QA controls',
|
||||
};
|
||||
await applyAdminRoomControl(f.database, f.identity, f.code, controls, now);
|
||||
await f.apply(change(f.version()));
|
||||
const before = f.row();
|
||||
assert.equal(
|
||||
(await readAdminLobby(f.database, f.identity, f.code, now)).editable,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
(await readAdminRoomControl(f.database, f.identity, f.code, now))
|
||||
.joinLocked,
|
||||
true,
|
||||
);
|
||||
const receipt = await configureAdminLobby(
|
||||
f.database,
|
||||
f.identity,
|
||||
f.code,
|
||||
lobbyInput,
|
||||
now,
|
||||
);
|
||||
assert.equal(receipt.replayed, true);
|
||||
assert.equal(receipt.appliedVersion, lobby.appliedVersion);
|
||||
assert.equal(receipt.lobby.editable, false);
|
||||
assert.equal(
|
||||
(
|
||||
await applyAdminRoomControl(
|
||||
f.database,
|
||||
f.identity,
|
||||
f.code,
|
||||
controls,
|
||||
now,
|
||||
)
|
||||
).replayed,
|
||||
true,
|
||||
);
|
||||
await assert.rejects(
|
||||
configureAdminLobby(
|
||||
f.database,
|
||||
f.identity,
|
||||
f.code,
|
||||
{
|
||||
...lobbyInput,
|
||||
operationId: randomUUID(),
|
||||
expectedVersion: f.version(),
|
||||
},
|
||||
now,
|
||||
),
|
||||
status(409),
|
||||
);
|
||||
await assert.rejects(
|
||||
applyAdminRoomControl(
|
||||
f.database,
|
||||
f.identity,
|
||||
f.code,
|
||||
{
|
||||
...controls,
|
||||
operationId: randomUUID(),
|
||||
expectedRevision: 1,
|
||||
paused: true,
|
||||
},
|
||||
now,
|
||||
),
|
||||
status(409),
|
||||
);
|
||||
assert.deepEqual(f.row(), before);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('prepared admin lobby and pause writes lose their transaction race to removal', async () => {
|
||||
for (const operation of ['lobby', 'pause'] as const) {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const gate = gateBatch(f, operation === 'lobby' ? 2 : 1);
|
||||
const pending = (
|
||||
operation === 'lobby'
|
||||
? configureAdminLobby(
|
||||
f.database,
|
||||
f.identity,
|
||||
f.code,
|
||||
{
|
||||
operationId: randomUUID(),
|
||||
expectedVersion: 0,
|
||||
action: { type: 'rules', advanced: true },
|
||||
reason: 'QA rules',
|
||||
},
|
||||
now,
|
||||
)
|
||||
: applyAdminRoomControl(
|
||||
f.database,
|
||||
f.identity,
|
||||
f.code,
|
||||
{
|
||||
operationId: randomUUID(),
|
||||
expectedRevision: 0,
|
||||
paused: true,
|
||||
joinLocked: true,
|
||||
reason: 'QA pause',
|
||||
},
|
||||
now,
|
||||
)
|
||||
).then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
await gate.waiting;
|
||||
await f.apply();
|
||||
const before = f.row();
|
||||
gate.release();
|
||||
assert.ok(status(409)(await pending));
|
||||
assert.deepEqual(f.row(), before);
|
||||
assert.deepEqual(rows(f, 'admin_lobby_operations'), []);
|
||||
assert.deepEqual(rows(f, 'admin_room_audit'), []);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('creation receipts report temporary removal without granting access and recover only still-active original seats', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const input = {
|
||||
operationId: randomUUID(),
|
||||
sessionToken: secret(),
|
||||
name: 'New host',
|
||||
faction: 'atreides' as const,
|
||||
advanced: false,
|
||||
techTokens: false,
|
||||
strongholdCards: false,
|
||||
bots: [],
|
||||
reason: 'QA creation',
|
||||
};
|
||||
const created = await createAdminRoom(f.database, f.identity, input, now);
|
||||
await applyAdminRemoval(
|
||||
f.database,
|
||||
f.identity,
|
||||
created.code,
|
||||
change(),
|
||||
now,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await createAdminRoom(f.database, f.identity, input, now),
|
||||
{ ...created, replayed: true, hostAccess: false, roomRemoved: true },
|
||||
);
|
||||
await applyAdminRemoval(
|
||||
f.database,
|
||||
f.identity,
|
||||
created.code,
|
||||
change(1, 1, false),
|
||||
now,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await createAdminRoom(f.database, f.identity, input, now),
|
||||
{ ...created, replayed: true },
|
||||
);
|
||||
await applyAdminRemoval(
|
||||
f.database,
|
||||
f.identity,
|
||||
created.code,
|
||||
change(2, 2),
|
||||
now,
|
||||
);
|
||||
f.sqlite
|
||||
.prepare('UPDATE seats SET revoked=1 WHERE room_code=?')
|
||||
.run(created.code);
|
||||
await applyAdminRemoval(
|
||||
f.database,
|
||||
f.identity,
|
||||
created.code,
|
||||
change(3, 3, false),
|
||||
now,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await createAdminRoom(f.database, f.identity, input, now),
|
||||
{ ...created, replayed: true, hostAccess: false },
|
||||
);
|
||||
assert.equal(rows(f, 'admin_room_creations').length, 1);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('removed games and exact receipts survive database reopen without restoring historical credentials', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dune-admin-removal-'));
|
||||
let store = adminStore(join(dir, 'rooms.sqlite'));
|
||||
try {
|
||||
const account = store.provision('operator'),
|
||||
login = await adminLogin(store.database, account.key, now);
|
||||
const identity = await requireAdmin(
|
||||
store.database,
|
||||
login.token,
|
||||
undefined,
|
||||
now,
|
||||
),
|
||||
room = seed(store);
|
||||
seedDependents(store, room);
|
||||
const secure = secureRows(store),
|
||||
state = rows(store, 'rooms')[0].state;
|
||||
const input = change();
|
||||
const removed = await applyAdminRemoval(
|
||||
store.database,
|
||||
identity,
|
||||
room.code,
|
||||
input,
|
||||
now,
|
||||
);
|
||||
store.sqlite.close();
|
||||
store = adminStore(join(dir, 'rooms.sqlite'), false);
|
||||
assert.deepEqual(
|
||||
await applyAdminRemoval(store.database, identity, room.code, input, now),
|
||||
{ ...removed, replayed: true },
|
||||
);
|
||||
await applyAdminRemoval(
|
||||
store.database,
|
||||
identity,
|
||||
room.code,
|
||||
change(1, 1, false),
|
||||
now + 10000,
|
||||
);
|
||||
assert.equal(rows(store, 'rooms')[0].state, state);
|
||||
assert.deepEqual(secureRows(store), secure);
|
||||
const audit = rows(store, 'admin_room_removals');
|
||||
store.sqlite.prepare('DELETE FROM rooms WHERE code=?').run(room.code);
|
||||
assert.deepEqual(rows(store, 'admin_room_removals'), audit);
|
||||
} finally {
|
||||
store.sqlite.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -11,6 +11,20 @@ function storage() {
|
||||
}
|
||||
const receipt = (operationId: string): AdminRoomCreationResult => ({ operationId, code: 'ABCD2345', hostId: crypto.randomUUID(), replayed: true, hostAccess: true });
|
||||
|
||||
void test('a removed room confirmation cannot erase its only creation proof before restoration', () => {
|
||||
const tab = storage(), input = newAdminRoomCreationRequest(fields());
|
||||
saveAdminRoomCreation(tab, 'a', input);
|
||||
const result = { ...receipt(input.operationId), hostAccess: false, roomRemoved: true as const };
|
||||
assert.equal(validAdminRoomCreationResult(result), true);
|
||||
assert.equal(validAdminRoomCreationResult({ ...result, hostAccess: true }), false);
|
||||
assert.equal(validAdminRoomCreationResult({ ...result, roomRemoved: false }), false);
|
||||
assert.throws(() => completeAdminRoomCreation(tab, 'a', input, result), /temporarily removed/);
|
||||
assert.deepEqual(readAdminRoomCreation(tab, 'a'), { kind: 'pending', input });
|
||||
const restored = receipt(input.operationId);
|
||||
completeAdminRoomCreation(tab, 'a', input, restored);
|
||||
assert.deepEqual(readAdminRoomCreation(tab, 'a'), { kind: 'completed', result: restored });
|
||||
});
|
||||
|
||||
void test('creation retry preserves one exact private host proof and roster order in its original administrator scope', () => {
|
||||
const tab = storage(), input = newAdminRoomCreationRequest(fields());
|
||||
assert.equal(input.name, 'Host QA'); assert.equal(input.reason, 'Dedicated QA lobby');
|
||||
|
||||
@@ -4,11 +4,38 @@ import {
|
||||
ClientRequestError,
|
||||
requestJson,
|
||||
requestMayHaveCompleted,
|
||||
isRoomRemoved,
|
||||
subscribeRoomRemoval,
|
||||
} from '../lib/client-request';
|
||||
|
||||
const never = () => new Promise<never>(() => {});
|
||||
const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
void test('typed removal retains post-commit proofs and notifies only the affected public room', async () => {
|
||||
const rooms: string[] = [];
|
||||
const stop = subscribeRoomRemoval(room => rooms.push(room));
|
||||
const fetcher: typeof fetch = async () => Response.json({ code: 'ROOM_REMOVED', error: 'Room removed', private: 'NOT_FOR_LISTENERS' }, { status: 410 });
|
||||
try {
|
||||
for (const path of ['/api/rooms/TEST2345', '/api/rooms/TEST2345/control', '/api/rooms/TEST2345/messages', '/api/rooms'])
|
||||
await assert.rejects(requestJson(path, { method: 'POST' }, { fetcher }), error => isRoomRemoved(error) && error.status === 410 && requestMayHaveCompleted(error));
|
||||
assert.deepEqual(rooms, ['TEST2345', 'TEST2345', 'TEST2345']);
|
||||
} finally { stop(); }
|
||||
await assert.rejects(requestJson('/api/rooms/TEST2345', {}, { fetcher }));
|
||||
assert.equal(rooms.length, 3, 'Unmounted controllers no longer receive removal events');
|
||||
assert.equal(requestMayHaveCompleted(new ClientRequestError('Ordinary rejection', 'http', 410)), false);
|
||||
});
|
||||
|
||||
void test('removal observers cannot swallow the typed error or trigger automatic request retries', async () => {
|
||||
let calls = 0;
|
||||
const stop = subscribeRoomRemoval(() => { throw new Error('Controller failed'); });
|
||||
try {
|
||||
await assert.rejects(requestJson('/api/rooms/TEST2345', {}, { fetcher: async () => {
|
||||
calls++; return Response.json({ code: 'ROOM_REMOVED', error: 'Removed' }, { status: 410 });
|
||||
} }), isRoomRemoved);
|
||||
assert.equal(calls, 1);
|
||||
} finally { stop(); }
|
||||
});
|
||||
|
||||
void test('successful JSON preserves mutation body, version and credential options, with one fetch', async () => {
|
||||
let calls = 0;
|
||||
let signal: AbortSignal | null | undefined;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, type Game } from '../game/engine';
|
||||
|
||||
for (const change of ['join', 'faction'] as const) {
|
||||
void test(`human ${change} clears human readiness while AI remains ready through saved start`, () => {
|
||||
let game = createGame('READY234', newPlayer('host', 'Host', 'atreides'), false);
|
||||
game = applyAction(game, 'host', { type: 'addBot', faction: 'harkonnen', difficulty: 'Easy' });
|
||||
game = applyAction(game, 'host', { type: 'ready' });
|
||||
if (change === 'join') joinGame(game, newPlayer('guest', 'Guest', 'emperor'));
|
||||
else game = applyAction(game, 'host', { type: 'faction', faction: 'emperor' });
|
||||
assert.ok(game.players.filter(p => !p.bot).every(p => !p.ready));
|
||||
assert.ok(game.players.filter(p => p.bot).every(p => p.ready));
|
||||
game = JSON.parse(JSON.stringify(game)) as Game;
|
||||
for (const player of game.players.filter(p => !p.bot)) game = applyAction(game, player.id, { type: 'ready' });
|
||||
const started = applyAction(game, 'host', { type: 'start' });
|
||||
assert.equal(started.status, 'setup');
|
||||
assert.equal(started.players.length, change === 'join' ? 3 : 2);
|
||||
});
|
||||
}
|
||||
@@ -23,6 +23,13 @@ const fields = {
|
||||
advanced: false,
|
||||
};
|
||||
const playerId = '93c66e7c-2d3b-4845-aedf-5d1f06cf3371';
|
||||
|
||||
void test('temporary removal cannot clear a first creation or join proof that may already have committed', () => {
|
||||
const removed = new ClientRequestError('Room removed', 'http', 410, 'ROOM_REMOVED');
|
||||
assert.equal(roomEntryRejectedBeforeCommit(true, removed), false);
|
||||
assert.equal(roomEntryRejectedBeforeCommit(false, removed), false);
|
||||
assert.equal(roomEntryRejectedBeforeCommit(true, new ClientRequestError('Invalid input', 'http', 400)), true);
|
||||
});
|
||||
const memoryStorage = () => {
|
||||
const data = new Map<string, string>();
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,918 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomBytes, randomUUID, webcrypto } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
import { adminStore } from './admin-access-fixture';
|
||||
import { adminLogin, requireAdmin } from '../db/admin-access';
|
||||
import { applyAdminRemoval, readAdminRemoval } from '../db/admin-removal';
|
||||
import * as engine from '../game/engine';
|
||||
import * as bots from '../game/bots';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import * as delegation from '../lib/seat-ai-delegation';
|
||||
import * as talkContract from '../lib/table-talk';
|
||||
import type * as Rooms from '../db/rooms';
|
||||
import type * as Talk from '../db/table-talk';
|
||||
|
||||
const secret = () => randomBytes(32).toString('hex');
|
||||
const entry = () => ({ operationId: randomUUID(), sessionToken: secret() });
|
||||
const removed = (error: unknown) =>
|
||||
!!error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ROOM_REMOVED' &&
|
||||
'status' in error &&
|
||||
error.status === 410;
|
||||
const draft = (recipientId: string | null = null) => ({
|
||||
id: randomUUID(),
|
||||
recipientId,
|
||||
text: 'Retained discussion for the same saved seat.',
|
||||
});
|
||||
|
||||
type Values = (string | number | null)[];
|
||||
async function fixture(started = false) {
|
||||
const store = adminStore();
|
||||
assert.ok(store.sqlite instanceof DatabaseSync);
|
||||
const account = store.provision('operator');
|
||||
const login = await adminLogin(store.database, account.key);
|
||||
const identity = await requireAdmin(store.database, login.token);
|
||||
const calls = { bots: 0, normalization: 0, views: 0 };
|
||||
const hooks: {
|
||||
beforeFirst?: (sql: string, values: Values) => void;
|
||||
beforeRun?: (sql: string, values: Values) => void;
|
||||
} = {};
|
||||
const database = {
|
||||
batch: (statements: D1PreparedStatement[]) =>
|
||||
store.database.batch(statements),
|
||||
prepare(sql: string) {
|
||||
const statement = store.database.prepare(sql);
|
||||
const first = statement.first.bind(statement);
|
||||
const run = statement.run.bind(statement);
|
||||
const bind = statement.bind.bind(statement);
|
||||
let values: Values = [];
|
||||
statement.bind = (...input: Values) => {
|
||||
values = input;
|
||||
return bind(...input);
|
||||
};
|
||||
statement.first = async <T>() => {
|
||||
hooks.beforeFirst?.(sql, values);
|
||||
return first<T>();
|
||||
};
|
||||
statement.run = async <T>() => {
|
||||
hooks.beforeRun?.(sql, values);
|
||||
return run<T>();
|
||||
};
|
||||
return statement;
|
||||
},
|
||||
} as D1Database;
|
||||
function load<T>(file: string) {
|
||||
const exports = {};
|
||||
runInNewContext(
|
||||
ts.transpileModule(readFileSync(new URL(file, import.meta.url), 'utf8'), {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
}).outputText,
|
||||
{
|
||||
exports,
|
||||
crypto: webcrypto,
|
||||
TextEncoder,
|
||||
structuredClone,
|
||||
JSON,
|
||||
require: (name: string) => {
|
||||
if (name === 'cloudflare:workers') return { env: { DB: database } };
|
||||
if (name === '@/game/engine')
|
||||
return {
|
||||
...engine,
|
||||
viewGame: (...args: Parameters<typeof engine.viewGame>) => {
|
||||
calls.views++;
|
||||
return engine.viewGame(...args);
|
||||
},
|
||||
normalizeAutomaticGame: (
|
||||
...args: Parameters<typeof engine.normalizeAutomaticGame>
|
||||
) => {
|
||||
calls.normalization++;
|
||||
return engine.normalizeAutomaticGame(...args);
|
||||
},
|
||||
};
|
||||
if (name === '@/game/bots')
|
||||
return {
|
||||
...bots,
|
||||
runBots: (...args: Parameters<typeof bots.runBots>) => {
|
||||
calls.bots++;
|
||||
return bots.runBots(...args);
|
||||
},
|
||||
};
|
||||
if (name === '@/lib/seat-ai-delegation') return delegation;
|
||||
if (name === '../lib/table-talk') return talkContract;
|
||||
throw new Error('Unexpected dependency ' + name);
|
||||
},
|
||||
},
|
||||
);
|
||||
return exports as T;
|
||||
}
|
||||
const rooms = load<typeof Rooms>('../db/rooms.ts');
|
||||
const talk = load<typeof Talk>('../db/table-talk.ts');
|
||||
const ownerEntry = entry(),
|
||||
guestEntry = entry();
|
||||
const owner = await rooms.createRoom(
|
||||
'Removal owner',
|
||||
'atreides',
|
||||
false,
|
||||
[],
|
||||
ownerEntry,
|
||||
);
|
||||
const code = owner.view.code;
|
||||
const guest = await rooms.joinRoom(
|
||||
code,
|
||||
'Removal guest',
|
||||
'emperor',
|
||||
guestEntry,
|
||||
);
|
||||
const auth = await rooms.authenticate(code, owner.token);
|
||||
const guestAuth = await rooms.authenticate(code, guest.token!);
|
||||
let now = 10000;
|
||||
const clock = {
|
||||
now: () => now,
|
||||
advance: (ms: number) => {
|
||||
now += ms;
|
||||
},
|
||||
sleep: async (ms: number) => {
|
||||
now += ms;
|
||||
},
|
||||
};
|
||||
const version = () =>
|
||||
Number(
|
||||
store.sqlite
|
||||
.prepare('SELECT version FROM rooms WHERE code = ?')
|
||||
.get(code)!.version,
|
||||
);
|
||||
if (started) {
|
||||
for (const seat of [auth, guestAuth])
|
||||
await rooms.act(code, seat, version(), { type: 'ready' }, clock);
|
||||
await rooms.act(code, auth, version(), { type: 'start' }, clock);
|
||||
}
|
||||
const transition = async (removed: boolean, roomCode = code) => {
|
||||
const view = await readAdminRemoval(database, identity, roomCode, now);
|
||||
return applyAdminRemoval(
|
||||
database,
|
||||
identity,
|
||||
roomCode,
|
||||
{
|
||||
operationId: randomUUID(),
|
||||
expectedVersion: view.version,
|
||||
expectedRevision: view.revision,
|
||||
removed,
|
||||
reason: 'Dedicated recoverable removal runtime QA',
|
||||
},
|
||||
now,
|
||||
);
|
||||
};
|
||||
// Deliberately retain the old game version to prove the SQL removal predicate itself,
|
||||
// independently of the additional version bump made by the real admin transaction.
|
||||
const flagRemoved = (roomCode = code) =>
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES (?,1,1,10000,10000)',
|
||||
)
|
||||
.run(roomCode);
|
||||
const snapshot = () =>
|
||||
Object.fromEntries(
|
||||
[
|
||||
'rooms',
|
||||
'seats',
|
||||
'room_entry_receipts',
|
||||
'seat_recovery_keys',
|
||||
'seat_recovery_receipts',
|
||||
'seat_handover_offers',
|
||||
'seat_handover_claim_receipts',
|
||||
'seat_ai_delegations',
|
||||
'room_messages',
|
||||
'room_controls',
|
||||
'room_removals',
|
||||
].map((table) => [
|
||||
table,
|
||||
store.sqlite
|
||||
.prepare('SELECT * FROM ' + table + ' ORDER BY rowid')
|
||||
.all(),
|
||||
]),
|
||||
);
|
||||
return {
|
||||
...store,
|
||||
rooms,
|
||||
talk,
|
||||
hooks,
|
||||
calls,
|
||||
code,
|
||||
owner,
|
||||
ownerEntry,
|
||||
guest,
|
||||
guestEntry,
|
||||
auth,
|
||||
guestAuth,
|
||||
clock,
|
||||
version,
|
||||
transition,
|
||||
flagRemoved,
|
||||
snapshot,
|
||||
restart: () => load<typeof Rooms>('../db/rooms.ts'),
|
||||
};
|
||||
}
|
||||
type Fixture = Awaited<ReturnType<typeof fixture>>;
|
||||
const grant = (f: Fixture) => ({
|
||||
grantId: randomUUID(),
|
||||
delegateId: f.guestAuth.playerId,
|
||||
difficulty: 'Hard' as const,
|
||||
});
|
||||
const offer = () => ({ offerId: randomUUID(), handoverSecret: secret() });
|
||||
const recovery = (f: Fixture, recoverySecret: string) => ({
|
||||
playerId: f.auth.playerId,
|
||||
recoverySecret,
|
||||
operationId: randomUUID(),
|
||||
newSessionToken: secret(),
|
||||
});
|
||||
|
||||
void test('an invitation without a saved cookie reports removal and retains the ordinary active-room join error', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await assert.rejects(
|
||||
f.rooms.authenticate(f.code, ''),
|
||||
/Join this room first\./,
|
||||
);
|
||||
await f.transition(true);
|
||||
const before = f.snapshot(),
|
||||
calls = { ...f.calls };
|
||||
await assert.rejects(f.rooms.authenticate(f.code, ''), removed);
|
||||
assert.deepEqual(f.snapshot(), before);
|
||||
assert.deepEqual(f.calls, calls);
|
||||
await f.transition(false);
|
||||
await assert.rejects(
|
||||
f.rooms.authenticate(f.code, ''),
|
||||
/Join this room first\./,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('removed rooms deny private access, entry receipts, every seat control and discussion without invalidating proofs', async () => {
|
||||
const f = await fixture(true);
|
||||
try {
|
||||
const consent = grant(f),
|
||||
handover = offer(),
|
||||
recoverySecret = secret();
|
||||
await f.rooms.setSeatAiDelegate(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
consent,
|
||||
f.clock,
|
||||
);
|
||||
await f.rooms.setRecoveryKey(f.code, f.auth, f.version(), recoverySecret);
|
||||
await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
handover,
|
||||
f.clock,
|
||||
);
|
||||
const message = draft(f.guestAuth.playerId);
|
||||
await f.talk.sendTableTalk(f.code, f.auth, message, 1000);
|
||||
await f.transition(true);
|
||||
const before = f.snapshot(),
|
||||
views = f.calls.views;
|
||||
const attempts = [
|
||||
() => f.rooms.authenticate(f.code, f.owner.token),
|
||||
() => f.rooms.readRoom(f.code),
|
||||
() => f.rooms.readSeatView(f.code, f.auth),
|
||||
() =>
|
||||
f.rooms.createRoom(
|
||||
'Removal owner',
|
||||
'atreides',
|
||||
false,
|
||||
[],
|
||||
f.ownerEntry,
|
||||
),
|
||||
() => f.rooms.joinRoom(f.code, 'Removal guest', 'emperor', f.guestEntry),
|
||||
() => f.rooms.joinRoom(f.code, 'New name', 'guild', undefined, f.auth),
|
||||
() => f.rooms.joinRoom(f.code, 'New guest', 'guild', entry()),
|
||||
() =>
|
||||
f.rooms.act(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
{ type: 'setAutopilot', difficulty: null },
|
||||
f.clock,
|
||||
),
|
||||
() =>
|
||||
f.rooms.act(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
{ type: 'advanceBots' },
|
||||
f.clock,
|
||||
),
|
||||
() =>
|
||||
f.rooms.setSeatAiDelegate(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
consent,
|
||||
f.clock,
|
||||
),
|
||||
() =>
|
||||
f.rooms.revokeSeatAiDelegate(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
consent.grantId,
|
||||
f.clock,
|
||||
),
|
||||
() =>
|
||||
f.rooms.useSeatAiDelegate(
|
||||
f.code,
|
||||
f.guestAuth,
|
||||
f.version(),
|
||||
{ ownerId: f.auth.playerId, grantId: consent.grantId },
|
||||
f.clock,
|
||||
),
|
||||
() => f.rooms.setRecoveryKey(f.code, f.auth, f.version(), secret()),
|
||||
() => f.rooms.recoverSeat(f.code, recovery(f, recoverySecret)),
|
||||
() =>
|
||||
f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
handover,
|
||||
f.clock,
|
||||
),
|
||||
() =>
|
||||
f.rooms.revokeSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
handover.offerId,
|
||||
f.clock,
|
||||
),
|
||||
() =>
|
||||
f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
{
|
||||
playerId: f.auth.playerId,
|
||||
...handover,
|
||||
operationId: randomUUID(),
|
||||
newSessionToken: secret(),
|
||||
},
|
||||
f.clock,
|
||||
),
|
||||
() => f.talk.readTableTalk(f.code, f.auth, f.guestAuth.playerId),
|
||||
() => f.talk.sendTableTalk(f.code, f.auth, message, 1000),
|
||||
() => f.talk.sendTableTalk(f.code, f.auth, draft(), 3000),
|
||||
];
|
||||
for (const attempt of attempts) await assert.rejects(attempt(), removed);
|
||||
assert.deepEqual(f.snapshot(), before);
|
||||
assert.equal(f.calls.views, views);
|
||||
await f.transition(false);
|
||||
assert.equal(
|
||||
(
|
||||
await f.rooms.createRoom(
|
||||
'Removal owner',
|
||||
'atreides',
|
||||
false,
|
||||
[],
|
||||
f.ownerEntry,
|
||||
)
|
||||
).entryReceipt?.replayed,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.rooms.joinRoom(f.code, 'Removal guest', 'emperor', f.guestEntry))
|
||||
.entryReceipt?.replayed,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
handover,
|
||||
f.clock,
|
||||
)
|
||||
).replayed,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.talk.sendTableTalk(f.code, f.auth, message, 1000)).replayed,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.talk.readTableTalk(f.code, f.auth, f.guestAuth.playerId))
|
||||
.messages[0].text,
|
||||
message.text,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('removed malformed game JSON is never parsed or computed by private reads and restarted workers', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.transition(true);
|
||||
f.sqlite
|
||||
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
|
||||
.run('not game JSON', f.code);
|
||||
const before = f.snapshot(),
|
||||
calls = { ...f.calls };
|
||||
for (const operation of [
|
||||
() => f.rooms.readRoom(f.code),
|
||||
() => f.rooms.readSeatView(f.code, f.auth),
|
||||
() => f.talk.readTableTalk(f.code, f.auth, null),
|
||||
() => f.talk.sendTableTalk(f.code, f.auth, draft(), 1000),
|
||||
])
|
||||
await assert.rejects(operation(), removed);
|
||||
const noSleep = {
|
||||
now: f.clock.now,
|
||||
sleep: async () => {
|
||||
assert.fail('A removed room must not sleep.');
|
||||
},
|
||||
};
|
||||
await f.restart().continueRoomBots(f.code, 4, noSleep);
|
||||
await f.restart().continueRoomAutomatic(f.code, noSleep);
|
||||
assert.deepEqual(f.snapshot(), before);
|
||||
assert.deepEqual(f.calls, calls);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
for (const kind of [
|
||||
'authentication',
|
||||
'raw state',
|
||||
'seat view',
|
||||
'discussion read',
|
||||
'discussion receipt',
|
||||
] as const) {
|
||||
void test(`${kind} SQL excludes a removal racing the last private query`, async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const message = draft();
|
||||
await f.talk.sendTableTalk(f.code, f.auth, message, 1000);
|
||||
const match = {
|
||||
authentication: 'SELECT player_id FROM seats',
|
||||
'raw state': 'SELECT state,version',
|
||||
'seat view': 'SELECT rooms.state',
|
||||
'discussion read': 'SELECT (',
|
||||
'discussion receipt': 'SELECT 1 AS accepted',
|
||||
}[kind];
|
||||
let expected: ReturnType<typeof f.snapshot> | undefined;
|
||||
f.hooks.beforeFirst = (sql) => {
|
||||
if (!sql.startsWith(match)) return;
|
||||
delete f.hooks.beforeFirst;
|
||||
f.flagRemoved();
|
||||
expected = f.snapshot();
|
||||
};
|
||||
const operations = {
|
||||
authentication: () => f.rooms.authenticate(f.code, f.owner.token),
|
||||
'raw state': () => f.rooms.readRoom(f.code),
|
||||
'seat view': () => f.rooms.readSeatView(f.code, f.auth),
|
||||
'discussion read': () => f.talk.readTableTalk(f.code, f.auth, null),
|
||||
'discussion receipt': () =>
|
||||
f.talk.sendTableTalk(f.code, f.auth, message, 1000),
|
||||
};
|
||||
const views = f.calls.views;
|
||||
await assert.rejects(operations[kind](), removed);
|
||||
assert.ok(expected);
|
||||
assert.deepEqual(f.snapshot(), expected);
|
||||
assert.equal(f.calls.views, views);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const kind of [
|
||||
'join',
|
||||
'action',
|
||||
'takeback',
|
||||
'grant',
|
||||
'revoke grant',
|
||||
'activate grant',
|
||||
'recovery key',
|
||||
'recovery',
|
||||
'create handover',
|
||||
'revoke handover',
|
||||
'claim handover',
|
||||
'discussion send',
|
||||
] as const) {
|
||||
void test(`${kind} commit fence rejects removal without changing state, credentials or receipts`, async () => {
|
||||
const f = await fixture(kind !== 'join' && kind !== 'action');
|
||||
try {
|
||||
const consent = grant(f),
|
||||
handover = offer(),
|
||||
recoverySecret = secret();
|
||||
if (kind === 'revoke grant' || kind === 'activate grant')
|
||||
await f.rooms.setSeatAiDelegate(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
consent,
|
||||
f.clock,
|
||||
);
|
||||
if (kind === 'recovery')
|
||||
await f.rooms.setRecoveryKey(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
recoverySecret,
|
||||
);
|
||||
if (kind === 'revoke handover' || kind === 'claim handover')
|
||||
await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
handover,
|
||||
f.clock,
|
||||
);
|
||||
if (kind === 'takeback')
|
||||
await f.rooms.act(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
{ type: 'setAutopilot', difficulty: 'Hard' },
|
||||
f.clock,
|
||||
);
|
||||
let expected: ReturnType<typeof f.snapshot> | undefined;
|
||||
f.hooks.beforeRun = (sql) => {
|
||||
if (
|
||||
!sql.startsWith(
|
||||
kind === 'discussion send'
|
||||
? 'INSERT INTO room_messages'
|
||||
: 'UPDATE rooms',
|
||||
)
|
||||
)
|
||||
return;
|
||||
delete f.hooks.beforeRun;
|
||||
f.flagRemoved();
|
||||
expected = f.snapshot();
|
||||
};
|
||||
const operations = {
|
||||
join: () => f.rooms.joinRoom(f.code, 'Racing guest', 'guild', entry()),
|
||||
action: () =>
|
||||
f.rooms.act(f.code, f.auth, f.version(), { type: 'ready' }, f.clock),
|
||||
takeback: () =>
|
||||
f.rooms.act(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
{ type: 'setAutopilot', difficulty: null },
|
||||
f.clock,
|
||||
),
|
||||
grant: () =>
|
||||
f.rooms.setSeatAiDelegate(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
consent,
|
||||
f.clock,
|
||||
),
|
||||
'revoke grant': () =>
|
||||
f.rooms.revokeSeatAiDelegate(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
consent.grantId,
|
||||
f.clock,
|
||||
),
|
||||
'activate grant': () =>
|
||||
f.rooms.useSeatAiDelegate(
|
||||
f.code,
|
||||
f.guestAuth,
|
||||
f.version(),
|
||||
{ ownerId: f.auth.playerId, grantId: consent.grantId },
|
||||
f.clock,
|
||||
),
|
||||
'recovery key': () =>
|
||||
f.rooms.setRecoveryKey(f.code, f.auth, f.version(), recoverySecret),
|
||||
recovery: () =>
|
||||
f.rooms.recoverSeat(f.code, recovery(f, recoverySecret)),
|
||||
'create handover': () =>
|
||||
f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
handover,
|
||||
f.clock,
|
||||
),
|
||||
'revoke handover': () =>
|
||||
f.rooms.revokeSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
handover.offerId,
|
||||
f.clock,
|
||||
),
|
||||
'claim handover': () =>
|
||||
f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
{
|
||||
playerId: f.auth.playerId,
|
||||
...handover,
|
||||
operationId: randomUUID(),
|
||||
newSessionToken: secret(),
|
||||
},
|
||||
f.clock,
|
||||
),
|
||||
'discussion send': () =>
|
||||
f.talk.sendTableTalk(f.code, f.auth, draft(), 1000),
|
||||
};
|
||||
await assert.rejects(operations[kind](), removed);
|
||||
assert.ok(expected);
|
||||
assert.deepEqual(f.snapshot(), expected);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test('completed recovery and handover receipts survive removal, but restore never revives revoked sessions or keys', async () => {
|
||||
const f = await fixture(true);
|
||||
try {
|
||||
const consent = grant(f),
|
||||
ownerKey = secret(),
|
||||
guestKey = secret(),
|
||||
handover = offer();
|
||||
await f.rooms.setSeatAiDelegate(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
consent,
|
||||
f.clock,
|
||||
);
|
||||
await f.rooms.setRecoveryKey(f.code, f.auth, f.version(), ownerKey);
|
||||
await f.rooms.setRecoveryKey(f.code, f.guestAuth, f.version(), guestKey);
|
||||
const recovering = {
|
||||
...recovery(f, guestKey),
|
||||
playerId: f.guestAuth.playerId,
|
||||
};
|
||||
const recovered = await f.rooms.recoverSeat(f.code, recovering);
|
||||
await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
handover,
|
||||
f.clock,
|
||||
);
|
||||
const claiming = {
|
||||
playerId: f.auth.playerId,
|
||||
...handover,
|
||||
operationId: randomUUID(),
|
||||
newSessionToken: secret(),
|
||||
};
|
||||
const claimed = await f.rooms.claimSeatHandover(f.code, claiming, f.clock);
|
||||
const ownerAuth = await f.rooms.authenticate(f.code, claimed.token);
|
||||
const guestAuth = await f.rooms.authenticate(f.code, recovered.token);
|
||||
const message = draft(guestAuth.playerId);
|
||||
await f.talk.sendTableTalk(f.code, ownerAuth, message, 1000);
|
||||
const baseline = f.snapshot();
|
||||
await f.transition(true);
|
||||
for (const operation of [
|
||||
() => f.rooms.recoverSeat(f.code, recovering),
|
||||
() => f.rooms.claimSeatHandover(f.code, claiming, f.clock),
|
||||
])
|
||||
await assert.rejects(operation(), removed);
|
||||
await f.transition(false);
|
||||
const after = f.snapshot();
|
||||
for (const key of Object.keys(baseline).filter(
|
||||
(key) => key !== 'rooms' && key !== 'room_removals',
|
||||
))
|
||||
assert.deepEqual(after[key], baseline[key]);
|
||||
assert.equal(
|
||||
(await f.rooms.recoverSeat(f.code, recovering)).replayed,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.rooms.claimSeatHandover(f.code, claiming, f.clock)).replayed,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.talk.sendTableTalk(f.code, ownerAuth, message, 1000)).replayed,
|
||||
true,
|
||||
);
|
||||
for (const old of [f.owner, f.guest])
|
||||
await assert.rejects(
|
||||
f.rooms.authenticate(f.code, old.token!),
|
||||
/could not be verified/,
|
||||
);
|
||||
await assert.rejects(
|
||||
f.rooms.recoverSeat(f.code, recovery(f, ownerKey)),
|
||||
/proof could not be verified/,
|
||||
);
|
||||
await assert.rejects(
|
||||
f.rooms.useSeatAiDelegate(
|
||||
f.code,
|
||||
guestAuth,
|
||||
f.version(),
|
||||
{ ownerId: ownerAuth.playerId, grantId: consent.grantId },
|
||||
f.clock,
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
(await f.rooms.readSeatView(f.code, ownerAuth)).seatAiDelegations,
|
||||
undefined,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
for (const kind of ['recovery', 'handover'] as const) {
|
||||
void test(`${kind} exact receipt query fences removal after its preliminary proof checks`, async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const recoverySecret = secret(),
|
||||
handover = offer();
|
||||
await f.rooms.setRecoveryKey(f.code, f.auth, f.version(), recoverySecret);
|
||||
const recovering = recovery(f, recoverySecret);
|
||||
const claiming = {
|
||||
playerId: f.auth.playerId,
|
||||
...handover,
|
||||
operationId: randomUUID(),
|
||||
newSessionToken: secret(),
|
||||
};
|
||||
if (kind === 'recovery') await f.rooms.recoverSeat(f.code, recovering);
|
||||
else {
|
||||
await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
handover,
|
||||
f.clock,
|
||||
);
|
||||
await f.rooms.claimSeatHandover(f.code, claiming, f.clock);
|
||||
}
|
||||
let expected: ReturnType<typeof f.snapshot> | undefined;
|
||||
f.hooks.beforeFirst = (sql) => {
|
||||
if (!sql.startsWith('SELECT rooms.state')) return;
|
||||
delete f.hooks.beforeFirst;
|
||||
f.flagRemoved();
|
||||
expected = f.snapshot();
|
||||
};
|
||||
const views = f.calls.views;
|
||||
await assert.rejects(
|
||||
kind === 'recovery'
|
||||
? f.rooms.recoverSeat(f.code, recovering)
|
||||
: f.rooms.claimSeatHandover(f.code, claiming, f.clock),
|
||||
removed,
|
||||
);
|
||||
assert.ok(expected);
|
||||
assert.deepEqual(f.snapshot(), expected);
|
||||
assert.equal(f.calls.views, views);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test('entry committed before removal keeps its exact retry proof through temporary rejection and restoration', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const pendingEntry = entry();
|
||||
let code: string | undefined;
|
||||
f.hooks.beforeFirst = (sql, values) => {
|
||||
if (!sql.startsWith('SELECT rooms.state')) return;
|
||||
delete f.hooks.beforeFirst;
|
||||
code = String(values[0]);
|
||||
f.flagRemoved(code);
|
||||
};
|
||||
await assert.rejects(
|
||||
f.rooms.createRoom(
|
||||
'Uncertain creation',
|
||||
'guild',
|
||||
false,
|
||||
[],
|
||||
pendingEntry,
|
||||
),
|
||||
removed,
|
||||
);
|
||||
assert.ok(code);
|
||||
assert.equal(
|
||||
f.sqlite.prepare('SELECT COUNT(*) AS n FROM room_entry_receipts').get()!
|
||||
.n,
|
||||
3,
|
||||
);
|
||||
await f.transition(false, code);
|
||||
const replay = await f.rooms.createRoom(
|
||||
'Uncertain creation',
|
||||
'guild',
|
||||
false,
|
||||
[],
|
||||
pendingEntry,
|
||||
);
|
||||
assert.equal(replay.view.code, code);
|
||||
assert.equal(replay.entryReceipt?.replayed, true);
|
||||
assert.equal(
|
||||
f.sqlite.prepare('SELECT COUNT(*) AS n FROM rooms').get()!.n,
|
||||
2,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('sleeping AI rechecks removal before computing and restored AI retains normal pacing', async () => {
|
||||
const f = await fixture(true);
|
||||
try {
|
||||
await f.rooms.act(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
{ type: 'setAutopilot', difficulty: 'Hard' },
|
||||
f.clock,
|
||||
);
|
||||
const clock = {
|
||||
now: f.clock.now,
|
||||
sleep: async (ms: number) => {
|
||||
await f.transition(true);
|
||||
f.clock.advance(ms);
|
||||
},
|
||||
};
|
||||
await f.rooms.continueRoomBots(f.code, 1, clock);
|
||||
assert.equal(f.calls.bots, 0);
|
||||
const before = f.snapshot();
|
||||
await f.rooms.continueRoomAutomatic(f.code, f.clock);
|
||||
await f.restart().continueRoomBots(f.code, 4, f.clock);
|
||||
assert.deepEqual(f.snapshot(), before);
|
||||
f.clock.advance(50000);
|
||||
await f.transition(false);
|
||||
assert.equal(
|
||||
(await f.rooms.readRoom(f.code)).botNextActionAt,
|
||||
f.clock.now() + 1500,
|
||||
);
|
||||
await f.rooms.continueRoomBots(f.code, 1, f.clock);
|
||||
assert.equal(f.calls.bots, 1);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
for (const kind of ['AI', 'automatic normalization'] as const) {
|
||||
void test(`${kind} discards computed work when removal wins its final SQL fence`, async () => {
|
||||
const f = await fixture(kind === 'AI');
|
||||
try {
|
||||
if (kind === 'AI') {
|
||||
await f.rooms.act(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.version(),
|
||||
{ type: 'setAutopilot', difficulty: 'Hard' },
|
||||
f.clock,
|
||||
);
|
||||
f.clock.advance(1500);
|
||||
} else {
|
||||
const game = await f.rooms.readRoom(f.code);
|
||||
game.players[0] = engine.newPlayer(f.auth.playerId, 'CHOAM', 'choam');
|
||||
game.players.push(engine.newPlayer('guild', 'Guild', 'guild'));
|
||||
Object.assign(game, {
|
||||
status: 'playing',
|
||||
phase: 3,
|
||||
turn: 2,
|
||||
order: game.players.map((p) => p.id),
|
||||
active: f.auth.playerId,
|
||||
auction: null,
|
||||
response: null,
|
||||
deck: baseDeck(),
|
||||
discard: [],
|
||||
choamMarket: { owner: f.auth.playerId, resume: 'phase', blocked: [] },
|
||||
decision: { kind: 'choamMarket', player: f.auth.playerId },
|
||||
aid: { [f.auth.playerId]: { recipient: 'emperor', amount: 2 } },
|
||||
});
|
||||
for (const player of game.players) {
|
||||
player.hand = [];
|
||||
player.spice = 10;
|
||||
}
|
||||
f.sqlite
|
||||
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
|
||||
.run(JSON.stringify(game), f.code);
|
||||
}
|
||||
let expected: ReturnType<typeof f.snapshot> | undefined;
|
||||
f.hooks.beforeRun = (sql) => {
|
||||
if (!sql.startsWith('UPDATE rooms')) return;
|
||||
delete f.hooks.beforeRun;
|
||||
f.flagRemoved();
|
||||
expected = f.snapshot();
|
||||
};
|
||||
if (kind === 'AI') await f.rooms.continueRoomBots(f.code, 4, f.clock);
|
||||
else await f.rooms.continueRoomAutomatic(f.code, f.clock);
|
||||
assert.ok(expected);
|
||||
assert.deepEqual(f.snapshot(), expected);
|
||||
assert.equal(kind === 'AI' ? f.calls.bots : f.calls.normalization, 1);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Reversible removal of one newly created QA room; never accepts an existing room.
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
|
||||
export async function verifyRoomRemoval(request, adminCookie, report) {
|
||||
const secret = () => randomBytes(32).toString('hex');
|
||||
let code, path, url, controls;
|
||||
async function removal(removed, saved) {
|
||||
const current = await request(url, undefined, adminCookie); assert.equal(current.status, 200);
|
||||
const input = saved ?? { operationId: randomUUID(), expectedVersion: current.data.version, expectedRevision: current.data.revision, removed, reason: 'Dedicated recoverable removal acceptance' };
|
||||
const result = await request(url, input, adminCookie);
|
||||
assert.equal(result.status, 200); assert.equal(result.rawCookie, null);
|
||||
assert.deepEqual(Object.keys(result.data).sort(), ['appliedRevision', 'appliedVersion', 'operationId', 'replayed', 'room']);
|
||||
assert.equal(result.data.operationId, input.operationId);
|
||||
assert.equal(result.data.appliedVersion, input.expectedVersion + 1); assert.equal(result.data.appliedRevision, input.expectedRevision + 1);
|
||||
return { ...result.data, input };
|
||||
}
|
||||
async function control(paused, joinLocked) {
|
||||
const current = await request(controls, undefined, adminCookie); assert.equal(current.status, 200);
|
||||
return request(controls, { operationId: randomUUID(), expectedRevision: current.data.revision, paused, joinLocked, reason: 'Dedicated removal QA room availability' }, adminCookie);
|
||||
}
|
||||
let failure;
|
||||
try {
|
||||
const creation = { operationId: randomUUID(), sessionToken: secret(), name: 'Removal QA ' + Date.now(), faction: 'atreides', advanced: false,
|
||||
techTokens: false, strongholdCards: false, bots: [{ faction: 'harkonnen', difficulty: 'Easy' }], reason: 'Dedicated removal and restoration acceptance' };
|
||||
const made = await request('/api/admin/rooms', creation, adminCookie); assert.equal(made.status, 200);
|
||||
code = made.data.code; report.removalRoom = code; path = '/api/rooms/' + code; url = '/api/admin/rooms/' + code + '/removal'; controls = '/api/admin/rooms/' + code + '/control';
|
||||
const oldHostCookie = made.cookie;
|
||||
const joined = await request(path, { type: 'join', name: 'Removal participant QA', faction: 'emperor' }); assert.equal(joined.status, 200);
|
||||
const guest = joined.cookie;
|
||||
const hostBefore = await request(path, undefined, oldHostCookie);
|
||||
const recoverySecret = secret();
|
||||
assert.equal((await request(path + '/control', { type: 'setRecoveryKey', version: hostBefore.data.version, recoverySecret }, oldHostCookie)).status, 200);
|
||||
const recovery = { type: 'recoverSeat', playerId: made.data.hostId, recoverySecret, operationId: randomUUID(), newSessionToken: secret() };
|
||||
const recovered = await request(path + '/control', recovery); assert.equal(recovered.status, 200);
|
||||
const host = recovered.cookie;
|
||||
const message = { id: randomUUID(), recipientId: null, text: 'Dedicated removal continuity QA' };
|
||||
assert.equal((await request(path + '/messages', message, host)).status, 200);
|
||||
for (const cookie of [host, guest]) {
|
||||
const current = await request(path, undefined, cookie);
|
||||
assert.equal((await request(path, { version: current.data.version, action: { type: 'ready' } }, cookie)).status, 200);
|
||||
}
|
||||
const ready = await request(path, undefined, host);
|
||||
assert.equal((await request(path, { version: ready.data.version, action: { type: 'start' } }, host)).status, 200);
|
||||
assert.equal((await control(true, true)).status, 200);
|
||||
const before = (await request(path, undefined, host)).data;
|
||||
const beforeTalk = (await request(path + '/messages', undefined, host)).data;
|
||||
const metadata = await request(url, undefined, adminCookie);
|
||||
const attempt = { operationId: randomUUID(), expectedVersion: metadata.data.version, expectedRevision: metadata.data.revision, removed: true, reason: 'Dedicated recoverable removal acceptance' };
|
||||
for (const denied of [await request(url, attempt, host), await request(url, attempt, adminCookie, 'https://foreign.invalid'), await request(url, attempt, adminCookie, undefined, randomUUID())]) {
|
||||
assert.ok([401, 403].includes(denied.status)); assert.equal(denied.rawCookie, null);
|
||||
}
|
||||
const removed = await removal(true, attempt); assert.equal(removed.room.removed, true);
|
||||
const replay = await removal(true, attempt); assert.equal(replay.replayed, true); assert.equal(replay.room.revision, removed.room.revision);
|
||||
assert.equal((await request('/api/admin/rooms?q=' + code, undefined, adminCookie)).data.total, 0);
|
||||
const directory = await request('/api/admin/rooms?removal=removed&q=' + code, undefined, adminCookie);
|
||||
assert.equal(directory.status, 200); assert.equal(directory.data.total, 1); assert.equal(directory.data.rooms[0].removed, true);
|
||||
report.checks.push('Removal is live-authorized and exactly replayable; removed QA room is hidden by default and searchable for restoration');
|
||||
|
||||
for (const blocked of [await request(path, undefined, host), await request(path), await request(path, { version: before.version, action: { type: 'advanceBots' } }, host),
|
||||
await request(path, { type: 'join', name: 'Blocked removed QA', faction: 'guild' }), await request(path + '/messages', undefined, host),
|
||||
await request(path + '/messages', message, host), await request(path + '/control', recovery)]) {
|
||||
assert.equal(blocked.status, 410); assert.equal(blocked.data.code, 'ROOM_REMOVED'); assert.equal(blocked.rawCookie, null);
|
||||
assert.deepEqual(Object.keys(blocked.data).sort(), ['code', 'error']);
|
||||
}
|
||||
const inaccessibleCreation = await request('/api/admin/rooms', creation, adminCookie);
|
||||
assert.equal(inaccessibleCreation.status, 200); assert.equal(inaccessibleCreation.data.roomRemoved, true);
|
||||
assert.equal(inaccessibleCreation.data.hostAccess, false); assert.equal(inaccessibleCreation.rawCookie, null);
|
||||
assert.equal((await control(false, false)).status, 409);
|
||||
const lobby = await request('/api/admin/rooms/' + code + '/lobby', undefined, adminCookie);
|
||||
assert.equal(lobby.status, 200); assert.equal(lobby.data.editable, false);
|
||||
assert.equal((await request(url, undefined, adminCookie)).data.version, removed.room.version);
|
||||
report.checks.push('Removed room denies player/invitation/AI/seat/message access with a typed non-private response; creation retry retains temporary-removal proof');
|
||||
|
||||
const restored = await removal(false); assert.equal(restored.room.removed, false); assert.equal(restored.room.paused, true); assert.equal(restored.room.joinLocked, true);
|
||||
const after = (await request(path, undefined, host)).data;
|
||||
assert.deepEqual({ ...after, version: before.version }, before);
|
||||
assert.deepEqual((await request(path + '/messages', undefined, host)).data, beforeTalk);
|
||||
const guestView = await request(path, undefined, guest); assert.equal(guestView.status, 200); assert.equal(guestView.data.me, joined.data.me);
|
||||
assert.notEqual((await request(path, undefined, oldHostCookie)).status, 200);
|
||||
const receipt = await request(path + '/control', recovery); assert.equal(receipt.status, 200); assert.equal(receipt.data.replayed, true); assert.equal(receipt.cookie, host);
|
||||
assert.deepEqual((await request(path, undefined, host)).data, after);
|
||||
const oldRemoval = await removal(true, attempt); assert.equal(oldRemoval.replayed, true); assert.equal(oldRemoval.room.removed, false);
|
||||
assert.deepEqual((await request(path, undefined, host)).data, after);
|
||||
assert.equal((await request('/api/admin/rooms?q=' + code, undefined, adminCookie)).data.total, 1);
|
||||
report.checks.push('Restoration preserves the complete paused game, discussion and both saved seats; revoked host cookie stays denied and old recovery/removal receipts cannot reapply');
|
||||
} catch (error) {
|
||||
report.failureRequest ??= report.lastRequest;
|
||||
report.removalFailure = 'Recoverable removal acceptance failed; the dedicated room is retained.';
|
||||
report.removalFailureLocation = error instanceof Error ? error.stack?.split('\n').find(line => line.trim().startsWith('at ') && line.includes('admin-removal-verification.mjs'))?.trim() : undefined;
|
||||
failure = error;
|
||||
} finally {
|
||||
if (code) try {
|
||||
const current = await request(url, undefined, adminCookie); assert.equal(current.status, 200);
|
||||
if (current.data.removed) await removal(false);
|
||||
const flags = await request(controls, undefined, adminCookie); assert.equal(flags.status, 200);
|
||||
if (flags.data.paused || flags.data.joinLocked) assert.equal((await control(false, false)).status, 200);
|
||||
} catch (error) { report.removalCleanupFailure = { message: 'Dedicated QA restoration could not be confirmed.', request: report.lastRequest }; failure ??= error; }
|
||||
}
|
||||
if (failure) throw failure;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { verifyRoomLifecycle } from './admin-lifecycle-verification.mjs';
|
||||
import { verifyRoomCreation } from './admin-creation-verification.mjs';
|
||||
import { verifyLobbyConfiguration } from './admin-lobby-verification.mjs';
|
||||
import { verifyRoomRemoval } from './admin-removal-verification.mjs';
|
||||
|
||||
const options = {};
|
||||
for (let n = 2; n < process.argv.length; n += 2) {
|
||||
@@ -32,7 +33,7 @@ async function request(path, body, cookie, origin = base, expectedAdminId = admi
|
||||
const response = await fetch(base + path, {
|
||||
method: body === undefined ? 'GET' : 'POST',
|
||||
headers: { ...(body === undefined ? {} : { 'content-type': 'application/json', origin }), ...(cookie ? { cookie } : {}),
|
||||
...(expectedAdminId && (path === '/api/admin/rooms' && body !== undefined || /^\/api\/admin\/rooms\/[^/]+\/(control|lobby)$/.test(path)) ? { 'X-Dune-Admin-Id': expectedAdminId } : {}) },
|
||||
...(expectedAdminId && (path === '/api/admin/rooms' && body !== undefined || /^\/api\/admin\/rooms\/[^/]+\/(control|lobby|removal)$/.test(path)) ? { 'X-Dune-Admin-Id': expectedAdminId } : {}) },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
report.lastRequest = { path, status: response.status };
|
||||
@@ -87,6 +88,7 @@ try {
|
||||
await verifyRoomLifecycle(request, session1, report);
|
||||
await verifyRoomCreation(request, session1, report);
|
||||
await verifyLobbyConfiguration(request, session1, report);
|
||||
await verifyRoomRemoval(request, session1, report);
|
||||
}
|
||||
report.passed = true;
|
||||
} catch (error) {
|
||||
|
||||
Reference in new issue
Block a user