From 160fdc1f30f7b5f76b23e52d81b69fe22f3c9b0d Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:30:52 +1000 Subject: [PATCH] Prototype administrative discussion mute controls Let owners/operators mute or unmute new public/private sends from a human seat while retaining history, incoming messages, gameplay and credentials. Fence metadata and durable audit writes with live authority, membership and versions. Preserve exact admin/message retries and inherit restrictions through seat recovery. Validation: types/lint, 5431 offline tests, build, 52 HTTP integration tests, 24 administrator HTTP groups, independent SQL/privacy/UI review and desktop/mobile refresh acceptance. All 2230 prior games and 4746 seats preserved. Additive migration 0016. Verified runtime fingerprint 588967fbb9c2e3b6abe2990c4fce96ee69e2256fb7e3ee416314b87593cebba9; documentation-only grammar/button-label review gives b5e57bdd26812debede91c588e38f247f1fa895ad23e7ae30bc8550eedcd90ec. New-revision deployed acceptance remains open; full admin/rules scope is unfinished. --- README.md | 6 +- app/admin/page.tsx | 13 +- .../admin/rooms/[code]/discussion/route.ts | 29 + components/admin-discussion.tsx | 153 ++ components/table-talk.tsx | 21 +- db/admin-discussion.ts | 97 + db/schema.ts | 26 + db/table-talk.ts | 12 +- docs/ADMIN_DISCUSSION.md | 67 + docs/ADMIN_PANEL.md | 7 +- docs/CURRENT_STATUS.md | 20 +- docs/IMPLEMENTATION_STATUS.md | 16 + docs/TABLE_DISCUSSION.md | 10 + drizzle/0016_seat_discussion_controls.sql | 26 + drizzle/meta/0016_snapshot.json | 2161 +++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/admin-discussion-client.ts | 44 + lib/admin-discussion.ts | 35 + lib/table-talk.ts | 2 +- tests/admin-boundary-http.test.ts | 9 +- tests/admin-discussion-client.test.ts | 54 + tests/admin-discussion.test.ts | 118 + tests/table-talk-http.test.ts | 6 +- tests/table-talk-recovery.test.ts | 53 +- tools/admin-discussion-verification.mjs | 45 + tools/verify-admin.mjs | 4 +- 26 files changed, 3008 insertions(+), 33 deletions(-) create mode 100644 app/api/admin/rooms/[code]/discussion/route.ts create mode 100644 components/admin-discussion.tsx create mode 100644 db/admin-discussion.ts create mode 100644 docs/ADMIN_DISCUSSION.md create mode 100644 drizzle/0016_seat_discussion_controls.sql create mode 100644 drizzle/meta/0016_snapshot.json create mode 100644 lib/admin-discussion-client.ts create mode 100644 lib/admin-discussion.ts create mode 100644 tests/admin-discussion-client.test.ts create mode 100644 tests/admin-discussion.test.ts create mode 100644 tools/admin-discussion-verification.mjs diff --git a/README.md b/README.md index 13a73974..fa393717 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,10 @@ 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. [Participant AI](docs/ADMIN_PARTICIPANT_AI.md) supports an absent human in a paused -game while preserving their seat and takeback control. Permanent deletion, -further participant support and backup tools are still being built. +game while preserving their seat and takeback control. +[Discussion controls](docs/ADMIN_DISCUSSION.md) mute or unmute new messages from +a human seat while preserving history and gameplay. Permanent deletion, further +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 7c751b55..4fcc248a 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -11,6 +11,7 @@ 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 { AdminDiscussion } from '@/components/admin-discussion'; import { AdminSeatAi } from '@/components/admin-seat-ai'; import { AdminRoomArchive } from '@/components/admin-room-archive'; import { AdminRoomClosure } from '@/components/admin-room-closure'; @@ -32,6 +33,7 @@ export default function Administration() { const [filters, setFilters] = useState(initialFilters); const [applied, setApplied] = useState(initialFilters); const [confirmAll, setConfirmAll] = useState(false); + const [selectedDiscussion, setSelectedDiscussion] = useState(null); const [selectedSeatAi, setSelectedSeatAi] = useState(null); const [selectedRoom, setSelectedRoom] = useState(null); const [selectedLobby, setSelectedLobby] = useState(null); @@ -45,7 +47,7 @@ export default function Administration() { const clearSession = useCallback(() => { epoch.current++; setAccount(null); setDirectory(null); setConfirmAll(false); - setBusy(false); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setCreatingRoom(false); + setBusy(false); setSelectedDiscussion(null); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setCreatingRoom(false); }, []); const accessChanged = useCallback(() => { clearSession(); @@ -121,7 +123,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} @@ -148,6 +150,9 @@ export default function Administration() { + {selectedDiscussion && setSelectedDiscussion(null)} onDenied={accessChanged} + onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />} {selectedSeatAi && setSelectedSeatAi(null)} onDenied={accessChanged} onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />} @@ -188,7 +193,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, archive/unarchive closed rooms, and recoverably remove or restore rooms. Participant AI can support human seats in paused games. Permanent removal, further 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. Participant AI can support human seats in paused games; discussion controls mute or unmute new messages from a seat. Permanent removal, further participant support and backup tools are still being implemented.

} ; } diff --git a/app/api/admin/rooms/[code]/discussion/route.ts b/app/api/admin/rooms/[code]/discussion/route.ts new file mode 100644 index 00000000..a89a2d6a --- /dev/null +++ b/app/api/admin/rooms/[code]/discussion/route.ts @@ -0,0 +1,29 @@ +import { env } from 'cloudflare:workers'; +import { AdminError, requireAdmin } from '@/db/admin-access'; +import { applyAdminDiscussion, readAdminDiscussion } from '@/db/admin-discussion'; +import { validAdminDiscussionInput } from '@/lib/admin-discussion'; +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 readAdminDiscussion(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 (!validAdminDiscussionInput(input)) throw new AdminError('Choose a participant, discussion setting, current versions and a short reason.', 400); + return adminResponse(await applyAdminDiscussion(env.DB, identity, code(request), input)); + } catch (error) { return adminFailure(error); } +} diff --git a/components/admin-discussion.tsx b/components/admin-discussion.tsx new file mode 100644 index 00000000..94f05f36 --- /dev/null +++ b/components/admin-discussion.tsx @@ -0,0 +1,153 @@ +'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 { adminDiscussionResponse, adminDiscussionConfirmation, clearAdminDiscussionRequest, newAdminDiscussionRequest, readAdminDiscussionRequest, saveAdminDiscussionRequest } from '@/lib/admin-discussion-client'; +import type { AdminDiscussionInput, AdminDiscussionView } from '@/lib/admin-discussion'; +import { FACTIONS } from '@/game/catalog'; + +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 : 'Participant availability could not be loaded.'; + +export function AdminDiscussion({ accountId, code, canManage, onClose, onUpdated, onDenied, onBusyChange }: Props) { + const [room, setRoom] = useState(null); + const [target, setTarget] = useState(''); + 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 [initialRequestPending, setInitialRequestPending] = useState(false); + const [loading, setLoading] = useState(true); + const alive = useRef(false), inFlight = useRef(false); + const title = useRef(null); + const url = `/api/admin/rooms/${code}/discussion`; + const selected = room?.players.find(player => player.id === target && player.control !== 'ai'); + + useEffect(() => { + alive.current = true; title.current?.focus(); + let canceled = false; + try { + const saved = readAdminDiscussionRequest(window.sessionStorage, accountId, code); + // oxlint-disable-next-line react/react-compiler -- Restore this account/room's tab-scoped request after SSR. + setPending(saved); + if (saved) setNotice('A saved discussion 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 = adminDiscussionResponse(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 = adminDiscussionResponse(await requestJson(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code); + if (alive.current) { setRoom(current); setTarget(''); 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?: AdminDiscussionInput) { + if (inFlight.current || loading || !canManage || storageProblem || + (!saved && (!room?.editable || !selected || pending || !confirmed || confirmationCode !== code))) return; + let input: AdminDiscussionInput; + try { + input = saved ?? newAdminDiscussionRequest(room!, target, !selected!.muted, reason); + saveAdminDiscussionRequest(window.sessionStorage, accountId, code, input); + } catch (error) { setNotice(message(error)); return; } + inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setPending(input); setNotice('Saving discussion setting…'); + try { + const result = adminDiscussionConfirmation(await requestJson(url, { + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input), + }), code, input); + clearAdminDiscussionRequest(window.sessionStorage, accountId, code); + if (alive.current) { + setPending(null); setRoom(result.room); setTarget(''); setConfirmed(false); setConfirmationCode(''); setReason(''); + setNotice(result.replayed ? 'The original request was already recorded. Current discussion settings are shown below; later changes have been preserved.' : + input.muted ? 'Discussion muted for this seat. The player can still read messages and play. Saved-message retries can confirm earlier sends.' : 'Discussion unmuted for this seat. New messages are available again when the room is open.'); + onUpdated(); + } + } catch (error) { + let cleared = false; + if (!retainAdminRoomRequest(error)) { + try { clearAdminDiscussionRequest(window.sessionStorage, accountId, code); cleared = true; } catch { /* Preserve 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 = adminDiscussionResponse(await requestJson(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code); + if (alive.current) setRoom(current); + } catch (readError) { + // Keep the exact retry proof, but never leave stale privileged controls visible. + if (alive.current && readError instanceof ClientRequestError && [401, 403].includes(readError.status ?? 0)) onDenied(); + } + } + } finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } } + } + + function discardUnreadableRecord() { + if (!canManage || !room || !confirmed || busy) return; + try { + clearAdminDiscussionRequest(window.sessionStorage, accountId, code); setStorageProblem(false); setConfirmed(false); + setNotice('Local retry record removed. This did not undo any server change. Review current discussion settings before continuing.'); + } catch (error) { setNotice(message(error)); } + } + + return
+

Discussion controls · {code}

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

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

+

Mute blocks new public and private messages from a seat. Its owner can still read discussion, receive messages and play. Existing messages stay saved; this panel cannot read them.

+ {room.closed &&

The room is closed to all new messages. These settings also apply when it reopens.

} + {room.players.length > 0 &&
    {room.players.map(player =>
  • {player.name} · {FACTIONS.find(faction => faction.id === player.faction)?.name ?? player.faction} · {player.control === 'ai' ? 'AI seat — no discussion' : player.muted ? 'Discussion muted' : 'Discussion allowed'}
  • )}
} + + : } + {!canManage ?

Your viewer role can inspect these settings. An owner or operator can mute or unmute a seat.

: 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.

+ + +
: initialRequestPending ? null : pending ?
+

Saved request: {pending.muted ? 'Mute' : 'Unmute'} discussion for {room?.players.find(player => player.id === pending.target)?.name ?? pending.target} in room {code}.

Reason: {pending.reason}

+

The exact request stays in this tab across refresh. Retrying confirms the original change without applying it again, even if another administrator changed the setting afterward.

+

After refresh or sign-in, use All rooms in both directory filters to find later removed or archived rooms and reopen Discussion controls.

+ +
: room && !room.editable ?

{room.blockedReason}

: room && !room.players.some(player => player.control !== 'ai') ?

No human seats are available for discussion moderation.

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

Use a short operational reason. Do not copy private messages, credentials or private game information.

+
+

{selected ? `${selected.muted ? 'Unmute' : 'Mute'} discussion for ${selected.name}.` : 'Choose a participant above.'} The setting follows this game seat through recovery and handover. A later owner inherits it until an administrator changes it.

+

Game decisions, AI control, seat access, recovery and saved messages remain unchanged. Previously saved sends can still be confirmed.

+ + + +
+
} + } +
; +} diff --git a/components/table-talk.tsx b/components/table-talk.tsx index e11886cd..4b2954e7 100644 --- a/components/table-talk.tsx +++ b/components/table-talk.tsx @@ -4,7 +4,7 @@ import { useEffect, useId, useRef, useState } from 'react'; import type { GameView } from '@/game/engine'; import { faction } from '@/game/catalog'; import { Button } from './ui/button'; -import { requestJson, requestMayHaveCompleted } from '@/lib/client-request'; +import { ClientRequestError, requestJson, requestMayHaveCompleted } from '@/lib/client-request'; import { TALK_LIMIT, clearTalkDraft, @@ -24,7 +24,8 @@ export function TableTalk({ game }: { game: GameView }) { const [text, setText] = useState(''); const [record, setRecord] = useState(null); const [pageChannel, setPageChannel] = useState(null); - const [page, setPage] = useState({ messages: [], before: null }); + const [page, setPage] = useState({ messages: [], before: null, muted: false }); + const [muted, setMuted] = useState(false); const [ready, setReady] = useState(false); const [busy, setBusy] = useState(false); const [loading, setLoading] = useState(false); @@ -104,6 +105,7 @@ export function TableTalk({ game }: { game: GameView }) { !readingHistory.current ) { setPage(result); + setMuted(result.muted === true); setPageChannel(url); setReadError(''); } @@ -120,7 +122,7 @@ export function TableTalk({ game }: { game: GameView }) { readingHistory.current = false; // oxlint-disable-next-line react/react-compiler -- Reset saved-history browsing when the external conversation subscription changes. setHistory(false); - setPage({ messages: [], before: null }); + setPage({ messages: [], before: null, muted: false }); void read(); return () => { controller.abort(); @@ -138,11 +140,14 @@ export function TableTalk({ game }: { game: GameView }) { const generation = channelGeneration.current; try { const result = await requestJson(pageUrl(shownPage.before)); - if (generation === channelGeneration.current) + if (generation === channelGeneration.current) { + setMuted(result.muted === true); setPage((current) => ({ before: result.before, messages: mergeTalkPages(result.messages, current.messages), + muted: result.muted, })); + } } catch { if (generation === channelGeneration.current) setReadError('Older messages could not be loaded. Try again.'); @@ -152,7 +157,7 @@ export function TableTalk({ game }: { game: GameView }) { } } async function submit() { - if (sending.current || !ready || (closed && !record)) return; + if (sending.current || !ready || ((closed || muted) && !record)) return; const draft = record ?? { id: randomId(), recipientId: recipient || null, @@ -188,6 +193,7 @@ export function TableTalk({ game }: { game: GameView }) { setRefresh((value) => value + 1); } catch (error) { if (!mounted.current) return; + if (error instanceof ClientRequestError && error.code === 'SEAT_DISCUSSION_MUTED') setMuted(true); if (dispatched && (wasRetry || requestMayHaveCompleted(error))) { setNotice( 'Message not confirmed. Retry the exact message, or check the conversation before discarding its retry details.', @@ -316,6 +322,7 @@ export function TableTalk({ game }: { game: GameView }) {

No messages loaded in this conversation.

)} {closed &&

This room is closed. Discussion history remains available; an exact saved message can still be confirmed.

} + {muted &&

An administrator muted discussion for this seat. You can still read messages and play. An exact saved message can still be confirmed; new messages are available after unmuting.

}
{ event.preventDefault(); @@ -332,7 +339,7 @@ export function TableTalk({ game }: { game: GameView }) { rows={3} maxLength={TALK_LIMIT} value={text} - disabled={busy || !!record || closed} + disabled={busy || !!record || closed || muted} onChange={(event) => setText(event.target.value)} aria-describedby={`${field}-count`} /> @@ -343,7 +350,7 @@ export function TableTalk({ game }: { game: GameView }) { type="submit" disabled={ !ready || - (closed && !record) || + ((closed || muted) && !record) || busy || !text.trim() || (!record && !!recipient && !target) diff --git a/db/admin-discussion.ts b/db/admin-discussion.ts new file mode 100644 index 00000000..bd91990a --- /dev/null +++ b/db/admin-discussion.ts @@ -0,0 +1,97 @@ +import { AdminError, type AdminIdentity } from './admin-access'; +import { FACTIONS } from '../game/catalog'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { validAdminDiscussionInput, type AdminDiscussionInput, type AdminDiscussionView, type AdminDiscussionResult } from '../lib/admin-discussion'; + +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,r.state, + EXISTS (SELECT 1 FROM room_removals WHERE room_code=r.code AND removed=1) AS removed, + EXISTS (SELECT 1 FROM room_closures WHERE room_code=r.code AND closed=1) AS closed, + EXISTS (SELECT 1 FROM room_archives WHERE room_code=r.code AND archived=1) AS archived, + (SELECT json_group_array(json_object('id',player_id,'muted',muted,'revision',revision)) FROM seat_discussion_controls WHERE room_code=r.code) AS controls + FROM rooms r WHERE r.code=? AND EXISTS (${authority})`; +const receiptSelect = `SELECT operation_id,actor_admin_id,room_code,request_hash,expected_version,applied_revision + FROM admin_discussion_operations WHERE operation_id=? AND EXISTS (${mutationAuthority})`; +type Row = { code: string; version: number; state: string; removed: number; closed: number; archived: number; controls: string }; +type Receipt = { operation_id: string; actor_admin_id: string; room_code: string; request_hash: string; expected_version: number; applied_revision: number }; +const object = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); +const counter = (value: unknown) => Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) < Number.MAX_SAFE_INTEGER; +function argsFor(identity: AdminIdentity, code: string, now: number) { + if (!identity || typeof identity.id !== 'string' || typeof identity.sessionHash !== 'string' || !/^[0-9a-f]{64}$/.test(identity.sessionHash)) throw new AdminError('Administrator sign-in required.', 401); + if (typeof code !== 'string' || code.length !== 8 || !/^[A-Z2-9]{8}$/.test(code)) throw new AdminError('Enter the 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); +} +export function projectAdminDiscussion(row: Row): AdminDiscussionView { + const base: AdminDiscussionView = { code: row.code, version: row.version, removed: row.removed === 1, closed: row.closed === 1, archived: row.archived === 1, + status: 'unreadable', editable: false, blockedReason: 'This room needs inspection before changing participant discussion.', players: [] }; + try { + const game: unknown = JSON.parse(row.state), controls: unknown = JSON.parse(row.controls); + if (!object(game) || !['lobby','setup','playing','finished'].includes(String(game.status)) || !Array.isArray(game.players) || game.players.length < 1 || game.players.length > 6 || + !game.players.every(player => object(player) && typeof player.id === 'string' && !!player.id.trim() && player.id.length <= 80 && typeof player.name === 'string' && player.name.length <= 80 && + FACTIONS.some(faction => faction.id === player.faction) && [player.bot, player.autopilot].every(value => value === undefined || DIFFICULTIES.some(difficulty => difficulty === value))) || + new Set(game.players.map(player => player.id)).size !== game.players.length || !Array.isArray(controls) || !controls.every(control => object(control) && typeof control.id === 'string' && [0,1].includes(Number(control.muted)) && counter(control.revision))) return base; + const players = game.players.map(player => { + const control = controls.find(control => control.id === player.id); + return { id: player.id as string, name: player.name as string, faction: player.faction as string, + control: player.bot ? 'ai' as const : player.autopilot ? 'autopilot' as const : 'human' as const, muted: control?.muted === 1, revision: control?.revision ?? 0 }; + }); + return { ...base, status: game.status as AdminDiscussionView['status'], editable: !base.removed, blockedReason: base.removed ? 'Restore this removed room before changing participant discussion.' : null, players }; + } catch { return base; } +} +function replay(receipt: Receipt | undefined, row: Row | undefined, identity: AdminIdentity, code: string, hash: string, replayed: boolean): AdminDiscussionResult | null { + if (!receipt) return null; + if (receipt.actor_admin_id !== identity.id || receipt.room_code !== code || receipt.request_hash !== hash) throw new AdminError('This discussion operation belongs to a different administrator or request.', 409); + const room = row ? projectAdminDiscussion(row) : { code, version: receipt.expected_version, removed: true, closed: false, archived: false, status: 'unreadable' as const, + editable: false, blockedReason: 'The room is no longer available.', players: [] }; + return { operationId: receipt.operation_id, appliedRevision: receipt.applied_revision, replayed, room }; +} +export async function readAdminDiscussion(database: D1Database, identity: AdminIdentity, code: string, now = Date.now()): Promise { + const args = argsFor(identity, code, now); + const result = await database.batch([database.prepare(authority).bind(...args), database.prepare(roomSelect).bind(code, ...args)]); + authorize(result[0]); const row = result[1].results[0] as Row | undefined; + if (!row) throw new AdminError('Room not found.', 404); + return projectAdminDiscussion(row); +} +export async function applyAdminDiscussion(database: D1Database, identity: AdminIdentity, code: string, input: AdminDiscussionInput, now = Date.now()): Promise { + const args = argsFor(identity, code, now); + if (!validAdminDiscussionInput(input)) throw new AdminError('Choose a human participant, discussion setting, current versions and a short operational reason.', 400); + const { operationId, expectedVersion, expectedRevision, target, muted } = input, reason = input.reason.trim(); + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify({ expectedVersion, expectedRevision, target, muted, reason }))); + const hash = Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''); + const before = await database.batch([database.prepare(authority).bind(...args), database.prepare(receiptSelect).bind(operationId, ...args), database.prepare(roomSelect).bind(code, ...args)]); + authorize(before[0], true); + const row = before[2].results[0] as Row | undefined, previous = replay(before[1].results[0] as Receipt | undefined, row, identity, code, hash, true); + if (previous) return previous; + if (!row) throw new AdminError('Room not found.', 404); + const view = projectAdminDiscussion(row), player = view.players.find(player => player.id === target && player.control !== 'ai'); + if (!view.editable) throw new AdminError(view.blockedReason ?? 'Refresh participant availability.', 409); + if (view.version !== expectedVersion || !player || player.revision !== expectedRevision || player.muted === muted) throw new AdminError('The room or participant discussion setting changed. Refresh before choosing a new operation.', 409); + const result = await database.batch([ + database.prepare(authority).bind(...args), + database.prepare(`INSERT INTO seat_discussion_controls(room_code,player_id,muted,revision,updated_at) + SELECT r.code,?,?,?,? FROM rooms r WHERE r.code=? AND r.version=? AND EXISTS (${mutationAuthority}) + AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code=r.code AND removed=1) + AND EXISTS (SELECT 1 FROM json_each(r.state,'$.players') p WHERE json_extract(p.value,'$.id')=? AND json_extract(p.value,'$.bot') IS NULL) + AND COALESCE((SELECT revision FROM seat_discussion_controls WHERE room_code=r.code AND player_id=?),0)=? + AND COALESCE((SELECT muted FROM seat_discussion_controls WHERE room_code=r.code AND player_id=?),0)<>? + AND NOT EXISTS (SELECT 1 FROM admin_discussion_operations WHERE operation_id=?) + ON CONFLICT(room_code,player_id) DO UPDATE SET muted=excluded.muted,revision=excluded.revision,updated_at=excluded.updated_at + WHERE seat_discussion_controls.revision=?`) + .bind(target, Number(muted), expectedRevision + 1, now, code, expectedVersion, ...args, target, target, expectedRevision, target, Number(muted), operationId, expectedRevision), + database.prepare(`INSERT INTO admin_discussion_operations(operation_id,actor_admin_id,room_code,request_hash,expected_version,expected_revision,applied_revision,target,muted,reason,created_at) + SELECT ?,?,?,?,?,?,?,?,?,?,? WHERE changes()=1`).bind(operationId, identity.id, code, hash, expectedVersion, expectedRevision, expectedRevision + 1, target, Number(muted), reason, now), + database.prepare(receiptSelect).bind(operationId, ...args), database.prepare(roomSelect).bind(code, ...args), + ]); + authorize(result[0], true); + const committed = replay(result[3].results[0] as Receipt | undefined, result[4].results[0] as Row | undefined, identity, code, hash, result[1].meta.changes !== 1); + if (committed) return committed; + throw new AdminError('The room, participant setting or administrator access changed. Refresh before choosing a new operation.', 409); +} diff --git a/db/schema.ts b/db/schema.ts index 0e5b2cd9..2af2c59e 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -363,6 +363,32 @@ export const roomEntryReceipts = sqliteTable('room_entry_receipts', { playerId: text('player_id').notNull(), }); +// Discussion moderation follows the logical seat across recovery and handover. +export const seatDiscussionControls = sqliteTable('seat_discussion_controls', { + roomCode: text('room_code').notNull().references(() => rooms.code, { onDelete: 'cascade' }), + playerId: text('player_id').notNull(), + muted: integer('muted').notNull().default(0), + revision: integer('revision').notNull().default(0), + updatedAt: integer('updated_at').notNull(), +}, table => [primaryKey({ columns: [table.roomCode, table.playerId] }), + check('seat_discussion_muted', sql`${table.muted} IN (0, 1)`), + check('seat_discussion_revision', sql`${table.revision} >= 0`)]); + +// Keep audit receipts independent of room/account deletion, like other admin operations. +export const adminDiscussionOperations = sqliteTable('admin_discussion_operations', { + 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(), + appliedRevision: integer('applied_revision').notNull(), + target: text('target').notNull(), + muted: integer('muted').notNull(), + reason: text('reason').notNull(), + createdAt: integer('created_at').notNull(), +}, table => [index('admin_discussion_room').on(table.roomCode, table.createdAt)]); + export const roomMessages = sqliteTable( 'room_messages', { diff --git a/db/table-talk.ts b/db/table-talk.ts index 402edb13..3ca5a580 100644 --- a/db/table-talk.ts +++ b/db/table-talk.ts @@ -70,7 +70,9 @@ export async function readTableTalk( WHERE m.room_code = r.code AND m.id = ? AND (${channel}))) ORDER BY m.sequence DESC LIMIT ${TALK_PAGE_SIZE + 1} ) - ) AS messages ${viewer}`) + ) AS messages, + EXISTS (SELECT 1 FROM seat_discussion_controls WHERE room_code=r.code AND player_id=s.player_id AND muted=1) AS muted + ${viewer}`) .bind( recipientId, recipientId, @@ -86,7 +88,7 @@ export async function readTableTalk( auth.playerId, auth.tokenHash, ) - .first<{ messages: string }>(); + .first<{ messages: string; muted: number }>(); if (!row) { await requireRoomNotRemoved(code); throw new TableTalkError('Your seat could not be verified.'); @@ -96,6 +98,7 @@ export async function readTableTalk( return { messages, before: newestFirst.length > TALK_PAGE_SIZE ? messages[0].id : null, + muted: row.muted === 1, }; } @@ -117,6 +120,7 @@ export async function sendTableTalk( (SELECT json_extract(target.value, '$.name') FROM json_each(r.state, '$.players') target WHERE json_extract(target.value, '$.id') = ?), ?, ? ${viewer} AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = r.code AND closed = 1) + AND NOT EXISTS (SELECT 1 FROM seat_discussion_controls WHERE room_code=r.code AND player_id=s.player_id AND muted=1) AND (? IS NULL OR EXISTS (SELECT 1 FROM json_each(r.state, '$.players') target WHERE json_extract(target.value, '$.id') = ? AND json_extract(target.value, '$.bot') IS NULL)) AND NOT EXISTS (SELECT 1 FROM room_messages recent WHERE recent.room_code = r.code @@ -158,6 +162,10 @@ export async function sendTableTalk( await requireRoomNotRemoved(code); if (await database().prepare('SELECT 1 FROM room_closures WHERE room_code = ? AND closed = 1').bind(code).first()) throw new TableTalkError('An administrator closed this room. Discussion history remains readable; new messages are available after reopening.', 409, 'ROOM_CLOSED'); + // Only a still-authenticated seat may learn its own restriction. Never gate prior receipts on mute. + if (await database().prepare(`SELECT 1 ${viewer} AND EXISTS (SELECT 1 FROM seat_discussion_controls WHERE room_code=r.code AND player_id=s.player_id AND muted=1)`) + .bind(code, auth.playerId, auth.tokenHash).first()) + throw new TableTalkError('An administrator muted discussion for this seat. You can still read messages and play; new messages are available after unmuting.', 409, 'SEAT_DISCUSSION_MUTED'); throw new TableTalkError( 'Message not confirmed. Check your seat and recipient, wait a second, then try again.', ); diff --git a/docs/ADMIN_DISCUSSION.md b/docs/ADMIN_DISCUSSION.md new file mode 100644 index 00000000..500cfb0d --- /dev/null +++ b/docs/ADMIN_DISCUSSION.md @@ -0,0 +1,67 @@ +# Administrative discussion controls + +**Discussion controls** in the administration directory lets an owner or operator +mute or unmute new public and private messages from one human seat. Viewers can +inspect the public roster and discussion settings. A muted player can still read +history, receive messages and play the game. This prototype does not revoke seat +access, remove a participant or permit administrators to read private messages. + +## Use and recovery + +Select the human participant, enter a short operational reason, type the room +code and acknowledge that the setting follows the seat. **Mute discussion** or +**Unmute discussion** applies the change. Human seats using AI remain eligible; +permanent AI seats do not send messages. Closed and archived rooms permit settings +for reopening; a removed room must first be restored. Closing a room still blocks +all new sends independently of its seat settings. + +The player sees a persistent explanation when muted, with **Refresh discussion +availability** for checking an unmute while browsing older history. Only the +caller's mute status is returned with their message page. Other players receive +no mute indicator or private discussion activity. Incoming history remains under +the existing sender/recipient privacy rules. Recovery and voluntary handover +inherit the logical seat's setting; revoked credentials remain invalid. + +The exact admin operation, room version, seat-setting revision, target, setting +and reason are saved in account/room-scoped tab storage before sending. The first +request shows concise progress. Uncertain requests retain explicit retry controls +across refresh; success text stays until a deliberate action. Retrying an old +mute after a later unmute confirms the old receipt without muting again. Existing +committed message requests can likewise be confirmed while muted; altered or +new requests cannot bypass the restriction. A new owner cannot replay the former +credential's message receipt. + +## Persistence and authority + +Additive migration [0016](../drizzle/0016_seat_discussion_controls.sql) stores +seat settings separately from game JSON. The action leaves room versions, game +change timestamps, private custody, credentials, readiness and AI pacing intact. +It requires the current room version and a separate seat-setting revision. Live +admin authority, human membership, room availability and both versions are fenced +at commit. The setting and audit receipt commit together or roll back together. +Message insertion independently checks the live mute setting in SQL, preventing +a stale browser from sending. A message committed before a later mute still +returns its successful receipt. + +Controls: [admin panel](../components/admin-discussion.tsx), +[player discussion](../components/table-talk.tsx), +[client retry](../lib/admin-discussion-client.ts). +Server: [route](../app/api/admin/rooms/[code]/discussion/route.ts), +[admin transaction](../db/admin-discussion.ts), [message store](../db/table-talk.ts). + +```sh +npm test -- admin-discussion table-talk +npm run test:integration -- admin-boundary table-talk +``` + +Focused regressions cover rollback, concurrent/conflicting operations, live +role/session/membership/version changes, stale revisions after unmute, receipt +replay, own-only status, incoming messages, mute/send ordering and seat rotation. +The dedicated admin HTTP verifier creates a new QA room for deny/mute/read/retry/ +unmute flows and preserves both seats. Independent SQL/privacy and UI source +reviews found no blocking defects. Browser, checkpoint and preservation evidence +are recorded separately; deployed acceptance must match the running revision. + +Access revocation, disruptive participant removal, administrator-assisted recovery, +backup/restore and operational audit browsing remain unfinished. Discussion +moderation is a partial participant-support prototype, not full admin acceptance. diff --git a/docs/ADMIN_PANEL.md b/docs/ADMIN_PANEL.md index 0be82e27..8212eb91 100644 --- a/docs/ADMIN_PANEL.md +++ b/docs/ADMIN_PANEL.md @@ -13,7 +13,7 @@ recovery and persistence systems where suitable. | 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/unarchive](ADMIN_ROOM_ARCHIVE.md) has separate directory filters and exact retries. Permanent deletion and bulk actions remain missing. | Prototyped, partial | -| Participant support | [Participant AI](ADMIN_PARTICIPANT_AI.md) enables an existing difficulty on an eligible human seat in a paused game, retaining access/takeback and private custody. Participant restrictions/removal, access revocation, assisted recovery and further replacement remain. Existing lobby controls can reassign the host. | Prototyped, partial | +| Participant support | [Participant AI](ADMIN_PARTICIPANT_AI.md) enables an existing difficulty on an eligible human seat in a paused game, retaining access/takeback and private custody. [Discussion controls](ADMIN_DISCUSSION.md) mute/unmute new sends while retaining history and gameplay. Participant removal, access revocation, assisted recovery and further replacement remain. Existing lobby controls can reassign the host. | Prototyped, partial | | 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 | @@ -56,6 +56,8 @@ No default key, public account creation or host-to-administrator promotion exist [Participant AI](ADMIN_PARTICIPANT_AI.md) lets owners/operators support an absent human in a paused, started game. The player retains access and takeback controls; the administrator receives no private seat. Resume remains a separate action. +[Discussion controls](ADMIN_DISCUSSION.md) restrict new messages from a human seat, +including after recovery/handover, without exposing private history. The directory uses an explicit field allowlist, independent of any player view. It never returns hands, Traitors, predictions, plans, private messages, recovery @@ -129,7 +131,8 @@ room IDs and safe outcomes, never keys/cookies. The container verifier provision its own disposable QA account and checks admin-session continuity across restart and replacement, followed by the HTTP flow and HTTPS-proxy cookie behavior. Participant checks use another new QA room for paused AI enable, private-seat -continuity, takeback and durable retries after resuming. +continuity, takeback and durable retries after resuming. Discussion checks use a +new QA room for mute/unmute, own-only status, incoming messages and saved retries. Local browser checks cover sign-in, directory filters, refresh restoration and mobile layout. Final source-bound checks and separately recorded deployed diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 1abc46b3..8e1232ea 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -6,13 +6,19 @@ Expansion starts and full rules/publication acceptance remain gated.** ## Current checkpoint and work -Current work adds [participant AI support](ADMIN_PARTICIPANT_AI.md): a live -owner/operator can enable an existing AI difficulty for an eligible human in a -paused, started game. Saved custody and seat access remain; the human can take -back control, and resume is separate. Atomic audit, exact saved retries and -commit-time room/seat/authority checks are connected. Participant restrictions, -access revocation, assisted recovery, permanent deletion, bulk actions and -backup/operations tools remain unfinished. This adds no strategic AI refinement. +Current work adds [discussion moderation](ADMIN_DISCUSSION.md): owners/operators +can mute or unmute new public/private messages from a human seat. History, +incoming messages, game state and credentials remain saved; the setting follows +recovery/handover. Atomic authority/version fences, durable audit and exact +admin/message retries are connected. This is partial participant support; +access revocation, participant removal, assisted recovery, permanent deletion, +bulk actions and backup/operations tools remain unfinished. + +[Participant AI](ADMIN_PARTICIPANT_AI.md) checkpoint `2cddf03` is pushed. Types/lint, +5,408 offline tests, 52 HTTP tests, build, twenty-two administrator HTTP groups +and desktop/mobile browser acceptance passed. Deployment request delivery remains +unconfirmed; the latest confirmed production revision is `e918e2e`. No strategic +AI refinement was added. Readable-notice checkpoint `137f922` is pushed. Types/lint, 5,381 offline tests, 52 HTTP tests, build and delayed-response desktop/mobile browser QA passed. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index eec4138e..29e8f9d1 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,21 @@ # Dune implementation status +## 26 September 2026 — Administrative discussion moderation prototype + +[Discussion controls](ADMIN_DISCUSSION.md) connect human-seat mute/unmute to the +admin directory and player message panel. New public/private sends are blocked +at commit; history, incoming messages and exact committed retries remain usable. +Settings survive seat recovery/handover without reviving old credentials. Game +JSON, versions, timestamps, private custody and AI pacing remain unchanged. + +Atomic live authority/membership/version fences, per-seat revisions and audit +receipts protect concurrent and uncertain requests. Independent SQL/privacy and +UI source review found no blocking defects. Focused regressions cover mute/send +ordering, rollback, stale unmute races and exact retry privacy. Browser, HTTP, +source-bound checkpoint checks and preservation evidence accompany this slice. +New-revision deployed acceptance remains open; full administration and game +rules/expansion completion remain unfinished. + ## 26 September 2026 — Administrative participant AI prototype [Participant AI](ADMIN_PARTICIPANT_AI.md) connects paused human-seat support to diff --git a/docs/TABLE_DISCUSSION.md b/docs/TABLE_DISCUSSION.md index 554f8bc1..807d9ca4 100644 --- a/docs/TABLE_DISCUSSION.md +++ b/docs/TABLE_DISCUSSION.md @@ -28,6 +28,16 @@ resumes updates. Channel changes discard obsolete read results. There are no public direct-message counts, typing indicators or global sequence numbers. Messages are stored durably; pagination does not delete old history. +## Discussion moderation + +An administrator can [mute new messages from a seat](ADMIN_DISCUSSION.md). The +player sees a persistent explanation; reading history, receiving messages and +playing remain available. The setting follows recovery or handover and is never +shown as another player's private activity. New sends are checked at database +commit. An exact previously committed send can still be confirmed while muted; +no new message is created. Refresh availability after an unmute, or allow the +normal latest-message poll to update the controls. + ## Saved retries and state isolation The browser saves an exact message UUID, recipient and body in tab storage before diff --git a/drizzle/0016_seat_discussion_controls.sql b/drizzle/0016_seat_discussion_controls.sql new file mode 100644 index 00000000..3a8be4d5 --- /dev/null +++ b/drizzle/0016_seat_discussion_controls.sql @@ -0,0 +1,26 @@ +CREATE TABLE `admin_discussion_operations` ( + `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_revision` integer NOT NULL, + `target` text NOT NULL, + `muted` integer NOT NULL, + `reason` text NOT NULL, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `admin_discussion_room` ON `admin_discussion_operations` (`room_code`,`created_at`);--> statement-breakpoint +CREATE TABLE `seat_discussion_controls` ( + `room_code` text NOT NULL, + `player_id` text NOT NULL, + `muted` integer DEFAULT 0 NOT NULL, + `revision` integer DEFAULT 0 NOT NULL, + `updated_at` integer NOT NULL, + PRIMARY KEY(`room_code`, `player_id`), + FOREIGN KEY (`room_code`) REFERENCES `rooms`(`code`) ON UPDATE no action ON DELETE cascade, + CONSTRAINT "seat_discussion_muted" CHECK("seat_discussion_controls"."muted" IN (0, 1)), + CONSTRAINT "seat_discussion_revision" CHECK("seat_discussion_controls"."revision" >= 0) +); diff --git a/drizzle/meta/0016_snapshot.json b/drizzle/meta/0016_snapshot.json new file mode 100644 index 00000000..c564a3dc --- /dev/null +++ b/drizzle/meta/0016_snapshot.json @@ -0,0 +1,2161 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3362823e-363d-4145-8496-b2025fd47385", + "prevId": "51138d82-4316-4680-b370-1ddc93864468", + "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_discussion_operations": { + "name": "admin_discussion_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 + }, + "expected_revision": { + "name": "expected_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_revision": { + "name": "applied_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "muted": { + "name": "muted", + "type": "integer", + "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_discussion_room": { + "name": "admin_discussion_room", + "columns": [ + "room_code", + "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_seat_ai_operations": { + "name": "admin_seat_ai_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 + }, + "expected_control_revision": { + "name": "expected_control_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_version": { + "name": "applied_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "difficulty": { + "name": "difficulty", + "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_seat_ai_room": { + "name": "admin_seat_ai_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_discussion_controls": { + "name": "seat_discussion_controls", + "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 + }, + "muted": { + "name": "muted", + "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": { + "seat_discussion_controls_room_code_rooms_code_fk": { + "name": "seat_discussion_controls_room_code_rooms_code_fk", + "tableFrom": "seat_discussion_controls", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "seat_discussion_controls_room_code_player_id_pk": { + "columns": [ + "room_code", + "player_id" + ], + "name": "seat_discussion_controls_room_code_player_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": { + "seat_discussion_muted": { + "name": "seat_discussion_muted", + "value": "\"seat_discussion_controls\".\"muted\" IN (0, 1)" + }, + "seat_discussion_revision": { + "name": "seat_discussion_revision", + "value": "\"seat_discussion_controls\".\"revision\" >= 0" + } + } + }, + "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 6f561675..c42778aa 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1790389395436, "tag": "0015_admin_seat_ai", "breakpoints": true + }, + { + "idx": 16, + "version": "6", + "when": 1790395696065, + "tag": "0016_seat_discussion_controls", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/admin-discussion-client.ts b/lib/admin-discussion-client.ts new file mode 100644 index 00000000..2dead45c --- /dev/null +++ b/lib/admin-discussion-client.ts @@ -0,0 +1,44 @@ +import { randomId } from './random-id'; +import { validAdminDiscussionInput, validAdminDiscussionView, type AdminDiscussionInput, type AdminDiscussionView, type AdminDiscussionResult } from './admin-discussion'; +type Storage = Pick; +const key = (account: string, code: string) => `dune.admin-discussion-request.v1:${account}:${code}`; +const object = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); + +export function adminDiscussionResponse(value: unknown, code: string): AdminDiscussionView { + if (!validAdminDiscussionView(value) || value.code !== code) throw new Error('The server returned unreadable participant discussion settings.'); + return value; +} +export function adminDiscussionConfirmation(value: unknown, code: string, input: AdminDiscussionInput): AdminDiscussionResult { + if (!object(value) || Object.keys(value).length !== 4 || !['operationId','replayed','appliedRevision','room'].every(key => Object.hasOwn(value, key)) || + value.operationId !== input.operationId || typeof value.replayed !== 'boolean' || value.appliedRevision !== input.expectedRevision + 1) + throw new Error('The server did not confirm this participant discussion request.'); + const room = adminDiscussionResponse(value.room, code), target = room.players.find(player => player.id === input.target); + if (room.version < input.expectedVersion || (target && target.revision < Number(value.appliedRevision))) + throw new Error('The server returned participant settings older than the confirmed change.'); + return value as AdminDiscussionResult; +} +export function newAdminDiscussionRequest(view: AdminDiscussionView, target: string, muted: boolean, reason: string): AdminDiscussionInput { + if (!view.editable) throw new Error(view.blockedReason ?? 'Refresh participant discussion settings.'); + const player = view.players.find(player => player.id === target && player.control !== 'ai'); + if (!player || player.muted === muted) throw new Error('Choose a human participant with a different discussion setting.'); + const input = { operationId: randomId(), expectedVersion: view.version, expectedRevision: player.revision, target, muted, reason: reason.trim() }; + if (!validAdminDiscussionInput(input)) throw new Error('Choose a participant and a reason of 1–300 characters on one line.'); + return input; +} +export function readAdminDiscussionRequest(storage: Storage, account: string, code: string): AdminDiscussionInput | 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 (!validAdminDiscussionInput(value)) throw new Error('This tab’s saved discussion request is unreadable. Inspect the room before discarding its local retry record.'); + return value; +} +export function saveAdminDiscussionRequest(storage: Storage, account: string, code: string, input: AdminDiscussionInput) { + if (!validAdminDiscussionInput(input)) throw new Error('Invalid participant discussion request.'); + const previous = readAdminDiscussionRequest(storage, account, code), encoded = JSON.stringify(input); + if (previous && JSON.stringify(previous) !== encoded) throw new Error('Resolve the saved discussion request before sending a different change.'); + storage.setItem(key(account, code), encoded); + if (storage.getItem(key(account, code)) !== encoded) throw new Error('The discussion request could not be preserved in this tab. Nothing was sent.'); +} +export function clearAdminDiscussionRequest(storage: Storage, account: string, code: string) { + storage.removeItem(key(account, code)); + if (storage.getItem(key(account, code)) !== null) throw new Error('The saved discussion request could not be cleared.'); +} diff --git a/lib/admin-discussion.ts b/lib/admin-discussion.ts new file mode 100644 index 00000000..c6fc0299 --- /dev/null +++ b/lib/admin-discussion.ts @@ -0,0 +1,35 @@ +import { FACTIONS } from '../game/catalog'; + +export type AdminDiscussionInput = { + operationId: string; expectedVersion: number; expectedRevision: number; + target: string; muted: boolean; reason: string; +}; +export type AdminDiscussionView = { + code: string; version: number; removed: boolean; closed: boolean; archived: boolean; + status: 'lobby' | 'setup' | 'playing' | 'finished' | 'unreadable'; + editable: boolean; blockedReason: string | null; + players: { id: string; name: string; faction: string; control: 'human' | 'autopilot' | 'ai'; muted: boolean; revision: number }[]; +}; +export type AdminDiscussionResult = { operationId: string; replayed: boolean; appliedRevision: number; room: AdminDiscussionView }; +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 counter = (value: unknown) => Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) < Number.MAX_SAFE_INTEGER; +const oneLine = (value: unknown, max: number): value is string => typeof value === 'string' && value.length <= max && !!value.trim() && + !value.split('').some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127); + +export function validAdminDiscussionInput(value: unknown): value is AdminDiscussionInput { + return object(value) && keys(value, ['operationId','expectedVersion','expectedRevision','target','muted','reason']) && + typeof value.operationId === 'string' && value.operationId.length === 36 && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value.operationId) && + counter(value.expectedVersion) && counter(value.expectedRevision) && oneLine(value.target, 80) && value.target.trim() === value.target && + typeof value.muted === 'boolean' && oneLine(value.reason, 300); +} +export function validAdminDiscussionView(value: unknown): value is AdminDiscussionView { + return object(value) && keys(value, ['code','version','removed','closed','archived','status','editable','blockedReason','players']) && + typeof value.code === 'string' && value.code.length === 8 && /^[A-Z2-9]{8}$/.test(value.code) && counter(value.version) && + ['removed','closed','archived','editable'].every(key => typeof value[key] === 'boolean') && + ['lobby','setup','playing','finished','unreadable'].includes(String(value.status)) && (value.blockedReason === null || typeof value.blockedReason === 'string') && + Array.isArray(value.players) && value.players.length <= 6 && value.players.every(player => object(player) && + keys(player, ['id','name','faction','control','muted','revision']) && oneLine(player.id, 80) && typeof player.name === 'string' && player.name.length <= 80 && + FACTIONS.some(faction => faction.id === player.faction) && ['human','autopilot','ai'].includes(String(player.control)) && + typeof player.muted === 'boolean' && counter(player.revision)); +} diff --git a/lib/table-talk.ts b/lib/table-talk.ts index 11ebf88a..69db8584 100644 --- a/lib/table-talk.ts +++ b/lib/table-talk.ts @@ -15,7 +15,7 @@ export type TalkMessage = { text: string; createdAt: number; }; -export type TalkPage = { messages: TalkMessage[]; before: string | null }; +export type TalkPage = { messages: TalkMessage[]; before: string | null; muted: boolean }; export const talkId = (value: unknown): value is string => typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value); diff --git a/tests/admin-boundary-http.test.ts b/tests/admin-boundary-http.test.ts index 7106adc7..02d6110e 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`, `/api/admin/rooms/${room}/archive`, `/api/admin/rooms/${room}/seat-ai`]) { + 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`, `/api/admin/rooms/${room}/seat-ai`, `/api/admin/rooms/${room}/discussion`]) { 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'); @@ -64,6 +64,13 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden assert.equal(participant.status, 401); assert.equal(participant.headers.get('set-cookie'), null); assert.deepEqual(Object.keys(await participant.json()), ['error']); + const discussion = await fetch(`${base}/api/admin/rooms/${room}/discussion`, { + method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' }, + body: JSON.stringify({ operationId: crypto.randomUUID(), expectedVersion: 0, expectedRevision: 0, target: 'owner', muted: true, reason: 'Denied moderation QA' }), + }); + assert.equal(discussion.status, 401); + assert.equal(discussion.headers.get('set-cookie'), null); + assert.deepEqual(Object.keys(await discussion.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-discussion-client.test.ts b/tests/admin-discussion-client.test.ts new file mode 100644 index 00000000..3a995ece --- /dev/null +++ b/tests/admin-discussion-client.test.ts @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { adminDiscussionConfirmation, adminDiscussionResponse, clearAdminDiscussionRequest, newAdminDiscussionRequest, readAdminDiscussionRequest, saveAdminDiscussionRequest } from '../lib/admin-discussion-client'; +import type { AdminDiscussionView } from '../lib/admin-discussion'; + +const view = (): AdminDiscussionView => ({ code: 'SEATQAAB', version: 7, status: 'playing', closed: false, removed: false, archived: false, + editable: true, blockedReason: null, players: [{ id: 'owner', name: 'QA Owner', faction: 'atreides', control: 'human', muted: false, revision: 2 }] }); +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('participant retry stores exact intent before dispatch, survives refresh, and isolates room/account', () => { + const tab = storage(), room = view(), input = newAdminDiscussionRequest(room, 'owner', true, ' QA support '); + assert.equal(input.reason, 'QA support'); + saveAdminDiscussionRequest(tab, 'admin', room.code, input); + assert.deepEqual(readAdminDiscussionRequest(tab, 'admin', room.code), input); + assert.equal(readAdminDiscussionRequest(tab, 'other', room.code), null); + assert.equal(readAdminDiscussionRequest(tab, 'admin', 'OTHERQAA'), null); + for (const change of [{ operationId: crypto.randomUUID() }, { expectedVersion: 8 }, { expectedRevision: 3 }, { target: 'other' }, { muted: false }, { reason: 'Different' }]) + assert.throws(() => saveAdminDiscussionRequest(tab, 'admin', room.code, { ...input, ...change }), /Resolve the saved/); + saveAdminDiscussionRequest(tab, 'admin', room.code, input); + clearAdminDiscussionRequest(tab, 'admin', room.code); assert.equal(readAdminDiscussionRequest(tab, 'admin', room.code), null); +}); +void test('bad participant retry storage cannot lose or overwrite unresolved intent', () => { + const tab = storage(), room = view(), input = newAdminDiscussionRequest(room, 'owner', true, 'QA'); + assert.throws(() => saveAdminDiscussionRequest({ ...tab, setItem() {} }, 'admin', room.code, input), /Nothing was sent/); + saveAdminDiscussionRequest(tab, 'admin', room.code, input); + const key = [...tab.values.keys()][0]; + for (const raw of ['{broken', JSON.stringify({ ...input, token: 'not accepted' }), ' '.repeat(2049), JSON.stringify({ ...input, expectedRevision: -1 })]) { + tab.values.set(key, raw); + assert.throws(() => readAdminDiscussionRequest(tab, 'admin', room.code), /unreadable/); + assert.throws(() => saveAdminDiscussionRequest(tab, 'admin', room.code, input), /unreadable/); + assert.equal(tab.values.get(key), raw); + } + assert.throws(() => clearAdminDiscussionRequest({ ...tab, removeItem() {} }, 'admin', room.code), /could not be cleared/); + clearAdminDiscussionRequest(tab, 'admin', room.code); + for (const reason of [' ', 'a'.repeat(301), 'two\nlines', 'control\u007f']) assert.throws(() => newAdminDiscussionRequest(room, 'owner', true, reason)); + assert.throws(() => newAdminDiscussionRequest({ ...room, editable: false, blockedReason: 'Pause first' }, 'owner', true, 'QA'), /Pause first/); + assert.throws(() => newAdminDiscussionRequest(room, 'absent', true, 'QA'), /human participant/); +}); +void test('participant confirmation binds original operation/version but accepts later human unmute', () => { + const room = view(), input = newAdminDiscussionRequest(room, 'owner', true, 'QA'); + const result = { operationId: input.operationId, appliedRevision: 3, replayed: true, room: { ...room, version: 10, players: [{ ...room.players[0], revision: 4 }] } }; + assert.deepEqual(adminDiscussionConfirmation(result, room.code, input), result); + for (const bad of [{ ...result, operationId: crypto.randomUUID() }, { ...result, appliedRevision: 4 }, { ...result, replayed: 'yes' }, + { ...result, room: { ...room, version: 7 } }, { ...result, token: 'unexpected' }]) assert.throws(() => adminDiscussionConfirmation(bad, room.code, input)); +}); +void test('participant responses reject unexpected private fields and malformed operational values', () => { + const room = view(); assert.deepEqual(adminDiscussionResponse(room, room.code), room); + for (const bad of [null, [], { ...room, state: {} }, { ...room, auditReason: 'private' }, { ...room, code: 'bad' }, + { ...room, version: -1 }, { ...room, removed: 'yes' }, { ...room, players: [{ ...room.players[0], revision: 1.2 }] }, + { ...room, players: [{ ...room.players[0], hand: [] }] }, { ...room, players: [{ ...room.players[0], muted: 'yes' }] }]) + assert.throws(() => adminDiscussionResponse(bad, room.code)); +}); diff --git a/tests/admin-discussion.test.ts b/tests/admin-discussion.test.ts new file mode 100644 index 00000000..cbe35497 --- /dev/null +++ b/tests/admin-discussion.test.ts @@ -0,0 +1,118 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { DatabaseSync } from 'node:sqlite'; +import { adminStore } from './admin-access-fixture'; +import { adminLogin, requireAdmin, AdminError, type AdminRole } from '../db/admin-access'; +import { readAdminDiscussion, applyAdminDiscussion } from '../db/admin-discussion'; +import { createGame, newPlayer } from '../game/engine'; +import { adminDiscussionConfirmation } from '../lib/admin-discussion-client'; +import type { AdminDiscussionInput } from '../lib/admin-discussion'; + +const now = 10_000, code = 'MUTEQAAA'; +const denied = (error: unknown) => error instanceof AdminError && [401,403,409].includes(error.status); +async function fixture(role: AdminRole = 'operator') { + const store = adminStore(); assert.ok(store.sqlite instanceof DatabaseSync); + const account = store.provision(role, 'Private administrator identity'); + const login = await adminLogin(store.database, account.key, now); + const identity = await requireAdmin(store.database, login.token, undefined, now); + const player = newPlayer(randomUUID(), 'Public participant', 'atreides'); + const game = createGame(code, player); + store.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)').run(code, JSON.stringify(game), 7, 42); + const input: AdminDiscussionInput = { operationId: randomUUID(), expectedVersion: 7, expectedRevision: 0, target: player.id, muted: true, reason: 'Private operational reason' }; + const apply = (request = input) => applyAdminDiscussion(store.database, identity, code, request, now); + const rooms = () => store.sqlite.prepare('SELECT * FROM rooms ORDER BY code').all(); + const controls = () => store.sqlite.prepare('SELECT * FROM seat_discussion_controls').all(); + const audits = () => store.sqlite.prepare('SELECT * FROM admin_discussion_operations').all(); + return { ...store, identity, player, input, apply, rooms, controls, audits }; +} +type Fixture = Awaited>; +function pauseCommit(f: Fixture) { + let enter!: () => void, release!: () => void, calls = 0; + const waiting = new Promise(resolve => { enter = resolve; }), gate = new Promise(resolve => { release = resolve; }); + f.hooks.beforeBatch = async () => { if (++calls === 2) { delete f.hooks.beforeBatch; enter(); await gate; } }; + return { waiting, release }; +} +void test('discussion moderation changes only metadata; old receipt after unmute/removal never reapplies', async () => { + const f = await fixture(); + try { + f.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) SELECT ?,state,version,updated_at FROM rooms WHERE code=?').run('OTHERQAA', code); + f.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)').run('private seat hash', code, f.player.id); + const before = f.rooms(), seats = f.sqlite.prepare('SELECT * FROM seats').all(); + const first = await f.apply(); adminDiscussionConfirmation(first, code, f.input); + assert.equal(first.replayed, false); assert.equal(first.room.players[0].muted, true); + assert.deepEqual(Object.keys(first.room.players[0]).sort(), ['control','faction','id','muted','name','revision']); + assert.doesNotMatch(JSON.stringify(first), /Private|token_hash|traitor|hand|recovery/i); + await f.apply({ ...f.input, operationId: randomUUID(), expectedRevision: 1, muted: false }); + const retry = await f.apply(); adminDiscussionConfirmation(retry, code, f.input); + assert.equal(retry.replayed, true); assert.equal(retry.room.players[0].muted, false); + f.sqlite.prepare('INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES(?,1,1,1,1)').run(code); + assert.equal((await f.apply()).room.removed, true); + await assert.rejects(() => f.apply({ ...f.input, operationId: randomUUID(), expectedRevision: 2 }), denied); + await assert.rejects(() => f.apply({ ...f.input, reason: 'Changed payload' }), denied); + assert.equal(f.audits().length, 2); assert.deepEqual(f.rooms(), before); assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats); + } finally { f.sqlite.close(); } +}); +void test('closed archived rooms permit settings for reopening without exposing discussion or requiring a seat credential', async () => { + const f = await fixture(); + try { + f.sqlite.prepare('INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES(?,1,1,1,1)').run(code); + f.sqlite.prepare('INSERT INTO room_archives(room_code,archived,revision,archived_at,updated_at) VALUES(?,1,1,1,1)').run(code); + const before = f.rooms(), result = await f.apply(); + assert.equal(result.room.archived, true); assert.equal(result.room.closed, true); assert.equal(result.room.players[0].muted, true); assert.deepEqual(f.rooms(), before); + } finally { f.sqlite.close(); } +}); +for (const [name, sql] of [ + ['operator demotion', "UPDATE admin_accounts SET role='viewer'"], ['operator disable', 'UPDATE admin_accounts SET enabled=0'], + ['session revocation', 'UPDATE admin_sessions SET revoked_at=1'], ['session generation', 'UPDATE admin_accounts SET session_generation=session_generation+1'], + ['session expiration', 'UPDATE admin_sessions SET expires_at=1'], ['room version', 'UPDATE rooms SET version=version+1'], + ['room removal', "INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES('MUTEQAAA',1,1,1,1)"], + ['human conversion', "UPDATE rooms SET state=json_set(state,'$.players[0].bot','Easy')"], + ['membership removal', "UPDATE rooms SET state=json_set(state,'$.players',json('[]'))"], +]) void test(`discussion write fences ${name} at commit`, async () => { + const f = await fixture(); + try { + let batches = 0; + f.hooks.beforeBatch = async () => { if (++batches === 2) { delete f.hooks.beforeBatch; f.sqlite.exec(sql); } }; + await assert.rejects(f.apply, denied); assert.equal(f.controls().length, 0); assert.equal(f.audits().length, 0); + } finally { f.sqlite.close(); } +}); +void test('failed moderation audit rolls back metadata', async () => { + const f = await fixture(); + try { + const before = f.rooms(); + f.sqlite.exec("CREATE TRIGGER reject_receipt BEFORE INSERT ON admin_discussion_operations BEGIN SELECT RAISE(ABORT,'audit blocked'); END;"); + await assert.rejects(f.apply, /audit blocked/); assert.equal(f.controls().length, 0); assert.equal(f.audits().length, 0); assert.deepEqual(f.rooms(), before); + } finally { f.sqlite.close(); } +}); +for (const different of [false, true]) void test(`racing ${different ? 'conflicting' : 'identical'} discussion operations retain one audit`, async () => { + const f = await fixture(); + try { + const barrier = pauseCommit(f), pending = f.apply(); await barrier.waiting; + assert.equal((await f.apply(different ? { ...f.input, reason: 'Different reason' } : f.input)).replayed, false); + barrier.release(); + if (different) await assert.rejects(() => pending, denied); else assert.equal((await pending).replayed, true); + assert.equal(f.audits().length, 1); assert.equal(f.controls()[0].revision, 1); + } finally { f.sqlite.close(); } +}); +void test('mute/unmute race rejects stale metadata revision even though game version remains unchanged', async () => { + const f = await fixture(); + try { + const before = f.rooms(), barrier = pauseCommit(f), pending = f.apply(); await barrier.waiting; + await f.apply({ ...f.input, operationId: randomUUID() }); + await f.apply({ ...f.input, operationId: randomUUID(), expectedRevision: 1, muted: false }); + barrier.release(); await assert.rejects(() => pending, denied); + assert.equal(f.audits().length, 2); assert.equal(f.controls()[0].muted, 0); assert.deepEqual(f.rooms(), before); + } finally { f.sqlite.close(); } +}); +void test('viewer is read-only and malformed roster is rejected without reflecting private contents', async () => { + const f = await fixture('viewer'); + try { + assert.equal((await readAdminDiscussion(f.database, f.identity, code, now)).players.length, 1); + await assert.rejects(f.apply, denied); + f.sqlite.prepare('UPDATE rooms SET state=? WHERE code=?').run('{"status":"playing","players":"PRIVATE CREDENTIAL"}', code); + const view = await readAdminDiscussion(f.database, f.identity, code, now); + assert.equal(view.editable, false); assert.equal(view.status, 'unreadable'); assert.deepEqual(view.players, []); + assert.doesNotMatch(JSON.stringify(view), /PRIVATE CREDENTIAL/); assert.equal(f.audits().length, 0); + } finally { f.sqlite.close(); } +}); diff --git a/tests/table-talk-http.test.ts b/tests/table-talk-http.test.ts index 55bfd3ea..167c75cf 100644 --- a/tests/table-talk-http.test.ts +++ b/tests/table-talk-http.test.ts @@ -127,11 +127,11 @@ void test('discussion HTTP preserves sealed setup and room version; public and d undefined, f.c.cookie, ); - assert.deepEqual(outsider.data, { messages: [], before: null }); + assert.deepEqual(outsider.data, { messages: [], before: null, muted: false }); assert.deepEqual( (await request(endpoint + `?before=${direct.id}`, undefined, f.c.cookie)) .data, - { messages: [], before: null }, + { messages: [], before: null, muted: false }, ); const publicText = { id: crypto.randomUUID(), @@ -227,6 +227,6 @@ void test('discussion HTTP retries save once and seat recovery inherits conversa f.c.cookie, ) ).data, - { messages: [], before: null }, + { messages: [], before: null, muted: false }, ); }); diff --git a/tests/table-talk-recovery.test.ts b/tests/table-talk-recovery.test.ts index 7430fd9a..00767b20 100644 --- a/tests/table-talk-recovery.test.ts +++ b/tests/table-talk-recovery.test.ts @@ -87,7 +87,7 @@ void test('public and private discussion preserves game rows and hides direct ac assert.equal(JSON.stringify(publicPage).includes('PRIVATE'), false); assert.deepEqual( await f.talk.readTableTalk(f.code, f.auth[2], f.auth[0].playerId), - { messages: [], before: null }, + { messages: [], before: null, muted: false }, ); for (const [viewer, peer] of [ [0, 1], @@ -251,11 +251,11 @@ void test('pagination filters before limiting and never accepts a hidden message assert.equal(older.before, null); assert.deepEqual( await f.talk.readTableTalk(f.code, f.auth[2], null, privateId), - { messages: [], before: null }, + { messages: [], before: null, muted: false }, ); assert.deepEqual( await f.talk.readTableTalk(f.code, f.auth[2], null, crypto.randomUUID()), - { messages: [], before: null }, + { messages: [], before: null, muted: false }, ); } finally { f.sqlite.close(); @@ -347,3 +347,50 @@ void test('a credential rotation after a committed insert still returns that exa f.sqlite.close(); } }); + +void test('mute prevents new public/private sends, preserves incoming history and exact committed retries, and follows seat rotation', async () => { + const f = await fixture(); + try { + const before = gameRows(f), prior = draft('Private history before mute', f.auth[1].playerId); + await f.talk.sendTableTalk(f.code, f.auth[0], prior, 1000); + f.sqlite.prepare('INSERT INTO seat_discussion_controls(room_code,player_id,muted,revision,updated_at) VALUES(?,?,1,1,1)').run(f.code, f.auth[0].playerId); + for (const recipient of [null, f.auth[1].playerId]) + await assert.rejects(f.talk.sendTableTalk(f.code, f.auth[0], draft('Blocked', recipient), 2000), (error: unknown) => !!error && typeof error === 'object' && 'code' in error && error.code === 'SEAT_DISCUSSION_MUTED'); + assert.equal((await f.talk.sendTableTalk(f.code, f.auth[0], prior, 3000)).replayed, true); + await assert.rejects(f.talk.sendTableTalk(f.code, f.auth[0], { ...prior, text: 'Altered' }, 3000)); + await f.talk.sendTableTalk(f.code, f.auth[1], draft('Incoming remains available', f.auth[0].playerId), 4000); + const own = await f.talk.readTableTalk(f.code, f.auth[0], f.auth[1].playerId); + assert.equal(own.muted, true); assert.equal(own.messages.length, 2); + const observer = await f.talk.readTableTalk(f.code, f.auth[2], null); + assert.deepEqual(observer, { messages: [], before: null, muted: false }); + f.sqlite.prepare('UPDATE seats SET revoked=1 WHERE token_hash=?').run(f.auth[0].tokenHash); + const fresh = { ...f.auth[0], tokenHash: 'new-muted-controller' }; + f.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)').run(fresh.tokenHash, f.code, fresh.playerId); + await assert.rejects(f.talk.readTableTalk(f.code, f.auth[0], null)); + assert.deepEqual(await f.load().readTableTalk(f.code, fresh, f.auth[1].playerId), own); + await assert.rejects(f.talk.sendTableTalk(f.code, fresh, prior, 5000)); + await assert.rejects(f.talk.sendTableTalk(f.code, fresh, draft('Still muted'), 5000)); + f.sqlite.prepare('UPDATE seat_discussion_controls SET muted=0,revision=2').run(); + await f.talk.sendTableTalk(f.code, fresh, draft('Unmuted'), 6000); + assert.equal((await f.talk.readTableTalk(f.code, fresh, null)).muted, false); + assert.deepEqual(gameRows(f), before); + } finally { f.sqlite.close(); } +}); +for (const when of ['before', 'after'] as const) void test(`mute ${when} message insert respects commit order`, async () => { + const f = await fixture(); + try { + const input = draft('Racing mute'); + const hook = when === 'before' ? 'beforeStatement' : 'afterStatement'; + f.hooks[hook] = async sql => { + if (!sql.startsWith('INSERT INTO room_messages')) return; + delete f.hooks[hook]; + f.sqlite.prepare('INSERT INTO seat_discussion_controls(room_code,player_id,muted,revision,updated_at) VALUES(?,?,1,1,1)').run(f.code, f.auth[0].playerId); + }; + if (when === 'before') await assert.rejects(f.talk.sendTableTalk(f.code, f.auth[0], input, 1000)); + else { + assert.deepEqual(await f.talk.sendTableTalk(f.code, f.auth[0], input, 1000), { id: input.id, replayed: false }); + assert.equal((await f.talk.sendTableTalk(f.code, f.auth[0], input, 2000)).replayed, true); + } + assert.equal(f.sqlite.prepare('SELECT count(*) AS n FROM room_messages').get()!.n, when === 'before' ? 0 : 1); + } finally { f.sqlite.close(); } +}); diff --git a/tools/admin-discussion-verification.mjs b/tools/admin-discussion-verification.mjs new file mode 100644 index 00000000..4122e5c4 --- /dev/null +++ b/tools/admin-discussion-verification.mjs @@ -0,0 +1,45 @@ +// Creates its own QA room; never accepts an existing game for moderation. +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; + +export async function verifyDiscussionModeration(request, adminCookie, report) { + const made = await request('/api/rooms', { name: 'Discussion moderation QA', faction: 'atreides', advanced: false, expansions: [] }); + assert.equal(made.status, 201); + const code = made.data.code, host = made.cookie, path = '/api/rooms/' + code, adminPath = '/api/admin/rooms/' + code + '/discussion'; + report.discussionRoom = code; + const joined = await request(path, { type: 'join', name: 'Discussion guest QA', faction: 'emperor' }); + assert.equal(joined.status, 200); + const guest = joined.cookie, messages = path + '/messages'; + const prior = { id: randomUUID(), recipientId: joined.data.me, text: 'QA message before mute' }; + assert.equal((await request(messages, prior, host)).status, 200); + const before = (await request(path, undefined, host)).data; + const initial = await request(adminPath, undefined, adminCookie); assert.equal(initial.status, 200); + const input = { operationId: randomUUID(), expectedVersion: initial.data.version, expectedRevision: 0, target: made.data.me, muted: true, reason: 'Discussion moderation QA' }; + for (const denied of [ + await request(adminPath, input, host), await request(adminPath, input, adminCookie, 'https://foreign.invalid'), + await request(adminPath, input, adminCookie, undefined, randomUUID()), + ]) { assert.ok([401,403].includes(denied.status)); assert.equal(denied.rawCookie, null); } + const muted = await request(adminPath, input, adminCookie); + assert.equal(muted.status, 200); assert.equal(muted.rawCookie, null); assert.equal(muted.data.replayed, false); + assert.equal(muted.data.appliedRevision, 1); assert.equal(muted.data.room.version, initial.data.version); + assert.deepEqual(Object.keys(muted.data.room.players[0]).sort(), ['control','faction','id','muted','name','revision']); + assert.deepEqual((await request(path, undefined, host)).data, before); + for (const recipientId of [null, joined.data.me]) { + const denied = await request(messages, { id: randomUUID(), recipientId, text: 'Must be blocked' }, host); + assert.equal(denied.status, 409); assert.equal(denied.data.code, 'SEAT_DISCUSSION_MUTED'); + } + const own = await request(messages + '?recipient=' + joined.data.me, undefined, host); + assert.equal(own.status, 200); assert.equal(own.data.muted, true); assert.equal(own.data.messages[0].id, prior.id); + assert.equal((await request(messages, undefined, guest)).data.muted, false); + const retry = await request(messages, prior, host); assert.equal(retry.status, 200); assert.equal(retry.data.replayed, true); + assert.equal((await request(messages, { id: randomUUID(), recipientId: made.data.me, text: 'QA incoming message while muted' }, guest)).status, 200); + report.checks.push('Discussion moderation enforces live admin/account/origin authority, own-only mute status, new-send barrier and exact committed message retry without game changes'); + const undo = { ...input, operationId: randomUUID(), expectedRevision: 1, muted: false }; + assert.equal((await request(adminPath, undo, adminCookie)).status, 200); + const old = await request(adminPath, input, adminCookie); + assert.equal(old.status, 200); assert.equal(old.data.replayed, true); + assert.equal(old.data.room.players.find(player => player.id === input.target).muted, false); + assert.equal((await request(messages, undefined, host)).data.muted, false); + assert.deepEqual((await request(path, undefined, host)).data, before); + report.checks.push('Unmute restores sending availability; replaying an old admin request cannot mute again and preserves both human seats'); +} diff --git a/tools/verify-admin.mjs b/tools/verify-admin.mjs index 8a6c74fc..dbbc2076 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 { verifyDiscussionModeration } from './admin-discussion-verification.mjs'; import { verifyParticipantAi } from './admin-seat-ai-verification.mjs'; import { verifyRoomArchive } from './admin-archive-verification.mjs'; import { verifyRoomClosure } from './admin-closure-verification.mjs'; @@ -36,7 +37,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|archive|seat-ai)$/.test(path)) ? { 'X-Dune-Admin-Id': expectedAdminId } : {}) }, + ...(expectedAdminId && (path === '/api/admin/rooms' && body !== undefined || /^\/api\/admin\/rooms\/[^/]+\/(control|lobby|removal|closure|archive|seat-ai|discussion)$/.test(path)) ? { 'X-Dune-Admin-Id': expectedAdminId } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15000), }); report.lastRequest = { path, status: response.status }; @@ -95,6 +96,7 @@ try { await verifyRoomClosure(request, session1, report); await verifyRoomArchive(request, session1, report); await verifyParticipantAi(request, session1, report); + await verifyDiscussionModeration(request, session1, report); } report.passed = true; } catch (error) {