diff --git a/README.md b/README.md index e7ece97a..3b6b48d9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 67595c82..2194f1c2 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -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(null); const [selectedLobby, setSelectedLobby] = useState(null); + const [selectedRemoval, setSelectedRemoval] = useState(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() { : <>

Rooms

Public room details. Cards, sealed choices and private discussion stay hidden.

-
{account.role !== 'viewer' && } +
{account.role !== 'viewer' && }
{creatingRoom && account.role !== 'viewer' && setCreatingRoom(false)} onDenied={accessChanged} onBusyChange={setOperationBusy} onCreated={code => { const next = { ...initialFilters, q: code }; setFilters(next); void load(next); }} />}
{ event.preventDefault(); void load(filters); }}> + @@ -140,6 +145,9 @@ export default function Administration() { {selectedLobby && setSelectedLobby(null)} onDenied={accessChanged} onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />} + {selectedRemoval && setSelectedRemoval(null)} onDenied={accessChanged} + onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />}
{!directory ? {busy ? 'Loading rooms…' : 'Use Refresh to load rooms.'} : <>

{directory.total.toLocaleString()} {directory.total === 1 ? 'room' : 'rooms'} match

@@ -148,6 +156,7 @@ export default function Administration() { Room & playersRulesProgressLast game change {directory.rooms.map(room => {room.code} {room.status} + {room.removed && Removed} {room.control.paused && Paused} {room.control.joinLocked && Joins locked}

Host: {room.players.find(p => p.id === room.host)?.name ?? 'Unavailable'}

@@ -160,7 +169,7 @@ export default function Administration() { {room.turn !== null && room.status !== 'lobby' ? `Turn ${room.turn} · ` : ''}{room.phase}

{room.pending.label}{room.pending.owners.length > 0 ? ': ' + room.pending.owners.map(id => room.players.find(p => p.id === id)?.name).filter(Boolean).join(', ') : ''}

-

Version {room.version}

+

Version {room.version}

)}
}
-

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.

+

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.

} ; } diff --git a/app/api/admin/rooms/[code]/removal/route.ts b/app/api/admin/rooms/[code]/removal/route.ts new file mode 100644 index 00000000..7c9cd43f --- /dev/null +++ b/app/api/admin/rooms/[code]/removal/route.ts @@ -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); } +} diff --git a/app/api/rooms/[code]/control/route.ts b/app/api/rooms/[code]/control/route.ts index 0cbf8689..e991eb68 100644 --- a/app/api/rooms/[code]/control/route.ts +++ b/app/api/rooms/[code]/control/route.ts @@ -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 diff --git a/app/api/rooms/[code]/messages/route.ts b/app/api/rooms/[code]/messages/route.ts index 0e7ac1b2..f556e4d0 100644 --- a/app/api/rooms/[code]/messages/route.ts +++ b/app/api/rooms/[code]/messages/route.ts @@ -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 diff --git a/app/api/rooms/[code]/route.ts b/app/api/rooms/[code]/route.ts index 2d457002..2c765bcf 100644 --- a/app/api/rooms/[code]/route.ts +++ b/app/api/rooms/[code]/route.ts @@ -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) { diff --git a/app/page.tsx b/app/page.tsx index b6a2d443..9a63abdd 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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(null); + const removedRooms = useRef(new Set()); + const [removedRoom, setRemovedRoom] = useState(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 => { @@ -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; 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() { window.location.reload()} onRestored={(view) => { - restoredSeat(view, true); - setShowHandover(false); + if (restoredSeat(view, true)) setShowHandover(false); }} /> @@ -677,15 +708,17 @@ export default function Home() { if (game) return ( <> - void reconnect()} onExit={exitTable} exitDisabled={handoverPending || aiPermissionPending || claimUncertain} /> : + />}
- + {!roomUnavailable && } + {/* Keep controllers mounted: recovery kits and exact attempts can be memory-only. */} +
- {(notice || connection || needsReconcile || botRecovery) && ( + {!roomUnavailable && (notice || connection || needsReconcile || botRecovery) && ( {notice && {notice}} {connection && {connection}} @@ -776,7 +810,7 @@ export default function Home() { DUNEARRAKIS TABLE -
+ {removedRoom ? 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, '', '/'); }} /> :

{restoring ? 'Restoring your table' @@ -813,7 +847,7 @@ export default function Home() { )} -

+
} ); // A first request is progress, not recovery. Keep the actionable recovery diff --git a/components/admin-room-creation.tsx b/components/admin-room-creation.tsx index 7818eaf0..4dba9e63 100644 --- a/components/admin-room-creation.tsx +++ b/components/admin-room-creation.tsx @@ -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('/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); diff --git a/components/admin-room-removal.tsx b/components/admin-room-removal.tsx new file mode 100644 index 00000000..b087e03b --- /dev/null +++ b/components/admin-room-removal.tsx @@ -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(null); + const [reason, setReason] = useState(''); + const [confirmed, setConfirmed] = useState(false); + const [confirmationCode, setConfirmationCode] = useState(''); + const [pending, setPending] = useState(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(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(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(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(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(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
+

Remove or restore · {code}

+ {notice && {notice}} + {loading ? Loading room availability… : <> + {room ? <> +

Current availability: {room.removed ? 'Removed' : 'Active'}. Saved pause setting: {room.paused ? 'Paused' : 'Running'} · New joins {room.joinLocked ? 'locked' : 'open'}.

+ {room.removed &&

Ordinary room access is unavailable. The game, seats and messages remain saved.

} + {room.removedAt !== null &&

Last removal: .

} + + : } + {!canManage ?

Your viewer role can inspect room availability. An owner or operator can remove or restore rooms.

: storageProblem ?
+

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.

+ + +
: pending ?
+

Saved request: {pending.removed ? 'Remove' : 'Restore'} room {code}.

Reason: {pending.reason}

+

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.

+

After refreshing the page or signing in again, choose All rooms in the Directory filter to find this room and reopen these controls.

+ +
: room && { event.preventDefault(); void submit(); }}> + +

Use a short operational reason. Do not include credentials or private game information.

+
+ {room.removed ? <> +

Restore ordinary access to room {code}. Saved progress and seats are retained, and current credentials and access revocations remain in effect.

+

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'}.

+ : <> +

Remove room {code} from ordinary access. This interrupts every player: private room access, joining, game actions, AI play, seat security changes and messages stop until restoration.

+

Warn active players before interrupting their game. This recoverable removal keeps the saved game and seats for restoration.

+ + } + + +
+ } + } +
; +} diff --git a/components/removed-room-notice.tsx b/components/removed-room-notice.tsx new file mode 100644 index 00000000..9d764276 --- /dev/null +++ b/components/removed-room-notice.tsx @@ -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
+

Room {code} has been removed

+

An administrator removed this room from active play. Its game, saved seats and pending choices are retained for restoration.

+

Only an administrator can restore the room. Restoring it keeps existing seat ownership and access restrictions.

+

Keep this tab open if a seat or room request is unconfirmed. Its exact retry details remain here; check for restoration before continuing.

+ + +
; +} diff --git a/components/seat-recovery.tsx b/components/seat-recovery.tsx index 92ddd282..e660bfba 100644 --- a/components/seat-recovery.tsx +++ b/components/seat-recovery.tsx @@ -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.`, ); diff --git a/db/admin-directory.ts b/db/admin-directory.ts index c7b34531..2be02778 100644 --- a/db/admin-directory.ts +++ b/db/admin-directory.ts @@ -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 { - 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); diff --git a/db/admin-lifecycle.ts b/db/admin-lifecycle.ts index 13456ac3..38001a73 100644 --- a/db/admin-lifecycle.ts +++ b/db/admin-lifecycle.ts @@ -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, diff --git a/db/admin-lobby-configuration.ts b/db/admin-lobby-configuration.ts index 625eb648..1bafb0d3 100644 --- a/db/admin-lobby-configuration.ts +++ b/db/admin-lobby-configuration.ts @@ -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( diff --git a/db/admin-removal.ts b/db/admin-removal.ts new file mode 100644 index 00000000..9ee2e6c1 --- /dev/null +++ b/db/admin-removal.ts @@ -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 { + 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 { + 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, + ); +} diff --git a/db/admin-room-creation.ts b/db/admin-room-creation.ts index a48f6735..7093b4b3 100644 --- a/db/admin-room-creation.ts +++ b/db/admin-room-creation.ts @@ -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 { diff --git a/db/rooms.ts b/db/rooms.ts index 791fe2a9..7789eee4 100644 --- a/db/rooms.ts +++ b/db/rooms.ts @@ -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 { - 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, diff --git a/db/schema.ts b/db/schema.ts index f6518838..179eef18 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -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( diff --git a/db/table-talk.ts b/db/table-talk.ts index be0bfa83..344d66b3 100644 --- a/db/table-talk.ts +++ b/db/table-talk.ts @@ -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 }; } diff --git a/docs/ADMIN_PANEL.md b/docs/ADMIN_PANEL.md index 0f2ec2eb..54701ebe 100644 --- a/docs/ADMIN_PANEL.md +++ b/docs/ADMIN_PANEL.md @@ -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. diff --git a/docs/ADMIN_ROOM_REMOVAL.md b/docs/ADMIN_ROOM_REMOVAL.md new file mode 100644 index 00000000..9d2a43dc --- /dev/null +++ b/docs/ADMIN_ROOM_REMOVAL.md @@ -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. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 238d1be8..bd4e7b44 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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 diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 588219d6..00edafb2 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -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) diff --git a/drizzle/0012_admin_room_removal.sql b/drizzle/0012_admin_room_removal.sql new file mode 100644 index 00000000..54eb6e18 --- /dev/null +++ b/drizzle/0012_admin_room_removal.sql @@ -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)) +); diff --git a/drizzle/meta/0012_snapshot.json b/drizzle/meta/0012_snapshot.json new file mode 100644 index 00000000..04e8b187 --- /dev/null +++ b/drizzle/meta/0012_snapshot.json @@ -0,0 +1,1523 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0b688d88-eff4-438f-9596-8f079259da39", + "prevId": "2b84bd41-2fd8-4407-99b3-a6a8d88b3a92", + "tables": { + "admin_accounts": { + "name": "admin_accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "session_generation": { + "name": "session_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_accounts_key_hash_unique": { + "name": "admin_accounts_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "admin_accounts_role": { + "name": "admin_accounts_role", + "value": "\"admin_accounts\".\"role\" IN ('owner', 'operator', 'viewer')" + }, + "admin_accounts_enabled": { + "name": "admin_accounts_enabled", + "value": "\"admin_accounts\".\"enabled\" IN (0, 1)" + }, + "admin_accounts_generation": { + "name": "admin_accounts_generation", + "value": "\"admin_accounts\".\"session_generation\" >= 0" + } + } + }, + "admin_audit": { + "name": "admin_audit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_admin_id": { + "name": "target_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + } + }, + "indexes": { + "admin_audit_created_at": { + "name": "admin_audit_created_at", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_lobby_operations": { + "name": "admin_lobby_operations", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_version": { + "name": "expected_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_version": { + "name": "applied_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_configuration": { + "name": "before_configuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after_configuration": { + "name": "after_configuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_lobby_operations_room": { + "name": "admin_lobby_operations_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_room_audit": { + "name": "admin_room_audit", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'room_control'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_revision": { + "name": "expected_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_paused": { + "name": "before_paused", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_join_locked": { + "name": "before_join_locked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after_paused": { + "name": "after_paused", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after_join_locked": { + "name": "after_join_locked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_room_audit_room": { + "name": "admin_room_audit_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_room_creations": { + "name": "admin_room_creations", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_hash": { + "name": "session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_room_creations_session_hash_unique": { + "name": "admin_room_creations_session_hash_unique", + "columns": [ + "session_hash" + ], + "isUnique": true + }, + "admin_room_creations_room": { + "name": "admin_room_creations_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_room_removals": { + "name": "admin_room_removals", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_version": { + "name": "expected_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_revision": { + "name": "expected_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_version": { + "name": "applied_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_revision": { + "name": "applied_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "removed": { + "name": "removed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_metadata": { + "name": "before_metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after_metadata": { + "name": "after_metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_room_removals_room": { + "name": "admin_room_removals_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_sessions": { + "name": "admin_sessions", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "admin_id": { + "name": "admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "admin_sessions_id_unique": { + "name": "admin_sessions_id_unique", + "columns": [ + "id" + ], + "isUnique": true + }, + "admin_sessions_account": { + "name": "admin_sessions_account", + "columns": [ + "admin_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "admin_sessions_admin_id_admin_accounts_id_fk": { + "name": "admin_sessions_admin_id_admin_accounts_id_fk", + "tableFrom": "admin_sessions", + "tableTo": "admin_accounts", + "columnsFrom": [ + "admin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "room_controls": { + "name": "room_controls", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "paused": { + "name": "paused", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "join_locked": { + "name": "join_locked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "room_controls_room_code_rooms_code_fk": { + "name": "room_controls_room_code_rooms_code_fk", + "tableFrom": "room_controls", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "room_controls_paused": { + "name": "room_controls_paused", + "value": "\"room_controls\".\"paused\" IN (0, 1)" + }, + "room_controls_join_locked": { + "name": "room_controls_join_locked", + "value": "\"room_controls\".\"join_locked\" IN (0, 1)" + }, + "room_controls_revision": { + "name": "room_controls_revision", + "value": "\"room_controls\".\"revision\" >= 0" + } + } + }, + "room_entry_receipts": { + "name": "room_entry_receipts", + "columns": { + "operation_hash": { + "name": "operation_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_hash": { + "name": "session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "room_entry_receipts_room_code_rooms_code_fk": { + "name": "room_entry_receipts_room_code_rooms_code_fk", + "tableFrom": "room_entry_receipts", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "room_messages": { + "name": "room_messages", + "columns": { + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_session_hash": { + "name": "sender_session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_name": { + "name": "sender_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_faction": { + "name": "sender_faction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_id": { + "name": "recipient_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_name": { + "name": "recipient_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "room_messages_room_id": { + "name": "room_messages_room_id", + "columns": [ + "room_code", + "id" + ], + "isUnique": true + }, + "room_messages_channel": { + "name": "room_messages_channel", + "columns": [ + "room_code", + "recipient_id", + "sequence" + ], + "isUnique": false + }, + "room_messages_sender": { + "name": "room_messages_sender", + "columns": [ + "room_code", + "sender_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "room_messages_room_code_rooms_code_fk": { + "name": "room_messages_room_code_rooms_code_fk", + "tableFrom": "room_messages", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "room_removals": { + "name": "room_removals", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "removed": { + "name": "removed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "removed_at": { + "name": "removed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "room_removals_room_code_rooms_code_fk": { + "name": "room_removals_room_code_rooms_code_fk", + "tableFrom": "room_removals", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "room_removals_removed": { + "name": "room_removals_removed", + "value": "\"room_removals\".\"removed\" IN (0, 1)" + }, + "room_removals_revision": { + "name": "room_removals_revision", + "value": "\"room_removals\".\"revision\" >= 0" + }, + "room_removals_time": { + "name": "room_removals_time", + "value": "(\"room_removals\".\"removed\" = 1 AND \"room_removals\".\"removed_at\" IS NOT NULL) OR (\"room_removals\".\"removed\" = 0 AND \"room_removals\".\"removed_at\" IS NULL)" + } + } + }, + "rooms": { + "name": "rooms", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "rooms_updated_at": { + "name": "rooms_updated_at", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "seat_ai_delegations": { + "name": "seat_ai_delegations", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delegate_id": { + "name": "delegate_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "difficulty": { + "name": "difficulty", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_session_hash": { + "name": "owner_session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delegate_session_hash": { + "name": "delegate_session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "seat_ai_delegations_grant_id_unique": { + "name": "seat_ai_delegations_grant_id_unique", + "columns": [ + "grant_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "seat_ai_delegations_room_code_rooms_code_fk": { + "name": "seat_ai_delegations_room_code_rooms_code_fk", + "tableFrom": "seat_ai_delegations", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "seat_ai_delegations_room_code_owner_id_grant_id_pk": { + "columns": [ + "room_code", + "owner_id", + "grant_id" + ], + "name": "seat_ai_delegations_room_code_owner_id_grant_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "seat_handover_claim_receipts": { + "name": "seat_handover_claim_receipts", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation_hash": { + "name": "operation_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "offer_hash": { + "name": "offer_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_hash": { + "name": "session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claim_fence": { + "name": "claim_fence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "seat_handover_claim_receipts_claim_fence_unique": { + "name": "seat_handover_claim_receipts_claim_fence_unique", + "columns": [ + "claim_fence" + ], + "isUnique": true + } + }, + "foreignKeys": { + "seat_handover_claim_receipts_room_code_rooms_code_fk": { + "name": "seat_handover_claim_receipts_room_code_rooms_code_fk", + "tableFrom": "seat_handover_claim_receipts", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "seat_handover_claim_receipts_room_code_player_id_operation_hash_pk": { + "columns": [ + "room_code", + "player_id", + "operation_hash" + ], + "name": "seat_handover_claim_receipts_room_code_player_id_operation_hash_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "seat_handover_offers": { + "name": "seat_handover_offers", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "offer_hash": { + "name": "offer_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "issuer_session_hash": { + "name": "issuer_session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claim_operation_hash": { + "name": "claim_operation_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_hash": { + "name": "session_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "seat_handover_offers_offer_hash_unique": { + "name": "seat_handover_offers_offer_hash_unique", + "columns": [ + "offer_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "seat_handover_offers_room_code_rooms_code_fk": { + "name": "seat_handover_offers_room_code_rooms_code_fk", + "tableFrom": "seat_handover_offers", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "seat_handover_offers_room_code_player_id_pk": { + "columns": [ + "room_code", + "player_id" + ], + "name": "seat_handover_offers_room_code_player_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "seat_recovery_keys": { + "name": "seat_recovery_keys", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recovery_hash": { + "name": "recovery_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current_operation_hash": { + "name": "current_operation_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "seat_recovery_keys_room_code_rooms_code_fk": { + "name": "seat_recovery_keys_room_code_rooms_code_fk", + "tableFrom": "seat_recovery_keys", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "seat_recovery_keys_room_code_player_id_pk": { + "columns": [ + "room_code", + "player_id" + ], + "name": "seat_recovery_keys_room_code_player_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "seat_recovery_receipts": { + "name": "seat_recovery_receipts", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation_hash": { + "name": "operation_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recovery_hash": { + "name": "recovery_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_hash": { + "name": "session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "seat_recovery_receipts_room_code_rooms_code_fk": { + "name": "seat_recovery_receipts_room_code_rooms_code_fk", + "tableFrom": "seat_recovery_receipts", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "seat_recovery_receipts_room_code_player_id_operation_hash_pk": { + "columns": [ + "room_code", + "player_id", + "operation_hash" + ], + "name": "seat_recovery_receipts_room_code_player_id_operation_hash_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "seats": { + "name": "seats", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "revoked": { + "name": "revoked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "seats_room_code_rooms_code_fk": { + "name": "seats_room_code_rooms_code_fk", + "tableFrom": "seats", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 01faf730..5c4972c3 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/game/engine.ts b/game/engine.ts index 2cf87857..7e66ae5f 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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.'); diff --git a/lib/admin-directory.ts b/lib/admin-directory.ts index 70975e1a..b457398b 100644 --- a/lib/admin-directory.ts +++ b/lib/admin-directory.ts @@ -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; diff --git a/lib/admin-removal-client.ts b/lib/admin-removal-client.ts new file mode 100644 index 00000000..7b6d8888 --- /dev/null +++ b/lib/admin-removal-client.ts @@ -0,0 +1,63 @@ +import { randomId } from './random-id'; +import { validAdminRemovalInput, type AdminRemovalInput, type AdminRemovalView, type AdminRemovalResult } from './admin-removal'; + +type Storage = Pick; +const key = (account: string, code: string) => `dune.admin-removal-request.v1:${account}:${code}`; +const object = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); +const keys = (value: Record, 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.'); +} diff --git a/lib/admin-removal.ts b/lib/admin-removal.ts new file mode 100644 index 00000000..f78148ae --- /dev/null +++ b/lib/admin-removal.ts @@ -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; + 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) + ); +} diff --git a/lib/admin-room-creation-client.ts b/lib/admin-room-creation-client.ts index d51ec184..e7cd74b1 100644 --- a/lib/admin-room-creation-client.ts +++ b/lib/admin-room-creation-client.ts @@ -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; - 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.'); diff --git a/lib/admin-room-creation.ts b/lib/admin-room-creation.ts index 402146d6..d385f975 100644 --- a/lib/admin-room-creation.ts +++ b/lib/admin-room-creation.ts @@ -20,6 +20,7 @@ export type AdminRoomCreationResult = { hostId: string; replayed: boolean; hostAccess: boolean; + roomRemoved?: true; }; const object = (value: unknown): value is Record => diff --git a/lib/client-request.ts b/lib/client-request.ts index c653ad3e..fe18fe86 100644 --- a/lib/client-request.ts +++ b/lib/client-request.ts @@ -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( 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; }; diff --git a/tests/admin-boundary-http.test.ts b/tests/admin-boundary-http.test.ts index 144e23fe..d140dca0 100644 --- a/tests/admin-boundary-http.test.ts +++ b/tests/admin-boundary-http.test.ts @@ -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[]) { - 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' }), diff --git a/tests/admin-directory.test.ts b/tests/admin-directory.test.ts index 3bff684c..67d52369 100644 --- a/tests/admin-directory.test.ts +++ b/tests/admin-directory.test.ts @@ -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'); diff --git a/tests/admin-removal-client.test.ts b/tests/admin-removal-client.test.ts new file mode 100644 index 00000000..647d9cc6 --- /dev/null +++ b/tests/admin-removal-client.test.ts @@ -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(); + 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); +}); diff --git a/tests/admin-removal.test.ts b/tests/admin-removal.test.ts new file mode 100644 index 00000000..f2938830 --- /dev/null +++ b/tests/admin-removal.test.ts @@ -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; +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) { + 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((resolve) => { + enter = resolve; + }); + const gate = new Promise((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 }); + } +}); diff --git a/tests/admin-room-creation-client.test.ts b/tests/admin-room-creation-client.test.ts index 4c5a669f..f0b0bcfe 100644 --- a/tests/admin-room-creation-client.test.ts +++ b/tests/admin-room-creation-client.test.ts @@ -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'); diff --git a/tests/client-request.test.ts b/tests/client-request.test.ts index 173919c1..cb34436c 100644 --- a/tests/client-request.test.ts +++ b/tests/client-request.test.ts @@ -4,11 +4,38 @@ import { ClientRequestError, requestJson, requestMayHaveCompleted, + isRoomRemoved, + subscribeRoomRemoval, } from '../lib/client-request'; const never = () => new Promise(() => {}); 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; diff --git a/tests/lobby-join-readiness.test.ts b/tests/lobby-join-readiness.test.ts new file mode 100644 index 00000000..3eb48007 --- /dev/null +++ b/tests/lobby-join-readiness.test.ts @@ -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); + }); +} diff --git a/tests/room-entry-client.test.ts b/tests/room-entry-client.test.ts index 3f1a1780..a544dd90 100644 --- a/tests/room-entry-client.test.ts +++ b/tests/room-entry-client.test.ts @@ -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(); return { diff --git a/tests/room-removal-runtime.test.ts b/tests/room-removal-runtime.test.ts new file mode 100644 index 00000000..efad5614 --- /dev/null +++ b/tests/room-removal-runtime.test.ts @@ -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 () => { + hooks.beforeFirst?.(sql, values); + return first(); + }; + statement.run = async () => { + hooks.beforeRun?.(sql, values); + return run(); + }; + return statement; + }, + } as D1Database; + function load(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) => { + calls.views++; + return engine.viewGame(...args); + }, + normalizeAutomaticGame: ( + ...args: Parameters + ) => { + calls.normalization++; + return engine.normalizeAutomaticGame(...args); + }, + }; + if (name === '@/game/bots') + return { + ...bots, + runBots: (...args: Parameters) => { + 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('../db/rooms.ts'); + const talk = load('../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('../db/rooms.ts'), + }; +} +type Fixture = Awaited>; +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 | 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 | 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 | 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 | 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(); + } + }); +} diff --git a/tools/admin-removal-verification.mjs b/tools/admin-removal-verification.mjs new file mode 100644 index 00000000..4e16924a --- /dev/null +++ b/tools/admin-removal-verification.mjs @@ -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; +} diff --git a/tools/verify-admin.mjs b/tools/verify-admin.mjs index da3b3a39..e518b8f8 100644 --- a/tools/verify-admin.mjs +++ b/tools/verify-admin.mjs @@ -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) {