From e918e2e0c0e2fcf79371735646ca5312fda3cbb6 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:55:44 +1000 Subject: [PATCH] Add reversible administrator room archiving Organize closed rooms with archive filters, live-authorized atomic receipts and exact saved retries. Preserve saved games, timestamps, credentials, discussion and AI deadlines. Unarchive leaves play closed; reopen requires unarchive, and removal/restoration preserves both flags. Verified types, lint, 5379 offline tests, 52 HTTP tests and build. Twenty dedicated administrator HTTP groups and independent privacy/persistence review passed. Desktop/mobile browser QA confirmed a single archive after a lost response and refresh, readable recovery text, mobile unarchive and separate reopening, and saved-seat restoration. All 2,049 baseline rooms, 4,348 seats and related records preserved. Authenticated production admin acceptance awaits authorized QA access; full admin and rules gates remain open. --- README.md | 6 +- app/admin/page.tsx | 22 +- app/api/admin/rooms/[code]/archive/route.ts | 33 + components/admin-room-archive.tsx | 147 ++ components/admin-room-closure.tsx | 6 +- components/admin-room-removal.tsx | 4 +- db/admin-archive.ts | 217 +++ db/admin-closure.ts | 6 +- db/admin-directory.ts | 14 +- db/admin-removal.ts | 4 +- db/schema.ts | 45 + docs/ADMIN_PANEL.md | 7 +- docs/ADMIN_ROOM_ARCHIVE.md | 65 + docs/ADMIN_ROOM_CLOSURE.md | 5 +- docs/ADMIN_ROOM_REMOVAL.md | 6 +- docs/CURRENT_STATUS.md | 22 +- docs/IMPLEMENTATION_STATUS.md | 16 + drizzle/0014_admin_room_archive.sql | 28 + drizzle/meta/0014_snapshot.json | 1891 +++++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/admin-archive-client.ts | 65 + lib/admin-archive.ts | 64 + lib/admin-closure-client.ts | 3 +- lib/admin-closure.ts | 1 + lib/admin-directory.ts | 1 + lib/admin-removal-client.ts | 2 +- lib/admin-removal.ts | 1 + tests/admin-archive-client.test.ts | 85 + tests/admin-archive.test.ts | 737 ++++++++ tests/admin-boundary-http.test.ts | 9 +- tests/admin-closure-client.test.ts | 10 + tests/admin-directory.test.ts | 32 + tests/admin-removal-client.test.ts | 9 + tools/admin-archive-verification.mjs | 88 + tools/verify-admin.mjs | 4 +- 35 files changed, 3624 insertions(+), 38 deletions(-) create mode 100644 app/api/admin/rooms/[code]/archive/route.ts create mode 100644 components/admin-room-archive.tsx create mode 100644 db/admin-archive.ts create mode 100644 docs/ADMIN_ROOM_ARCHIVE.md create mode 100644 drizzle/0014_admin_room_archive.sql create mode 100644 drizzle/meta/0014_snapshot.json create mode 100644 lib/admin-archive-client.ts create mode 100644 lib/admin-archive.ts create mode 100644 tests/admin-archive-client.test.ts create mode 100644 tests/admin-archive.test.ts create mode 100644 tools/admin-archive-verification.mjs diff --git a/README.md b/README.md index 5a349d40..d90b7666 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,10 @@ 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 or reopen](docs/ADMIN_ROOM_CLOSURE.md) stops further play while preserving -readable tables and history, then resumes the same saved position. Archive, -permanent deletion, participant support and backup tools are still being built. +readable tables and history, then resumes the same saved position. +[Archive or unarchive](docs/ADMIN_ROOM_ARCHIVE.md) organizes closed rooms while +retaining their tables and history; unarchiving leaves play closed. +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 555d8b69..9f6ac86f 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -11,13 +11,14 @@ 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 { AdminRoomArchive } from '@/components/admin-room-archive'; import { AdminRoomClosure } from '@/components/admin-room-closure'; 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; removal: string }; -const initialFilters: Filters = { q: '', status: 'all', rules: 'all', sort: 'recent', availability: 'all', removal: 'active' }; +type Filters = { q: string; status: string; rules: string; sort: string; availability: string; removal: string; archive: string }; +const initialFilters: Filters = { q: '', status: 'all', rules: 'all', sort: 'recent', availability: 'all', removal: 'active', archive: 'unarchived' }; const errorMessage = (error: unknown) => error instanceof Error ? error.message : 'The request could not be completed.'; export default function Administration() { @@ -32,6 +33,7 @@ export default function Administration() { const [confirmAll, setConfirmAll] = useState(false); const [selectedRoom, setSelectedRoom] = useState(null); const [selectedLobby, setSelectedLobby] = useState(null); + const [selectedArchive, setSelectedArchive] = useState(null); const [selectedClosure, setSelectedClosure] = useState(null); const [selectedRemoval, setSelectedRemoval] = useState(null); const [creatingRoom, setCreatingRoom] = useState(false); @@ -41,7 +43,7 @@ export default function Administration() { const clearSession = useCallback(() => { epoch.current++; setAccount(null); setDirectory(null); setConfirmAll(false); - setBusy(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setCreatingRoom(false); + setBusy(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setCreatingRoom(false); }, []); const accessChanged = useCallback(() => { clearSession(); @@ -117,7 +119,7 @@ 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} @@ -127,6 +129,9 @@ export default function Administration() { + @@ -147,6 +152,9 @@ export default function Administration() { {selectedLobby && setSelectedLobby(null)} onDenied={accessChanged} onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />} + {selectedArchive && setSelectedArchive(null)} onDenied={accessChanged} + onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />} {selectedClosure && setSelectedClosure(null)} onDenied={accessChanged} onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />} @@ -161,7 +169,7 @@ export default function Administration() { Room & playersRulesProgressLast game change {directory.rooms.map(room => {room.code} {room.status} - {room.removed && Removed} + {room.archived && Archived} {room.removed && Removed} {room.control.closed && Closed} {room.control.paused && Paused} {room.control.joinLocked && Joins locked} @@ -175,7 +183,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, manage joining locks, close/reopen play, and recoverably remove or restore rooms. Archive, permanent removal, participant support and backup tools are still being implemented.

+

Administration preview: create and configure lobbies, assign hosts, pause/resume rooms, manage joining locks, close/reopen play, archive/unarchive closed rooms, and recoverably remove or restore rooms. Permanent removal, participant support and backup tools are still being implemented.

} ; } diff --git a/app/api/admin/rooms/[code]/archive/route.ts b/app/api/admin/rooms/[code]/archive/route.ts new file mode 100644 index 00000000..3e16b339 --- /dev/null +++ b/app/api/admin/rooms/[code]/archive/route.ts @@ -0,0 +1,33 @@ +import { env } from 'cloudflare:workers'; +import { AdminError, requireAdmin } from '@/db/admin-access'; +import { applyAdminArchive, readAdminArchive } from '@/db/admin-archive'; +import { validAdminArchiveInput } from '@/lib/admin-archive'; +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 readAdminArchive(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 (!validAdminArchiveInput(input)) throw new AdminError('Choose archive or unarchive, current versions and an operational reason.', 400); + const room = code(request); + const result = await applyAdminArchive(env.DB, identity, room, input); + return adminResponse(result); + } catch (error) { return adminFailure(error); } +} diff --git a/components/admin-room-archive.tsx b/components/admin-room-archive.tsx new file mode 100644 index 00000000..cbe84356 --- /dev/null +++ b/components/admin-room-archive.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 { adminArchiveResponse, adminArchiveConfirmation, clearAdminArchiveRequest, newAdminArchiveRequest, readAdminArchiveRequest, saveAdminArchiveRequest } from '@/lib/admin-archive-client'; +import type { AdminArchiveInput, AdminArchiveView } from '@/lib/admin-archive'; + +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 AdminRoomArchive({ 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}/archive`; + + useEffect(() => { + alive.current = true; title.current?.focus(); + let canceled = false; + try { + const saved = readAdminArchiveRequest(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 archive or unarchive 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 = adminArchiveResponse(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 = adminArchiveResponse(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?: AdminArchiveInput) { + if (inFlight.current || loading || !canManage || storageProblem || + (!saved && (!room || room.removed || !room.closed || pending || !confirmed || (!room.archived && confirmationCode !== code)))) return; + let input: AdminArchiveInput; + try { + input = saved ?? newAdminArchiveRequest(room!, !room!.archived, reason); + saveAdminArchiveRequest(window.sessionStorage, accountId, code, input); + } catch (error) { setNotice(message(error)); return; } + inFlight.current = true; setBusy(true); onBusyChange(true); setPending(input); setNotice(input.archived ? 'Archiving room…' : 'Unarchiving room…'); + try { + const result = adminArchiveConfirmation(await requestJson(url, { + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input), + }), code, input); + clearAdminArchiveRequest(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.archived ? 'Room archived. It remains closed and readable to existing players. Find it with the Archive filter.' : 'Room unarchived. It remains closed until an administrator separately reopens it.'); + onUpdated(); + } + } catch (error) { + let cleared = false; + if (!retainAdminRoomRequest(error)) { + try { clearAdminArchiveRequest(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 = adminArchiveResponse(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 { + clearAdminArchiveRequest(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
+

Archive or unarchive · {code}

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

Archive status: {room.archived ? 'Archived' : 'Unarchived'}. Room availability: {room.removed ? 'Removed' : room.closed ? 'Closed' : 'Open'}.

+

Archiving organizes a closed room without changing its saved game, credentials or discussion. Existing players keep their readable table and history.

+ {room.archivedAt !== null &&

Archived at: .

} + + : } + {!canManage ?

Your viewer role can inspect archive status. An owner or operator can archive or unarchive rooms.

: storageProblem ?
+

No new changes can be sent until this tab’s unreadable retry record is resolved. Discarding the record does not cancel a server change.

+ + +
: pending ?
+

Saved request: {pending.archived ? 'Archive' : 'Unarchive'} room {code}.

Reason: {pending.reason}

+

The exact request stays in this tab across refresh. Retrying confirms the original change without applying it again; the current archive status may differ after another administrator’s action.

+

After refresh or sign-in, choose All rooms in both the Directory and Archive filters to find the room and reopen these controls.

+ +
: room?.removed ?

Restore this removed room before changing its archive status. Any saved archive request can still be confirmed above.

: room && !room.closed ?

Use Close or reopen to close this room before archiving it. Archiving does not interrupt play or assign an outcome.

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

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

+
+ {room.archived ? <> +

Return room {code} to the unarchived directory. Its game, seats, history, pause and joining settings remain saved.

+

Unarchiving leaves the room closed. Reopen it separately when play should resume.

+ : <> +

Archive closed room {code}. It leaves the default administrator list and remains available under Archived rooms or All rooms in the Archive filter.

+

Players can still read their saved table and history. The room stays closed to further play; archiving does not delete it or change its winner.

+ + } + + +
+
} + } +
; +} diff --git a/components/admin-room-closure.tsx b/components/admin-room-closure.tsx index d1edba54..bd09b318 100644 --- a/components/admin-room-closure.tsx +++ b/components/admin-room-closure.tsx @@ -63,7 +63,7 @@ export function AdminRoomClosure({ accountId, code, canManage, onClose, onUpdate async function submit(saved?: AdminClosureInput) { if (inFlight.current || loading || !canManage || storageProblem || - (!saved && (!room || room.removed || pending || !confirmed || (!room.closed && confirmationCode !== code)))) return; + (!saved && (!room || room.removed || room.archived || pending || !confirmed || (!room.closed && confirmationCode !== code)))) return; let input: AdminClosureInput; try { input = saved ?? newAdminClosureRequest(room!, !room!.closed, reason); @@ -124,9 +124,9 @@ export function AdminRoomClosure({ accountId, code, canManage, onClose, onUpdate : pending ?

Saved request: {pending.closed ? 'Close' : 'Reopen'} 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.

+

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

-
: room?.removed ?

Restore this removed room before changing its closure. Any saved close/reopen request can still be confirmed above.

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

Restore this removed room before changing its closure. Any saved close/reopen request can still be confirmed above.

: room?.archived ?

Unarchive this room before reopening it. The saved game remains closed and readable to existing players.

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

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

diff --git a/components/admin-room-removal.tsx b/components/admin-room-removal.tsx index 96629f96..f3f8bdaf 100644 --- a/components/admin-room-removal.tsx +++ b/components/admin-room-removal.tsx @@ -124,7 +124,7 @@ export function AdminRoomRemoval({ accountId, code, canManage, onClose, onUpdate
: 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.

+

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

: room && { event.preventDefault(); void submit(); }}> @@ -132,7 +132,7 @@ export function AdminRoomRemoval({ accountId, code, canManage, onClose, onUpdate
{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.closed && 'This room is also closed: restoration keeps it closed to further play until reopened.'} {room.closed ? 'AI remains stopped while closed.' : room.paused ? 'This room will remain paused.' : 'This room will resume with the existing AI pace.'} New joins will remain {room.joinLocked ? 'locked' : 'open'}.

+

Restoration preserves the previous pause and joining settings. {room.archived && 'This room remains archived; use the Archive filter to find it after restoration.'} {room.closed && 'This room is also closed: restoration keeps it closed to further play until reopened.'} {room.closed ? 'AI remains stopped while closed.' : room.paused ? 'This room will remain paused.' : 'This room will resume with the existing AI pace.'} New joins will remain {room.joinLocked ? 'locked' : 'open'}.

: <>

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/db/admin-archive.ts b/db/admin-archive.ts new file mode 100644 index 00000000..9566ab86 --- /dev/null +++ b/db/admin-archive.ts @@ -0,0 +1,217 @@ +import { AdminError, type AdminIdentity } from './admin-access'; +import { + validAdminArchiveInput, + type AdminArchiveInput, + type AdminArchiveResult, + type AdminArchiveView, +} from '../lib/admin-archive'; + +const authority = `SELECT a.role FROM admin_sessions s JOIN admin_accounts a ON a.id = s.admin_id + WHERE s.token_hash = ? AND a.id = ? AND a.enabled = 1 AND s.revoked_at IS NULL + AND s.expires_at > ? AND s.generation = a.session_generation + AND a.role IN ('owner','operator','viewer')`; +const mutationAuthority = authority + " AND a.role IN ('owner','operator')"; +const roomSelect = `SELECT r.code,r.version,EXISTS (SELECT 1 FROM room_closures WHERE room_code = r.code AND closed = 1) AS closed,EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1) AS removed,COALESCE(m.archived,0) AS archived, + COALESCE(m.revision,0) AS revision,m.archived_at,m.updated_at, + COALESCE(c.paused,0) AS paused,COALESCE(c.join_locked,0) AS join_locked + FROM rooms r LEFT JOIN room_archives m ON m.room_code = r.code + LEFT JOIN room_controls c ON c.room_code = r.code + WHERE r.code = ? AND EXISTS (${authority})`; +type Row = { + closed: number; + removed: number; + code: string; + version: number; + archived: number; + revision: number; + archived_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): AdminArchiveView => ({ + code: row.code, + closed: row.closed === 1, + removed: row.removed === 1, + version: row.version, + archived: row.archived === 1, + revision: row.revision, + archivedAt: row.archived_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 archived and unreadable saves. */ +export async function readAdminArchive( + 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 archive transition commit together. + * Neither transition changes saved state, credentials, closure or AI pacing. */ +export async function applyAdminArchive( + database: D1Database, + identity: AdminIdentity, + code: string, + input: AdminArchiveInput, + now = Date.now(), +): Promise { + const args = credentials(identity, code, now); + if (!validAdminArchiveInput(input)) + throw new AdminError( + 'Provide the room version, archive revision, requested state, a fresh operation identifier and a reason of 1–300 characters.', + 400, + ); + const { operationId, expectedVersion, expectedRevision, archived } = input; + const actorId = identity.id, + reason = input.reason.trim(); + const digest = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode( + JSON.stringify({ expectedVersion, expectedRevision, archived, 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_archives + (operation_id,actor_admin_id,room_code,request_hash,expected_version,expected_revision, + applied_version,applied_revision,archived,reason,before_metadata,after_metadata,created_at) + SELECT ?,?,r.code,?,?,?,r.version + 1,COALESCE(m.revision,0) + 1,?,?, + json_object('closed',json('true'),'removed',json('false'),'code',r.code,'version',r.version,'archived',json(CASE WHEN m.archived = 1 THEN 'true' ELSE 'false' END), + 'revision',COALESCE(m.revision,0),'archivedAt',m.archived_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('closed',json('true'),'removed',json('false'),'code',r.code,'version',r.version + 1,'archived',json(CASE WHEN ? = 1 THEN 'true' ELSE 'false' END), + 'revision',COALESCE(m.revision,0) + 1,'archivedAt',?,'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_archives 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.archived,0) != ? + AND EXISTS (${mutationAuthority}) + AND EXISTS (SELECT 1 FROM room_closures WHERE room_code = r.code AND closed = 1) + AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1) + AND NOT EXISTS (SELECT 1 FROM admin_room_archives WHERE operation_id = ?)`) + .bind( + operationId, + actorId, + requestHash, + expectedVersion, + expectedRevision, + Number(archived), + reason, + Number(archived), + archived ? now : null, + now, + now, + code, + expectedVersion, + expectedRevision, + Number(archived), + ...args, + operationId, + ), + // Archive is directory metadata only. Preserve the entire saved JSON and its timestamps. + database.prepare('UPDATE rooms SET version = version + 1 WHERE code = ? AND changes() = 1').bind(code), + database + .prepare(`INSERT INTO room_archives(room_code,archived,revision,archived_at,updated_at) + SELECT room_code,archived,applied_revision,CASE WHEN archived = 1 THEN created_at ELSE NULL END,created_at + FROM admin_room_archives WHERE operation_id = ? AND changes() = 1 + ON CONFLICT(room_code) DO UPDATE SET archived = excluded.archived,revision = excluded.revision, + archived_at = excluded.archived_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_archives 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 archive operation is bound to a different administrator or request.', + 409, + ); + if (!row) throw new AdminError('Room not found.', 404); + return { + operationId, + replayed: results[1].meta.changes !== 1, + appliedRevision: receipt.applied_revision, + appliedVersion: receipt.applied_version, + room: project(row), + }; + } + if (!row) throw new AdminError('Room not found.', 404); + if (row.removed) throw new AdminError('Restore this removed room before changing its archive.', 409); + if (!row.closed) throw new AdminError('Close this room before changing its archive state.', 409); + if (row.version !== expectedVersion || row.revision !== expectedRevision) + throw new AdminError( + 'The room or archive settings changed. Refresh before choosing a new operation.', + 409, + ); + throw new AdminError( + archived + ? 'This room is already archived.' + : 'This room is already unarchived.', + 409, + ); +} diff --git a/db/admin-closure.ts b/db/admin-closure.ts index 67dbe6ee..324d233d 100644 --- a/db/admin-closure.ts +++ b/db/admin-closure.ts @@ -11,13 +11,14 @@ const authority = `SELECT a.role FROM admin_sessions s JOIN admin_accounts a ON AND s.expires_at > ? AND s.generation = a.session_generation AND a.role IN ('owner','operator','viewer')`; const mutationAuthority = authority + " AND a.role IN ('owner','operator')"; -const roomSelect = `SELECT r.code,r.version,EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1) AS removed,COALESCE(m.closed,0) AS closed, +const roomSelect = `SELECT r.code,r.version,EXISTS (SELECT 1 FROM room_archives WHERE room_code = r.code AND archived = 1) AS archived,EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1) AS removed,COALESCE(m.closed,0) AS closed, COALESCE(m.revision,0) AS revision,m.closed_at,m.updated_at, COALESCE(c.paused,0) AS paused,COALESCE(c.join_locked,0) AS join_locked FROM rooms r LEFT JOIN room_closures m ON m.room_code = r.code LEFT JOIN room_controls c ON c.room_code = r.code WHERE r.code = ? AND EXISTS (${authority})`; type Row = { + archived: number; removed: number; code: string; version: number; @@ -38,6 +39,7 @@ type Receipt = { }; const project = (row: Row): AdminClosureView => ({ code: row.code, + ...(row.archived === 1 ? { archived: true as const } : {}), removed: row.removed === 1, version: row.version, closed: row.closed === 1, @@ -139,6 +141,7 @@ export async function applyAdminClosure( LEFT JOIN room_controls c ON c.room_code = r.code WHERE r.code = ? AND r.version = ? AND COALESCE(m.revision,0) = ? AND COALESCE(m.closed,0) != ? AND EXISTS (${mutationAuthority}) + AND NOT EXISTS (SELECT 1 FROM room_archives WHERE room_code = r.code AND archived = 1) AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM admin_room_closures WHERE operation_id = ?)`) .bind( @@ -211,6 +214,7 @@ export async function applyAdminClosure( } if (!row) throw new AdminError('Room not found.', 404); if (row.removed) throw new AdminError('Restore this removed room before changing its closure.', 409); + if (row.archived) throw new AdminError('Unarchive this room before reopening it.', 409); if (row.version !== expectedVersion || row.revision !== expectedRevision) throw new AdminError( 'The room or closure settings changed. Refresh before choosing a new operation.', diff --git a/db/admin-directory.ts b/db/admin-directory.ts index 0cb198b0..6efe11b3 100644 --- a/db/admin-directory.ts +++ b/db/admin-directory.ts @@ -9,11 +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; removed?: number; control_closed?: number; control_paused?: number; control_join_locked?: number; control_revision?: number; control_updated_at?: number | null }; +type DirectoryRow = { code: string; version: number; updated_at: number; state: string; removed?: number; archived?: number; control_closed?: number; control_paused?: number; control_join_locked?: number; control_revision?: number; control_updated_at?: number | null }; export function projectAdminRoom(row: DirectoryRow): AdminRoom { const base: AdminRoom = { code: row.code, version: row.version, updatedAt: row.updated_at, - removed: row.removed === 1, + removed: row.removed === 1, archived: row.archived === 1, status: 'unreadable', host: null, players: [], advanced: false, preview: false, expansions: [], modules: [], turn: null, phase: 'Unavailable', pending: { label: 'Saved room needs inspection', owners: [] }, @@ -50,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', 'removal']; + const allowed = ['q', 'status', 'rules', 'sort', 'page', 'availability', 'removal', 'archive']; 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'; @@ -59,8 +59,9 @@ export async function readAdminDirectory(database: D1Database, identity: AdminId const rawPage = search.get('page') ?? '1'; const availability = search.get('availability') ?? 'all'; const removal = search.get('removal') ?? 'active'; + const archive = search.get('archive') ?? 'unarchived'; if (q.length > 80 || !['all', ...statuses].includes(status) || !['all', 'basic', 'advanced'].includes(rules) || - !['recent', 'oldest', 'code'].includes(sort) || !['all', 'running', 'paused', 'locked', 'closed'].includes(availability) || !['active', 'removed', 'all'].includes(removal) || !/^[1-9][0-9]{0,5}$/.test(rawPage)) throw new AdminError('Invalid room filters.', 400); + !['recent', 'oldest', 'code'].includes(sort) || !['all', 'running', 'paused', 'locked', 'closed'].includes(availability) || !['active', 'removed', 'all'].includes(removal) || !['unarchived', 'archived', 'all'].includes(archive) || !/^[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"; @@ -86,13 +87,14 @@ export async function readAdminDirectory(database: D1Database, identity: AdminId if (availability === 'closed') where.push('cl.closed = 1'); if (availability === 'locked') where.push('c.join_locked = 1'); if (removal !== 'all') { where.push('COALESCE(m.removed,0) = ?'); args.push(removal === 'removed' ? 1 : 0); } + if (archive !== 'all') { where.push('COALESCE(ar.archived,0) = ?'); args.push(archive === 'archived' ? 1 : 0); } const predicate = where.length ? 'WHERE ' + where.join(' AND ') : ''; const order = sort === 'code' ? 'r.code ASC' : `r.updated_at ${sort === 'oldest' ? 'ASC' : 'DESC'}, r.code ASC`; - const source = 'FROM rooms r LEFT JOIN room_controls c ON c.room_code = r.code LEFT JOIN room_removals m ON m.room_code = r.code LEFT JOIN room_closures cl ON cl.room_code = r.code'; + const source = 'FROM rooms r LEFT JOIN room_controls c ON c.room_code = r.code LEFT JOIN room_removals m ON m.room_code = r.code LEFT JOIN room_closures cl ON cl.room_code = r.code LEFT JOIN room_archives ar ON ar.room_code = r.code'; const results = await database.batch([ database.prepare(authority).bind(...credentials), database.prepare(`SELECT COUNT(*) AS total ${source} ${predicate}`).bind(...args), - database.prepare(`SELECT r.code,r.version,r.updated_at,r.state,COALESCE(m.removed,0) AS removed,COALESCE(cl.closed,0) AS control_closed,COALESCE(c.paused,0) AS control_paused,COALESCE(c.join_locked,0) AS control_join_locked,COALESCE(c.revision,0) AS control_revision,c.updated_at AS control_updated_at ${source} ${predicate} ORDER BY ${order} LIMIT ? OFFSET ?`).bind(...args, pageSize, (page - 1) * pageSize), + database.prepare(`SELECT r.code,r.version,r.updated_at,r.state,COALESCE(ar.archived,0) AS archived,COALESCE(m.removed,0) AS removed,COALESCE(cl.closed,0) AS control_closed,COALESCE(c.paused,0) AS control_paused,COALESCE(c.join_locked,0) AS control_join_locked,COALESCE(c.revision,0) AS control_revision,c.updated_at AS control_updated_at ${source} ${predicate} ORDER BY ${order} LIMIT ? OFFSET ?`).bind(...args, pageSize, (page - 1) * pageSize), ]); if (!results[0].results.length) throw new AdminError('Administrator sign-in required.', 401); const total = Number((results[1].results[0] as { total: number }).total); diff --git a/db/admin-removal.ts b/db/admin-removal.ts index 25ee43fb..a3c67c64 100644 --- a/db/admin-removal.ts +++ b/db/admin-removal.ts @@ -11,13 +11,14 @@ const authority = `SELECT a.role FROM admin_sessions s JOIN admin_accounts a ON AND s.expires_at > ? AND s.generation = a.session_generation AND a.role IN ('owner','operator','viewer')`; const mutationAuthority = authority + " AND a.role IN ('owner','operator')"; -const roomSelect = `SELECT r.code,r.version,EXISTS (SELECT 1 FROM room_closures WHERE room_code = r.code AND closed = 1) AS closed,COALESCE(m.removed,0) AS removed, +const roomSelect = `SELECT r.code,r.version,EXISTS (SELECT 1 FROM room_archives WHERE room_code = r.code AND archived = 1) AS archived,EXISTS (SELECT 1 FROM room_closures WHERE room_code = r.code AND closed = 1) AS closed,COALESCE(m.removed,0) AS removed, COALESCE(m.revision,0) AS revision,m.removed_at,m.updated_at, COALESCE(c.paused,0) AS paused,COALESCE(c.join_locked,0) AS join_locked FROM rooms r LEFT JOIN room_removals m ON m.room_code = r.code LEFT JOIN room_controls c ON c.room_code = r.code WHERE r.code = ? AND EXISTS (${authority})`; type Row = { + archived: number; closed: number; code: string; version: number; @@ -38,6 +39,7 @@ type Receipt = { }; const project = (row: Row): AdminRemovalView => ({ code: row.code, + ...(row.archived === 1 ? { archived: true as const } : {}), ...(row.closed === 1 ? { closed: true as const } : {}), version: row.version, removed: row.removed === 1, diff --git a/db/schema.ts b/db/schema.ts index f31347ea..833f0176 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -136,6 +136,51 @@ export const adminRoomClosures = sqliteTable( ], ); +// Archive organizes closed rooms without changing saved games or credentials. +export const roomArchives = sqliteTable( + 'room_archives', + { + roomCode: text('room_code') + .primaryKey() + .references(() => rooms.code, { onDelete: 'cascade' }), + archived: integer('archived').notNull().default(0), + revision: integer('revision').notNull().default(0), + archivedAt: integer('archived_at'), + updatedAt: integer('updated_at').notNull(), + }, + (table) => [ + check('room_archives_archived', sql`${table.archived} IN (0, 1)`), + check('room_archives_revision', sql`${table.revision} >= 0`), + check( + 'room_archives_time', + sql`(${table.archived} = 1 AND ${table.archivedAt} IS NOT NULL) OR (${table.archived} = 0 AND ${table.archivedAt} IS NULL)`, + ), + ], +); + +// Exact operation receipts also retain safe audit evidence independently of rooms/accounts. +export const adminRoomArchives = sqliteTable( + 'admin_room_archives', + { + 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(), + archived: integer('archived').notNull(), + reason: text('reason').notNull(), + beforeMetadata: text('before_metadata').notNull(), + afterMetadata: text('after_metadata').notNull(), + createdAt: integer('created_at').notNull(), + }, + (table) => [ + index('admin_room_archives_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/docs/ADMIN_PANEL.md b/docs/ADMIN_PANEL.md index ddc198a8..4734fecc 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), [administrative close/reopen](ADMIN_ROOM_CLOSURE.md) and [recoverable removal/restoration](ADMIN_ROOM_REMOVAL.md) have controls, durable audit, exact retries and player/AI enforcement. Archive, permanent deletion and bulk actions remain missing. | Prototyped, partial | +| Lifecycle and removal | [Pause/resume and joining locks](ADMIN_ROOM_CONTROLS.md), [administrative close/reopen](ADMIN_ROOM_CLOSURE.md) and [recoverable removal/restoration](ADMIN_ROOM_REMOVAL.md) have controls, durable audit, exact retries and player/AI enforcement. [Archive/unarchive](ADMIN_ROOM_ARCHIVE.md) has separate directory filters and exact retries. 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 | @@ -46,6 +46,9 @@ find removed rooms; restoration retains prior pause and joining settings. [Close or reopen](ADMIN_ROOM_CLOSURE.md) ends further play without awarding a winner and preserves readable private tables and discussion. Reopening retains the saved game, pause/join settings and current credentials. +[Archive or unarchive](ADMIN_ROOM_ARCHIVE.md) organizes closed rooms with separate +directory filters and exact retries. Unarchiving leaves play closed; restoring +a removed archive preserves both flags. 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. @@ -116,7 +119,7 @@ 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. They -also remove/restore a separate QA game, preserving its paused state, discussion, +also close/reopen, archive/unarchive and remove/restore separate QA games, preserving their 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 diff --git a/docs/ADMIN_ROOM_ARCHIVE.md b/docs/ADMIN_ROOM_ARCHIVE.md new file mode 100644 index 00000000..82756a63 --- /dev/null +++ b/docs/ADMIN_ROOM_ARCHIVE.md @@ -0,0 +1,65 @@ +# Administrative archive and unarchive + +Owners and operators can choose **Archive or unarchive** beside a room in +Administration. First close the room through [Close or reopen](ADMIN_ROOM_CLOSURE.md). +Archiving requires an operational reason, the exact room code and confirmation. +Unarchiving requires a reason and acknowledgment that play stays closed. Viewers +can inspect archive status. Instructions and outcome notices remain readable +until another deliberate action changes the panel. + +Archive organizes closed rooms in the administrator directory. It preserves the +entire saved game, game-change timestamp, winner, pending decisions, physical +pieces, seats, revoked access, discussion and AI deadlines. Existing players can +still read their own closed table and history. It does not independently pause, +end or resume the rules engine. **Unarchive leaves the room closed**; an +administrator must separately reopen it when play should resume. + +## Directory and other controls + +The **Archive** filter defaults to **Unarchived rooms**; choose **Archived rooms** +or **All rooms** to find an archived game. This filter is independent of +**Directory → Active / Removed / All rooms** and the availability filter. +To find a room that is both removed and archived, choose **All rooms** in both +Directory and Archive. Searches still inspect only codes and public player names. + +Archived rooms cannot be reopened until unarchived. Recoverable removal can hide +an archived room; restoration preserves its archive and closure and does not wake +its AI. Restore a removed room before changing its archive status. An exact +previous receipt can still be confirmed while a room is removed or after another +administrator has changed its availability. + +## Saved retries and preservation + +Before sending, the browser saves the exact account/room operation, requested +state, reason and two version counters in tab-scoped storage. A lost response or +refresh offers **Retry saved request** when the controls reopen. The server +confirms the original operation without repeating it, and reports the room's +current status. Keep the tab open until resolved. Discarding an unreadable local +record cannot undo a server change. + +Live owner/operator authority, room version, archive revision, closed status and +removal status are fenced inside the write transaction. Audit receipt, room +version increment and archive metadata commit together. Rejected or competing +requests cannot partially change the room; identical concurrent requests commit +once. Admin responses and audit metadata contain operational flags and timestamps, +without hands, messages, seat credentials or raw saved state. These operations +never parse or repair the saved game, including an unreadable save. + +Apply additive migration `0014_admin_room_archive.sql` before using this revision. +`room_archives` holds current directory status; `admin_room_archives` holds durable +audit receipts. Existing records are not rewritten or deleted. Permanent deletion, +bulk operations, participant support and backup/restore tools remain unfinished. + +## Verification + +Focused tests cover live authorization changes, exact receipts, concurrent +reopening/removal, transaction rollback, database reopen, AI deadline and credential +preservation, strict client projections and combined directory pagination/search. +The administrator HTTP verifier creates dedicated rooms for archive/unarchive, +closure/removal composition and unchanged saved player views/history. Browser +acceptance exercises actual confirmations, a lost successful response followed by +refresh and exact retry, persistent notices, mobile controls and saved-seat reads. + +Keep source-bound checks, saved-record comparisons and deployed acceptance separate. +Local checks do not establish authenticated production acceptance; that remains +pending authorized QA operator access. See [verification workflow](VERIFICATION_WORKFLOW.md). diff --git a/docs/ADMIN_ROOM_CLOSURE.md b/docs/ADMIN_ROOM_CLOSURE.md index 67df8d9f..61996fdc 100644 --- a/docs/ADMIN_ROOM_CLOSURE.md +++ b/docs/ADMIN_ROOM_CLOSURE.md @@ -44,7 +44,7 @@ available while the table's mutation controls are disabled. ## Other administrator controls -Closed rooms remain in the default directory and can be filtered with +Unarchived closed rooms remain in the default directory and can be filtered with **Availability → Closed**. **Running** excludes closed rooms. New pause/join and lobby configuration changes require reopening; prior exact receipts still work. @@ -56,7 +56,8 @@ available to authorized administrators while it is removed. Apply additive migration `0013_admin_room_closure.sql` before this revision. `room_closures` stores current availability independently of pause and removal; `admin_room_closures` stores durable audit receipts. No existing game or credential -rows are migrated or deleted. Archive, permanent deletion, bulk actions, +rows are migrated or deleted. [Archiving](ADMIN_ROOM_ARCHIVE.md) organizes closed +rooms separately; unarchive before reopening. Permanent deletion, bulk actions, participant support and backup/restore tools remain unfinished. ## Verification diff --git a/docs/ADMIN_ROOM_REMOVAL.md b/docs/ADMIN_ROOM_REMOVAL.md index 9d2a43dc..19c261b6 100644 --- a/docs/ADMIN_ROOM_REMOVAL.md +++ b/docs/ADMIN_ROOM_REMOVAL.md @@ -64,7 +64,11 @@ 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 +[Close/reopen](ADMIN_ROOM_CLOSURE.md) and [archive/unarchive](ADMIN_ROOM_ARCHIVE.md) +are separate reversible controls. 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. + +Restoring an archived room preserves both archive and closure. Use All rooms in +both Directory and Archive filters to find a room with both flags. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 72ff26a4..966d28d3 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -6,12 +6,20 @@ Expansion starts and full rules/publication acceptance remain gated.** ## Current checkpoint and work -Current work adds [administrative close/reopen](ADMIN_ROOM_CLOSURE.md): -closed rooms preserve readable private tables and discussion while stopping new -play, joins, AI and seat mutations. Exact prior receipts remain confirmable; -reopening preserves pending decisions, credentials and pause/join flags. -Archive, permanent deletion, bulk actions and full participant/backup support -remain unfinished. +Current work adds [administrative archive/unarchive](ADMIN_ROOM_ARCHIVE.md): +closed rooms can leave the default directory without changing saved games, +credentials, discussion or AI deadlines. Unarchiving leaves play closed; +reopening requires unarchiving first. Exact saved retries and combined archive / +removal filters preserve recovery. Permanent deletion, bulk actions and full +participant/backup support remain unfinished. + +Checkpoint `98b1b32` added [close/reopen](ADMIN_ROOM_CLOSURE.md) and passed types/lint, +5,356 offline tests, 52 HTTP tests and build, with seventeen administrator HTTP +groups and lost-response desktop/mobile browser QA. It is deployed on the NAS: +container verification passed, migration 0013 applied, all eight prior rooms and +ten seats were preserved, and public HTTPS/browser saved-seat restoration passed. +Anonymous admin routes remain denied. Positive production administrator acceptance +still awaits authorized QA access; archive/unarchive is not deployed yet. Checkpoint `238de38` added [recoverable removal/restoration](ADMIN_ROOM_REMOVAL.md) and passed types/lint, 5,314 offline tests, 52 HTTP tests and build, with fourteen @@ -20,7 +28,7 @@ deployed on the NAS: container verification passed, migration 0012 applied, all eight prior rooms and ten seats were preserved, and public HTTPS/browser saved-seat restoration passed. Anonymous removal and other admin routes remain denied. Positive production administrator acceptance remains pending authorized -QA operator access; the current close/reopen work is not deployed yet. +QA operator access. 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 diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 65f86420..aad24763 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,21 @@ # Dune implementation status +## 22 September 2026 — Administrative archive/unarchive prototype + +[Archive or unarchive](ADMIN_ROOM_ARCHIVE.md) organizes closed rooms without +changing their saved state, timestamps, credentials, discussion or AI pacing. +Separate directory filters compose with removal; unarchive leaves play closed, +reopen rejects archives, and removal/restoration retains both flags. Live +operator authority and room/archive version fences commit with durable audit +receipts. Tab-scoped exact retries remain confirmable across refresh and later +availability changes. + +Independent read-only review found no material issues. Focused database/client +checks and twenty dedicated administrator HTTP groups passed. Browser acceptance, +source-bound broad checks and preservation evidence accompany the checkpoint. +Authenticated production acceptance awaits authorized QA access. Full admin and +rules gates remain unfinished. + ## 22 September 2026 — Administrative close/reopen prototype [Close or reopen](ADMIN_ROOM_CLOSURE.md) now separates administrative availability diff --git a/drizzle/0014_admin_room_archive.sql b/drizzle/0014_admin_room_archive.sql new file mode 100644 index 00000000..97c726e5 --- /dev/null +++ b/drizzle/0014_admin_room_archive.sql @@ -0,0 +1,28 @@ +CREATE TABLE `admin_room_archives` ( + `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, + `archived` 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_archives_room` ON `admin_room_archives` (`room_code`,`created_at`);--> statement-breakpoint +CREATE TABLE `room_archives` ( + `room_code` text PRIMARY KEY NOT NULL, + `archived` integer DEFAULT 0 NOT NULL, + `revision` integer DEFAULT 0 NOT NULL, + `archived_at` integer, + `updated_at` integer NOT NULL, + FOREIGN KEY (`room_code`) REFERENCES `rooms`(`code`) ON UPDATE no action ON DELETE cascade, + CONSTRAINT "room_archives_archived" CHECK("room_archives"."archived" IN (0, 1)), + CONSTRAINT "room_archives_revision" CHECK("room_archives"."revision" >= 0), + CONSTRAINT "room_archives_time" CHECK(("room_archives"."archived" = 1 AND "room_archives"."archived_at" IS NOT NULL) OR ("room_archives"."archived" = 0 AND "room_archives"."archived_at" IS NULL)) +); diff --git a/drizzle/meta/0014_snapshot.json b/drizzle/meta/0014_snapshot.json new file mode 100644 index 00000000..eb447af6 --- /dev/null +++ b/drizzle/meta/0014_snapshot.json @@ -0,0 +1,1891 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "92b4c430-f821-42c6-a220-e471e9cbc6e5", + "prevId": "1146c856-e30d-412c-b406-4f6ce66b9563", + "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_archives": { + "name": "admin_room_archives", + "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 + }, + "archived": { + "name": "archived", + "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_archives_room": { + "name": "admin_room_archives_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_closures": { + "name": "admin_room_closures", + "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 + }, + "closed": { + "name": "closed", + "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_closures_room": { + "name": "admin_room_closures_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_archives": { + "name": "room_archives", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "archived_at": { + "name": "archived_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_archives_room_code_rooms_code_fk": { + "name": "room_archives_room_code_rooms_code_fk", + "tableFrom": "room_archives", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "room_archives_archived": { + "name": "room_archives_archived", + "value": "\"room_archives\".\"archived\" IN (0, 1)" + }, + "room_archives_revision": { + "name": "room_archives_revision", + "value": "\"room_archives\".\"revision\" >= 0" + }, + "room_archives_time": { + "name": "room_archives_time", + "value": "(\"room_archives\".\"archived\" = 1 AND \"room_archives\".\"archived_at\" IS NOT NULL) OR (\"room_archives\".\"archived\" = 0 AND \"room_archives\".\"archived_at\" IS NULL)" + } + } + }, + "room_closures": { + "name": "room_closures", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "closed": { + "name": "closed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "closed_at": { + "name": "closed_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_closures_room_code_rooms_code_fk": { + "name": "room_closures_room_code_rooms_code_fk", + "tableFrom": "room_closures", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "room_closures_closed": { + "name": "room_closures_closed", + "value": "\"room_closures\".\"closed\" IN (0, 1)" + }, + "room_closures_revision": { + "name": "room_closures_revision", + "value": "\"room_closures\".\"revision\" >= 0" + }, + "room_closures_time": { + "name": "room_closures_time", + "value": "(\"room_closures\".\"closed\" = 1 AND \"room_closures\".\"closed_at\" IS NOT NULL) OR (\"room_closures\".\"closed\" = 0 AND \"room_closures\".\"closed_at\" IS NULL)" + } + } + }, + "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 f2cb310a..fa692499 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1790042692212, "tag": "0013_admin_room_closure", "breakpoints": true + }, + { + "idx": 14, + "version": "6", + "when": 1790044461950, + "tag": "0014_admin_room_archive", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/admin-archive-client.ts b/lib/admin-archive-client.ts new file mode 100644 index 00000000..df003cfe --- /dev/null +++ b/lib/admin-archive-client.ts @@ -0,0 +1,65 @@ +import { randomId } from './random-id'; +import { validAdminArchiveInput, type AdminArchiveInput, type AdminArchiveView, type AdminArchiveResult } from './admin-archive'; + +type Storage = Pick; +const key = (account: string, code: string) => `dune.admin-archive-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 validAdminArchiveView(value: unknown): value is AdminArchiveView { + return object(value) && keys(value, ['closed', 'removed', 'code', 'version', 'archived', 'revision', 'archivedAt', 'updatedAt', 'paused', 'joinLocked']) && + typeof value.code === 'string' && /^[A-Z2-9]{8}$/.test(value.code) && integer(value.version) && integer(value.revision) && + typeof value.closed === 'boolean' && typeof value.removed === 'boolean' && typeof value.archived === 'boolean' && typeof value.paused === 'boolean' && typeof value.joinLocked === 'boolean' && + timestamp(value.archivedAt) && timestamp(value.updatedAt); +} + +export function adminArchiveResponse(value: unknown, code: string): AdminArchiveView { + if (!validAdminArchiveView(value) || value.code !== code) throw new Error('The server returned unreadable room availability.'); + return value; +} + +export function adminArchiveConfirmation(value: unknown, code: string, input: AdminArchiveInput): AdminArchiveResult { + 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 archive or unarchive request.'); + const room = adminArchiveResponse(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 AdminArchiveResult; +} + +export function newAdminArchiveRequest(view: AdminArchiveView, archived: boolean, reason: string): AdminArchiveInput { + const input = { operationId: randomId(), expectedVersion: view.version, expectedRevision: view.revision, archived, reason: reason.trim() }; + if (!validAdminArchiveInput(input)) throw new Error('Enter a reason of 1–300 characters on one line.'); + if (view.removed) throw new Error('Restore this removed room before changing its archive.'); + if (!view.closed) throw new Error('Close this room before changing its archive state.'); + if (archived === view.archived) throw new Error('Refresh the room before choosing an archive or unarchive.'); + return input; +} + +export function readAdminArchiveRequest(storage: Storage, account: string, code: string): AdminArchiveInput | 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 (!validAdminArchiveInput(value)) throw new Error('This tab’s saved archive or unarchive 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 saveAdminArchiveRequest(storage: Storage, account: string, code: string, input: AdminArchiveInput) { + if (!validAdminArchiveInput(input)) throw new Error('Invalid archive or unarchive request.'); + const previous = readAdminArchiveRequest(storage, account, code), encoded = JSON.stringify(input); + if (previous && JSON.stringify(previous) !== encoded) throw new Error('Resolve the saved archive or unarchive request before sending a different change.'); + storage.setItem(key(account, code), encoded); + if (storage.getItem(key(account, code)) !== encoded) throw new Error('The archive or unarchive request could not be preserved in this tab. Nothing was sent.'); +} + +export function clearAdminArchiveRequest(storage: Storage, account: string, code: string) { + storage.removeItem(key(account, code)); + if (storage.getItem(key(account, code)) !== null) throw new Error('The saved archive or unarchive request could not be cleared.'); +} diff --git a/lib/admin-archive.ts b/lib/admin-archive.ts new file mode 100644 index 00000000..efc2d7bc --- /dev/null +++ b/lib/admin-archive.ts @@ -0,0 +1,64 @@ +export type AdminArchiveInput = { + operationId: string; + expectedVersion: number; + expectedRevision: number; + archived: boolean; + reason: string; +}; + +export type AdminArchiveView = { + closed: boolean; + removed: boolean; + code: string; + version: number; + archived: boolean; + revision: number; + archivedAt: number | null; + updatedAt: number | null; + paused: boolean; + joinLocked: boolean; +}; + +export type AdminArchiveResult = { + operationId: string; + replayed: boolean; + appliedRevision: number; + appliedVersion: number; + room: AdminArchiveView; +}; + +export function validAdminArchiveInput( + value: unknown, +): value is AdminArchiveInput { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const input = value as Record; + const fields = [ + 'operationId', + 'expectedVersion', + 'expectedRevision', + 'archived', + '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.archived === '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-closure-client.ts b/lib/admin-closure-client.ts index a512d9c7..24c3e0a0 100644 --- a/lib/admin-closure-client.ts +++ b/lib/admin-closure-client.ts @@ -9,7 +9,7 @@ const integer = (value: unknown): value is number => Number.isSafeInteger(value) const timestamp = (value: unknown) => value === null || integer(value) && value <= 8_640_000_000_000_000; export function validAdminClosureView(value: unknown): value is AdminClosureView { - return object(value) && keys(value, ['removed', 'code', 'version', 'closed', 'revision', 'closedAt', 'updatedAt', 'paused', 'joinLocked']) && + return object(value) && keys(value, ['removed', 'code', 'version', 'closed', 'revision', 'closedAt', 'updatedAt', 'paused', 'joinLocked', ...(value.archived === true ? ['archived'] : [])]) && typeof value.code === 'string' && /^[A-Z2-9]{8}$/.test(value.code) && integer(value.version) && integer(value.revision) && typeof value.removed === 'boolean' && typeof value.closed === 'boolean' && typeof value.paused === 'boolean' && typeof value.joinLocked === 'boolean' && timestamp(value.closedAt) && timestamp(value.updatedAt); @@ -36,6 +36,7 @@ export function newAdminClosureRequest(view: AdminClosureView, closed: boolean, const input = { operationId: randomId(), expectedVersion: view.version, expectedRevision: view.revision, closed, reason: reason.trim() }; if (!validAdminClosureInput(input)) throw new Error('Enter a reason of 1–300 characters on one line.'); if (view.removed) throw new Error('Restore this removed room before changing its closure.'); + if (view.archived) throw new Error('Unarchive this room before reopening it.'); if (closed === view.closed) throw new Error('Refresh the room before choosing a closure or reopening.'); return input; } diff --git a/lib/admin-closure.ts b/lib/admin-closure.ts index 96c17a46..885a2665 100644 --- a/lib/admin-closure.ts +++ b/lib/admin-closure.ts @@ -7,6 +7,7 @@ export type AdminClosureInput = { }; export type AdminClosureView = { + archived?: true; removed: boolean; code: string; version: number; diff --git a/lib/admin-directory.ts b/lib/admin-directory.ts index b457398b..6648a5fa 100644 --- a/lib/admin-directory.ts +++ b/lib/admin-directory.ts @@ -4,6 +4,7 @@ export type AdminRoom = { code: string; version: number; removed: boolean; + archived: 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 index 06bf5bc6..ba6ae15c 100644 --- a/lib/admin-removal-client.ts +++ b/lib/admin-removal-client.ts @@ -9,7 +9,7 @@ const integer = (value: unknown): value is number => Number.isSafeInteger(value) const timestamp = (value: unknown) => value === null || integer(value) && value <= 8_640_000_000_000_000; export function validAdminRemovalView(value: unknown): value is AdminRemovalView { - return object(value) && keys(value, ['code', 'version', 'removed', 'revision', 'removedAt', 'updatedAt', 'paused', 'joinLocked', ...(value.closed === true ? ['closed'] : [])]) && + return object(value) && keys(value, ['code', 'version', 'removed', 'revision', 'removedAt', 'updatedAt', 'paused', 'joinLocked', ...(value.closed === true ? ['closed'] : []), ...(value.archived === true ? ['archived'] : [])]) && 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); diff --git a/lib/admin-removal.ts b/lib/admin-removal.ts index e074ffe1..b006a357 100644 --- a/lib/admin-removal.ts +++ b/lib/admin-removal.ts @@ -7,6 +7,7 @@ export type AdminRemovalInput = { }; export type AdminRemovalView = { + archived?: true; closed?: true; code: string; version: number; diff --git a/tests/admin-archive-client.test.ts b/tests/admin-archive-client.test.ts new file mode 100644 index 00000000..0ed1db60 --- /dev/null +++ b/tests/admin-archive-client.test.ts @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { adminArchiveConfirmation, adminArchiveResponse, clearAdminArchiveRequest, newAdminArchiveRequest, readAdminArchiveRequest, saveAdminArchiveRequest } from '../lib/admin-archive-client'; +import { retainAdminRoomRequest } from '../lib/admin-room-control-client'; +import { ClientRequestError } from '../lib/client-request'; +import type { AdminArchiveView } from '../lib/admin-archive'; + +const view = (): AdminArchiveView => ({ code: 'ABCD2345', version: 7, archived: false, closed: true, removed: false, revision: 2, archivedAt: 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('archive retry preserves exact intent/version/revision/reason across refresh and isolates account and room', () => { + const tab = storage(), room = view(), input = newAdminArchiveRequest(room, true, ' QA archive '); + saveAdminArchiveRequest(tab, 'a', room.code, input); + assert.deepEqual(readAdminArchiveRequest(tab, 'a', room.code), input); + assert.equal(input.expectedVersion, 7); assert.equal(input.expectedRevision, 2); assert.equal(input.reason, 'QA archive'); + assert.equal(readAdminArchiveRequest(tab, 'b', room.code), null); + assert.equal(readAdminArchiveRequest(tab, 'a', 'DIFF2345'), null); + for (const change of [{ operationId: crypto.randomUUID() }, { expectedVersion: 8 }, { expectedRevision: 3 }, { reason: 'Different' }, { archived: false }]) + assert.throws(() => saveAdminArchiveRequest(tab, 'a', room.code, { ...input, ...change }), /Resolve the saved/); + saveAdminArchiveRequest(tab, 'a', room.code, input); + clearAdminArchiveRequest(tab, 'a', room.code); + assert.equal(readAdminArchiveRequest(tab, 'a', room.code), null); + const reopen = newAdminArchiveRequest({ ...room, archived: true, version: 8, revision: 3 }, false, 'Restore'); + saveAdminArchiveRequest(tab, 'a', room.code, reopen); + assert.deepEqual(readAdminArchiveRequest(tab, 'a', room.code), reopen); +}); + +void test('corrupt or unwritable archive retry storage fails closed and never replaces the saved record', () => { + const tab = storage(), room = view(), input = newAdminArchiveRequest(room, true, 'QA'); + assert.throws(() => saveAdminArchiveRequest({ ...tab, setItem() {} }, 'a', room.code, input), /Nothing was sent/); + saveAdminArchiveRequest(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(() => readAdminArchiveRequest(tab, 'a', room.code), /unreadable/); + assert.throws(() => saveAdminArchiveRequest(tab, 'a', room.code, input), /unreadable/); + assert.equal(tab.values.get(key), raw); + } + assert.throws(() => clearAdminArchiveRequest({ ...tab, removeItem() {} }, 'a', room.code), /could not be cleared/); + clearAdminArchiveRequest(tab, 'a', room.code); + for (const reason of [' ', 'a'.repeat(301), 'two\nlines', 'bad\u007freason']) + assert.throws(() => newAdminArchiveRequest(room, true, reason)); + assert.throws(() => newAdminArchiveRequest(room, false, 'No change')); + assert.throws(() => newAdminArchiveRequest({ ...room, removed: true }, true, 'Removed'), /Restore/); + assert.throws(() => newAdminArchiveRequest({ ...room, closed: false }, true, 'Open'), /Close/); + assert.throws(() => newAdminArchiveRequest({ ...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 = newAdminArchiveRequest(room, true, 'QA'); + const current = { ...room, version: 12, revision: 4, archived: false, closed: true, removed: false, archivedAt: 100, updatedAt: 200, paused: true }; + const receipt = { operationId: input.operationId, appliedVersion: 8, appliedRevision: 3, replayed: true, room: current }; + assert.deepEqual(adminArchiveConfirmation(receipt, room.code, input), receipt); + const fresh = { ...receipt, replayed: false, room: { ...room, version: 8, revision: 3, archived: true, archivedAt: 100, updatedAt: 100 } }; + assert.deepEqual(adminArchiveConfirmation(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(() => adminArchiveConfirmation(invalid, room.code, input)); +}); + +void test('room availability projection excludes private data and rejects malformed metadata', () => { + const room = view(); + assert.deepEqual(adminArchiveResponse(room, room.code), room); + assert.doesNotThrow(() => adminArchiveResponse({ ...room, archived: true, archivedAt: 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, archived: 'true' }, + { ...room, archivedAt: -1 }, { ...room, archivedAt: Number.MAX_SAFE_INTEGER }, { ...room, updatedAt: '100' }, + { ...room, closed: undefined }, { ...room, removed: 1 }, { ...room, paused: undefined }, { ...room, joinLocked: null }]) + assert.throws(() => adminArchiveResponse(invalid, room.code)); +}); + +void test('uncertain archive and changed-access responses keep the exact saved request', () => { + const tab = storage(), room = view(), input = newAdminArchiveRequest(room, true, 'QA'); + saveAdminArchiveRequest(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)) clearAdminArchiveRequest(tab, 'a', room.code); + assert.deepEqual(readAdminArchiveRequest(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-archive.test.ts b/tests/admin-archive.test.ts new file mode 100644 index 00000000..e20a2d3d --- /dev/null +++ b/tests/admin-archive.test.ts @@ -0,0 +1,737 @@ +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 { applyAdminArchive, readAdminArchive } from '../db/admin-archive'; +import { + validAdminArchiveInput, + type AdminArchiveInput, +} from '../lib/admin-archive'; +import { applyAdminRemoval, readAdminRemoval } from '../db/admin-removal'; +import { applyAdminClosure, readAdminClosure } from '../db/admin-closure'; +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 = [ + 'room_closures', + '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, + archived = true, +): AdminArchiveInput => ({ + operationId: randomUUID(), + expectedVersion, + expectedRevision, + archived, + reason: 'Dedicated QA room archive', +}); +function seed(store: Store, code = 'ARCHIVES') { + 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); + store.sqlite.prepare('INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES (?,1,1,100,100)').run(code); + 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 = () => readAdminArchive(store.database, identity, room.code, now); + const apply = (input = change(version()), at = now) => + applyAdminArchive(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('archive 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 < '0014') + .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/0014_admin_room_archive.sql', import.meta.url), + 'utf8', + ), + ); + assert.deepEqual( + tables.map((table) => rows(store, table)), + before, + ); + assert.deepEqual(rows(store, 'room_archives'), []); + assert.deepEqual(rows(store, 'admin_room_archives'), []); + assert.throws( + () => + store.sqlite + .prepare( + 'INSERT INTO room_archives(room_code,archived,revision,updated_at) VALUES(?,1,0,?)', + ) + .run(room.code, now), + /CHECK/, + ); + } finally { + store.sqlite.close(); + } +}); + +void test('archive inputs require exact bounded fields and both safe version counters', () => { + const valid = change(); + assert.equal(validAdminArchiveInput(valid), true); + for (const invalid of [ + null, + [], + {}, + { ...valid, extra: true }, + { ...valid, archived: 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({ archived: true }), { + operationId: valid.operationId, + expectedVersion: 0, + expectedRevision: 0, + reason: 'Reason', + other: true, + }), + ]) + assert.equal(validAdminArchiveInput(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, + archived: false, + closed: true, + removed: false, + revision: 0, + archivedAt: null, + updatedAt: null, + paused: false, + joinLocked: false, + }); + assert.deepEqual(f.row(), before); + assert.deepEqual(rows(f, 'room_archives'), []); + if (role === 'viewer') await assert.rejects(f.apply(), status(403)); + else assert.equal((await f.apply()).room.archived, true); + await assert.rejects( + readAdminArchive(f.database, f.identity, f.code + '\n', now), + status(400), + ); + await assert.rejects( + readAdminArchive(f.database, f.identity, 'ABSENTAA', now), + status(404), + ); + await assert.rejects( + readAdminArchive( + f.database, + { ...f.identity, sessionHash: f.tokenHash }, + f.code, + now, + ), + status(401), + ); + } finally { + f.sqlite.close(); + } + } +}); + +void test('archive and unarchive 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, + archived: true, + closed: true, + removed: false, + revision: 1, + archivedAt: now, + updatedAt: now, + paused: false, + joinLocked: false, + }); + assert.equal(f.row().state, old.state); + assert.equal(f.row().updated_at, old.updated_at); + const reopend = await f.apply(change(1, 1, false), now + 5000); + assert.deepEqual(reopend.room, { + ...result.room, + version: 2, + archived: false, + revision: 2, + archivedAt: null, + updatedAt: now + 5000, + }); + assert.equal(f.row().state, old.state); + assert.equal(f.row().updated_at, old.updated_at); + assert.deepEqual(secureRows(f), secure); + assert.deepEqual( + f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(other.code), + untouched, + ); + assert.equal(rows(f, 'room_archives').length, 1); + const audit = rows(f, 'admin_room_archives'); + 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('archiving and unarchiving 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()).archived, true); + await f.apply(change(1, 1, false)); + assert.equal(f.row().state, state); + } finally { + f.sqlite.close(); + } + } +}); + +void test('exact receipts bind actor, room and normalized intent and report current state after later transitions', async () => { + const f = await fixture(); + try { + const input = { ...change(), reason: ' Exact QA reason ' }; + const first = await f.apply(input); + const reopen = await f.apply(change(1, 1, false), now + 1); + const replay = await f.apply( + { ...input, reason: 'Exact QA reason' }, + now + 2, + ); + assert.deepEqual(replay, { ...first, replayed: true, room: reopen.room }); + const before = f.row(); + for (const mismatch of [ + { ...input, expectedVersion: 1 }, + { ...input, expectedRevision: 1 }, + { ...input, archived: 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( + applyAdminArchive(f.database, identity, f.code, input, now), + status(409), + ); + seed(f, 'KEEPSAFE'); + await assert.rejects( + applyAdminArchive(f.database, f.identity, 'KEEPSAFE', input, now), + status(409), + ); + assert.deepEqual(f.row(), before); + assert.equal(rows(f, 'admin_room_archives').length, 2); + } finally { + f.sqlite.close(); + } +}); + +void test('new no-op and stale requests reject without writes; both room version and archive 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_archives'), []); + 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_archives').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_archives'); + 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_archives'), 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_archives').length, 1); + } finally { + f.sqlite.close(); + } + } +}); + +void test('audit, game fence and archive metadata roll back together at every failed persistence stage', async () => { + for (const table of ['admin_room_archives', 'rooms', 'room_archives']) { + 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 archive failure'); END;`, + ); + await assert.rejects(f.apply(), /QA archive failure/); + assert.deepEqual(f.row(), before); + assert.deepEqual(secureRows(f), secure); + assert.deepEqual(rows(f, 'admin_room_archives'), []); + assert.deepEqual(rows(f, 'room_archives'), []); + } finally { + f.sqlite.close(); + } + } +}); + +void test('archived games and exact receipts survive database reopen without restoring historical credentials', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dune-admin-archive-')); + 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 archived = await applyAdminArchive( + store.database, + identity, + room.code, + input, + now, + ); + store.sqlite.close(); + store = adminStore(join(dir, 'rooms.sqlite'), false); + assert.deepEqual( + await applyAdminArchive(store.database, identity, room.code, input, now), + { ...archived, replayed: true }, + ); + await applyAdminArchive( + 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_archives'); + store.sqlite.prepare('DELETE FROM rooms WHERE code=?').run(room.code); + assert.deepEqual(rows(store, 'admin_room_archives'), audit); + } finally { + store.sqlite.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +void test('archive requires closure; unarchive leaves closure and reopening requires unarchive first', async () => { + const f = await fixture(); + try { + const native = await readAdminClosure(f.database, f.identity, f.code, now); + const reopen = { operationId: randomUUID(), expectedVersion: f.version(), expectedRevision: native.revision, closed: false, reason: 'QA reopening' }; + const oldState = f.row().state; + await applyAdminClosure(f.database, f.identity, f.code, reopen, now); + const open = f.row(); + await assert.rejects(f.apply(change(f.version())), /Close this room/); + assert.deepEqual(f.row(), open); + const close = { ...reopen, operationId: randomUUID(), expectedVersion: f.version(), expectedRevision: 2, closed: true }; + await applyAdminClosure(f.database, f.identity, f.code, close, now); + const input = change(f.version()); + await f.apply(input); + const archived = f.row(); + assert.equal((await readAdminClosure(f.database, f.identity, f.code, now)).archived, true); + await assert.rejects(applyAdminClosure(f.database, f.identity, f.code, { ...reopen, operationId: randomUUID(), expectedVersion: f.version(), expectedRevision: 3 }, now), /Unarchive/); + assert.deepEqual(f.row(), archived); + const oldClosureReceipt = await applyAdminClosure(f.database, f.identity, f.code, close, now); + assert.equal(oldClosureReceipt.replayed, true); + assert.equal(oldClosureReceipt.room.archived, true); + const unarchive = change(f.version(), 1, false); + await f.apply(unarchive); + assert.equal((await f.read()).closed, true); + assert.equal((await readAdminClosure(f.database, f.identity, f.code, now)).archived, undefined); + await applyAdminClosure(f.database, f.identity, f.code, { ...reopen, operationId: randomUUID(), expectedVersion: f.version(), expectedRevision: 3 }, now); + assert.equal((await f.read()).closed, false); + assert.equal(f.row().state, oldState); + const after = f.row(); + const receipt = await f.apply(input); + assert.equal(receipt.replayed, true); assert.equal(receipt.room.closed, false); assert.equal(receipt.room.archived, false); + assert.deepEqual(f.row(), after); + } finally { f.sqlite.close(); } +}); + +void test('removal and restoration preserve archived closure and every saved AI deadline and credential', 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); + f.sqlite.prepare('INSERT INTO room_controls(room_code,paused,join_locked,revision,updated_at) VALUES (?,?,1,1,100)').run(f.code, Number(paused)); + seedDependents(f, f); + const original = f.row(), secure = secureRows(f), controls = rows(f, 'room_controls'); + const input = change(); + await f.apply(input); + const remove = { operationId: randomUUID(), expectedVersion: f.version(), expectedRevision: 0, removed: true, reason: 'QA removal' }; + await applyAdminRemoval(f.database, f.identity, f.code, remove, now); + assert.equal((await readAdminRemoval(f.database, f.identity, f.code, now)).archived, true); + const removed = f.row(); + const receipt = await f.apply(input); assert.equal(receipt.replayed, true); assert.equal(receipt.room.removed, true); + await assert.rejects(f.apply(change(f.version(), 1, false)), /Restore/); + assert.deepEqual(f.row(), removed); + await applyAdminRemoval(f.database, f.identity, f.code, { ...remove, operationId: randomUUID(), expectedVersion: f.version(), expectedRevision: 1, removed: false }, now + 10000); + assert.equal((await f.read()).archived, true); assert.equal((await f.read()).closed, true); + const beforeUnarchive = f.row(); + await f.apply(change(f.version(), 1, false), now + 20000); + assert.equal(f.row().state, original.state); + assert.equal(f.row().updated_at, beforeUnarchive.updated_at); + assert.deepEqual(secureRows(f), secure); assert.deepEqual(rows(f, 'room_controls'), controls); + assert.equal((await f.read()).closed, true); + } finally { f.sqlite.close(); } + } +}); + +void test('reopening and archiving cannot both win a transaction race, in either order', async () => { + for (const archiveFirst of [false, true]) { + const f = await fixture(); + try { + const before = f.row().state; + const archive = () => f.apply(change()); + const reopen = () => applyAdminClosure(f.database, f.identity, f.code, { operationId: randomUUID(), expectedVersion: 0, expectedRevision: 1, closed: false, reason: 'QA reopen race' }, now); + const gate = gateBatch(f); + const pending = (archiveFirst ? reopen() : archive()).then(() => null, (error: unknown) => error); + await gate.waiting; + await (archiveFirst ? archive() : reopen()); gate.release(); + assert.ok(status(409)(await pending)); + const current = await f.read(); assert.equal(current.archived, archiveFirst); assert.equal(current.closed, archiveFirst); + assert.equal(f.row().state, before); assert.equal(f.version(), 1); + assert.equal(rows(f, 'admin_room_archives').length + rows(f, 'admin_room_closures').length, 1); + } finally { f.sqlite.close(); } + } +}); + +void test('removal fences a queued archive or unarchive without erasing a prior exact receipt', async () => { + for (const unarchive of [false, true]) { + const f = await fixture(); + try { + const original = change(); + if (unarchive) await f.apply(original); + const gate = gateBatch(f); + const pending = f.apply(change(f.version(), unarchive ? 1 : 0, !unarchive)).then(() => null, (error: unknown) => error); + await gate.waiting; + await applyAdminRemoval(f.database, f.identity, f.code, { operationId: randomUUID(), expectedVersion: f.version(), expectedRevision: 0, removed: true, reason: 'QA remove race' }, now); + const removed = f.row(); gate.release(); + assert.ok(status(409)(await pending)); assert.deepEqual(f.row(), removed); + assert.equal((await f.read()).archived, unarchive); + if (unarchive) assert.equal((await f.apply(original)).replayed, true); + } finally { f.sqlite.close(); } + } +}); diff --git a/tests/admin-boundary-http.test.ts b/tests/admin-boundary-http.test.ts index 8fa88acf..a1445832 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`, `/api/admin/rooms/${room}/removal`, `/api/admin/rooms/${room}/closure`]) { + for (const path of ['/api/admin/session', '/api/admin/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`, `/api/admin/rooms/${room}/removal`, `/api/admin/rooms/${room}/closure`, `/api/admin/rooms/${room}/archive`]) { 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'); @@ -50,6 +50,13 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden assert.equal(closure.status, 401); assert.equal(closure.headers.get('set-cookie'), null); assert.deepEqual(Object.keys(await closure.json()), ['error']); + const archive = await fetch(`${base}/api/admin/rooms/${room}/archive`, { + method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' }, + body: JSON.stringify({ operationId: crypto.randomUUID(), expectedVersion: 0, expectedRevision: 0, archived: true, reason: 'Denied archive QA' }), + }); + assert.equal(archive.status, 401); + assert.equal(archive.headers.get('set-cookie'), null); + assert.deepEqual(Object.keys(await archive.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-closure-client.test.ts b/tests/admin-closure-client.test.ts index 65e60147..fc3d0d2a 100644 --- a/tests/admin-closure-client.test.ts +++ b/tests/admin-closure-client.test.ts @@ -82,3 +82,13 @@ void test('uncertain closure and changed-access responses keep the exact saved r } for (const status of [400, 404, 409, 422]) assert.equal(retainAdminRoomRequest(new ClientRequestError('Rejected', 'http', status)), false); }); + +void test('closure projections and exact receipts retain only the explicit archived flag', () => { + const room = view(), input = newAdminClosureRequest(room, true, 'QA'); + assert.deepEqual(adminClosureResponse({ ...room, archived: true }, room.code), { ...room, archived: true }); + for (const archived of [false, undefined, null, 1, 'true']) + assert.throws(() => adminClosureResponse({ ...room, archived }, room.code)); + const receipt = { operationId: input.operationId, appliedVersion: 8, appliedRevision: 3, replayed: true, room: { ...room, version: 12, revision: 4, archived: true } }; + assert.deepEqual(adminClosureConfirmation(receipt, room.code, input), receipt); + assert.throws(() => newAdminClosureRequest({ ...room, closed: true, archived: true }, false, 'Reopen'), /Unarchive/); +}); diff --git a/tests/admin-directory.test.ts b/tests/admin-directory.test.ts index 47ab3194..e1dc232c 100644 --- a/tests/admin-directory.test.ts +++ b/tests/admin-directory.test.ts @@ -114,3 +114,35 @@ void test('closed rooms remain in the directory but are excluded from running ro assert.deepEqual((await read('availability=running')).rooms.map(room => room.code), ['OPENROOM']); } finally { f.sqlite.close(); } }); + +void test('archive filters compose with removal, availability, private-safe search and stable pagination', async () => { + const f = await fixture(); + try { + insert(f.sqlite, 'OPENROOM', state('Open QA')); + for (let n = 0; n < 28; n++) { + const code = `AR${String(n).padStart(6, '0')}`; + insert(f.sqlite, code, state(n === 0 ? 'Archived %_ QA' : 'Archived QA')); + f.sqlite.prepare('INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES (?,1,1,100,100)').run(code); + f.sqlite.prepare('INSERT INTO room_archives(room_code,archived,revision,archived_at,updated_at) VALUES (?,1,1,100,100)').run(code); + if (n === 27) f.sqlite.prepare('INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES (?,1,1,100,100)').run(code); + } + const read = (q = '') => readAdminDirectory(f.database, f.identity, new URLSearchParams(q)); + assert.deepEqual((await read()).rooms.map(room => room.code), ['OPENROOM']); + assert.equal((await read('q=Archived')).total, 0); + const first = await read('archive=archived'), second = await read('archive=archived&page=2'); + assert.equal(first.total, 27); assert.equal(first.rooms.length, 25); assert.equal(second.rooms.length, 2); + assert.equal(new Set([...first.rooms, ...second.rooms].map(room => room.code)).size, 27); + assert.ok(first.rooms.every(room => room.archived && room.control.closed && !room.removed)); + assert.equal(JSON.stringify(first).includes('SECRET'), false); + assert.equal((await read('archive=all')).total, 28); + assert.equal((await read('removal=all')).total, 1); + assert.equal((await read('removal=removed')).total, 0); + assert.equal((await read('archive=all&removal=all')).total, 29); + assert.equal((await read('archive=archived&removal=removed')).total, 1); + assert.equal((await read('archive=archived&availability=closed')).total, 27); + assert.equal((await read('archive=all&availability=running')).total, 1); + assert.equal((await read('archive=archived&q=%25_')).total, 1); + assert.equal((await read('archive=all&q=SECRET')).total, 0); + for (const q of ['archive=unknown', 'archive=all&archive=archived']) await assert.rejects(read(q), (e: unknown) => e instanceof AdminError && e.status === 400); + } finally { f.sqlite.close(); } +}); diff --git a/tests/admin-removal-client.test.ts b/tests/admin-removal-client.test.ts index 647d9cc6..1059e5d9 100644 --- a/tests/admin-removal-client.test.ts +++ b/tests/admin-removal-client.test.ts @@ -81,3 +81,12 @@ void test('uncertain removal and changed-access responses keep the exact saved r } for (const status of [400, 404, 409, 422]) assert.equal(retainAdminRoomRequest(new ClientRequestError('Rejected', 'http', status)), false); }); + +void test('removal projections and exact receipts retain only the explicit archived flag', () => { + const room = view(), input = newAdminRemovalRequest(room, true, 'QA'); + assert.deepEqual(adminRemovalResponse({ ...room, archived: true }, room.code), { ...room, archived: true }); + for (const archived of [false, undefined, null, 1, 'true']) + assert.throws(() => adminRemovalResponse({ ...room, archived }, room.code)); + const receipt = { operationId: input.operationId, appliedVersion: 8, appliedRevision: 3, replayed: true, room: { ...room, version: 12, revision: 4, archived: true } }; + assert.deepEqual(adminRemovalConfirmation(receipt, room.code, input), receipt); +}); diff --git a/tools/admin-archive-verification.mjs b/tools/admin-archive-verification.mjs new file mode 100644 index 00000000..c1528a2c --- /dev/null +++ b/tools/admin-archive-verification.mjs @@ -0,0 +1,88 @@ +// Mutations are confined to the new QA room; archiving never edits saved game content. +import assert from 'node:assert/strict'; +import { randomBytes, randomUUID } from 'node:crypto'; + +export async function verifyRoomArchive(request, adminCookie, report) { + let code, path, adminPath, failure; + async function transition(kind, flag, value, saved) { + const current = await request(adminPath + '/' + kind, undefined, adminCookie); assert.equal(current.status, 200); + const input = saved ?? { operationId: randomUUID(), expectedVersion: current.data.version, expectedRevision: current.data.revision, [flag]: value, reason: 'Dedicated archive acceptance' }; + const response = await request(adminPath + '/' + kind, input, adminCookie); + assert.equal(response.status, 200); assert.equal(response.rawCookie, null); + assert.equal(response.data.operationId, input.operationId); + assert.equal(response.data.appliedVersion, input.expectedVersion + 1); assert.equal(response.data.appliedRevision, input.expectedRevision + 1); + return { ...response.data, input }; + } + try { + const creation = { operationId: randomUUID(), sessionToken: randomBytes(32).toString('hex'), name: 'Archive QA ' + Date.now(), faction: 'atreides', advanced: false, + techTokens: false, strongholdCards: false, bots: [], reason: 'Dedicated archive acceptance' }; + const made = await request('/api/admin/rooms', creation, adminCookie); assert.equal(made.status, 200); + code = made.data.code; report.archiveRoom = code; path = '/api/rooms/' + code; adminPath = '/api/admin/rooms/' + code; + const host = made.cookie; + const joined = await request(path, { type: 'join', name: 'Archive participant QA', faction: 'emperor' }); assert.equal(joined.status, 200); + for (const cookie of [host, joined.cookie]) { + 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); + const message = { id: randomUUID(), recipientId: null, text: 'Archive continuity QA' }; + assert.equal((await request(path + '/messages', message, host)).status, 200); + const initial = await request(adminPath + '/archive', undefined, adminCookie); assert.equal(initial.status, 200); + const early = { operationId: randomUUID(), expectedVersion: initial.data.version, expectedRevision: initial.data.revision, archived: true, reason: 'Reject archive of open room' }; + assert.equal((await request(adminPath + '/archive', early, adminCookie)).status, 409); + const close = await transition('closure', 'closed', true); + const before = (await request(path, undefined, host)).data, history = (await request(path + '/messages', undefined, host)).data; + const input = { ...early, operationId: randomUUID(), expectedVersion: before.version }; + for (const denied of [await request(adminPath + '/archive', input, host), await request(adminPath + '/archive', input, adminCookie, 'https://foreign.invalid'), await request(adminPath + '/archive', input, adminCookie, undefined, randomUUID())]) { + assert.ok([401, 403].includes(denied.status)); assert.equal(denied.rawCookie, null); + } + const archived = await transition('archive', 'archived', true, input); assert.equal(archived.room.archived, true); assert.equal(archived.room.closed, true); + assert.equal((await transition('archive', 'archived', true, input)).replayed, true); + assert.equal((await request('/api/admin/rooms?q=' + code, undefined, adminCookie)).data.total, 0); + const directory = await request('/api/admin/rooms?archive=archived&q=' + code, undefined, adminCookie); + assert.equal(directory.status, 200); assert.equal(directory.data.total, 1); assert.equal(directory.data.rooms[0].archived, true); + const view = await request(path, undefined, host); assert.equal(view.status, 200); + assert.deepEqual({ ...view.data, version: before.version }, before); + assert.equal((await request(path, undefined, joined.cookie)).data.me, joined.data.me); + assert.deepEqual((await request(path + '/messages', undefined, host)).data, history); + assert.equal((await request(path + '/messages', message, host)).data.replayed, true); + report.checks.push('Archive requires a closed room and live administrator authority; exact replay, archive filters and private saved table/history remain intact'); + const closure = await request(adminPath + '/closure', undefined, adminCookie); + assert.equal(closure.data.archived, true); + assert.equal((await request(adminPath + '/closure', { ...close.input, operationId: randomUUID(), expectedVersion: view.data.version, expectedRevision: closure.data.revision, closed: false }, adminCookie)).status, 409); + const removed = await transition('removal', 'removed', true); assert.equal(removed.room.archived, true); assert.equal(removed.room.closed, true); + assert.equal((await request('/api/admin/rooms?removal=all&q=' + code, undefined, adminCookie)).data.total, 0); + assert.equal((await request('/api/admin/rooms?removal=all&archive=all&q=' + code, undefined, adminCookie)).data.total, 1); + assert.equal((await transition('archive', 'archived', true, input)).replayed, true); + const unavailable = await request(adminPath + '/archive', undefined, adminCookie); + assert.equal((await request(adminPath + '/archive', { ...input, operationId: randomUUID(), expectedVersion: unavailable.data.version, expectedRevision: unavailable.data.revision, archived: false }, adminCookie)).status, 409); + const restored = await transition('removal', 'removed', false); assert.equal(restored.room.archived, true); assert.equal(restored.room.closed, true); + assert.deepEqual({ ...(await request(path, undefined, host)).data, version: before.version }, before); + report.checks.push('Archived rooms cannot reopen; removal and restoration retain archive/closure, with explicit combined filters and confirmable prior archive receipts'); + const unarchived = await transition('archive', 'archived', false); assert.equal(unarchived.room.archived, false); assert.equal(unarchived.room.closed, true); + assert.equal((await request('/api/admin/rooms?q=' + code, undefined, adminCookie)).data.total, 1); + assert.deepEqual({ ...(await request(path, undefined, host)).data, version: before.version }, before); + const reopened = await transition('closure', 'closed', false); assert.equal(reopened.room.closed, false); + const after = (await request(path, undefined, host)).data; + const replay = await transition('archive', 'archived', true, input); assert.equal(replay.replayed, true); assert.equal(replay.room.closed, false); assert.equal(replay.room.archived, false); + assert.deepEqual((await request(path, undefined, host)).data, after); + assert.deepEqual((await request(path + '/messages', undefined, host)).data, history); + report.checks.push('Unarchive returns the directory entry but leaves play closed; separate reopening restores the same table and old archive receipts cannot rearchive it'); + } catch (error) { + report.failureRequest ??= report.lastRequest; + report.archiveFailure = 'Archive acceptance failed; the dedicated QA room is retained.'; + report.archiveFailureLocation = error instanceof Error ? error.stack?.split('\n').find(line => line.trim().startsWith('at ') && line.includes('admin-archive-verification.mjs'))?.trim() : undefined; + failure = error; + } finally { + if (code) try { + const removed = await request(adminPath + '/removal', undefined, adminCookie); assert.equal(removed.status, 200); + if (removed.data.removed) await transition('removal', 'removed', false); + const archive = await request(adminPath + '/archive', undefined, adminCookie); assert.equal(archive.status, 200); + if (archive.data.archived) await transition('archive', 'archived', false); + const closed = await request(adminPath + '/closure', undefined, adminCookie); assert.equal(closed.status, 200); + if (closed.data.closed) await transition('closure', 'closed', false); + } catch (error) { report.archiveCleanupFailure = { message: 'Dedicated QA unarchive/reopening 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 9899d5c2..016055e3 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 { verifyRoomArchive } from './admin-archive-verification.mjs'; import { verifyRoomClosure } from './admin-closure-verification.mjs'; import { verifyRoomRemoval } from './admin-removal-verification.mjs'; @@ -34,7 +35,7 @@ async function request(path, body, cookie, origin = base, expectedAdminId = admi const response = await fetch(base + path, { method: body === undefined ? 'GET' : 'POST', headers: { ...(body === undefined ? {} : { 'content-type': 'application/json', origin }), ...(cookie ? { cookie } : {}), - ...(expectedAdminId && (path === '/api/admin/rooms' && body !== undefined || /^\/api\/admin\/rooms\/[^/]+\/(control|lobby|removal|closure)$/.test(path)) ? { 'X-Dune-Admin-Id': expectedAdminId } : {}) }, + ...(expectedAdminId && (path === '/api/admin/rooms' && body !== undefined || /^\/api\/admin\/rooms\/[^/]+\/(control|lobby|removal|closure|archive)$/.test(path)) ? { 'X-Dune-Admin-Id': expectedAdminId } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15000), }); report.lastRequest = { path, status: response.status }; @@ -91,6 +92,7 @@ try { await verifyLobbyConfiguration(request, session1, report); await verifyRoomRemoval(request, session1, report); await verifyRoomClosure(request, session1, report); + await verifyRoomArchive(request, session1, report); } report.passed = true; } catch (error) {