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.
This commit is contained in:
Quintine committed 2026-09-26 14:30:52 +10:00
1 parent 2cddf03932
commit 160fdc1f30
26 files changed
+3008 -33

No files matched your search

+4 -2
View File
@@ -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
+9 -4
View File
@@ -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<string | null>(null);
const [selectedSeatAi, setSelectedSeatAi] = useState<string | null>(null);
const [selectedRoom, setSelectedRoom] = useState<string | null>(null);
const [selectedLobby, setSelectedLobby] = useState<string | null>(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() {
</section> : <>
<section aria-labelledby="admin-rooms-title">
<div className="admin-section-heading"><div><h2 id="admin-rooms-title">Rooms</h2><p className="admin-secondary">Public room details. Cards, sealed choices and private discussion stay hidden.</p></div>
<div className="admin-room-actions">{account.role !== 'viewer' && <Button disabled={controlsBusy} onClick={() => { setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setCreatingRoom(true); }}>Create a room</Button>}
<div className="admin-room-actions">{account.role !== 'viewer' && <Button disabled={controlsBusy} onClick={() => { setSelectedDiscussion(null); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setCreatingRoom(true); }}>Create a room</Button>}
<Button variant="outline" disabled={controlsBusy} onClick={() => void load(applied, directory?.page ?? 1)}><RefreshCw aria-hidden="true" /> Refresh</Button></div></div>
{creatingRoom && account.role !== 'viewer' && <AdminRoomCreation key={account.id} accountId={account.id}
onClose={() => setCreatingRoom(false)} onDenied={accessChanged} onBusyChange={setOperationBusy}
@@ -148,6 +150,9 @@ export default function Administration() {
</select></label>
<Button type="submit" disabled={controlsBusy}>Apply filters</Button>
</form>
{selectedDiscussion && <AdminDiscussion key={`${account.id}:${selectedDiscussion}`} accountId={account.id} code={selectedDiscussion}
canManage={account.role !== 'viewer'} onClose={() => setSelectedDiscussion(null)} onDenied={accessChanged}
onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />}
{selectedSeatAi && <AdminSeatAi key={`${account.id}:${selectedSeatAi}`} accountId={account.id} code={selectedSeatAi}
canManage={account.role !== 'viewer'} onClose={() => setSelectedSeatAi(null)} onDenied={accessChanged}
onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />}
@@ -188,7 +193,7 @@ export default function Administration() {
<TableCell>{room.turn !== null && room.status !== 'lobby' ? `Turn ${room.turn} · ` : ''}{room.phase}
<p>{room.pending.label}{room.pending.owners.length > 0 ? ': ' + room.pending.owners.map(id => room.players.find(p => p.id === id)?.name).filter(Boolean).join(', ') : ''}</p>
</TableCell>
<TableCell><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedArchive(null); setSelectedClosure(null); setSelectedRemoval(null); setSelectedSeatAi(room.code); }}>Participant AI · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(room.code); }}>Archive or unarchive · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedArchive(null); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(room.code); }}>Close or reopen · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setSelectedSeatAi(null); setSelectedRoom(room.code); }}>Room controls · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setSelectedLobby(room.code); }}>Configure lobby · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(room.code); setSelectedClosure(null); setSelectedArchive(null); }}>Remove or restore · {room.code}</Button><p><time dateTime={new Date(room.updatedAt).toISOString()}>{new Date(room.updatedAt).toLocaleString()}</time></p><p className="admin-secondary">Version {room.version}</p></TableCell>
<TableCell><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedArchive(null); setSelectedClosure(null); setSelectedRemoval(null); setSelectedSeatAi(null); setSelectedDiscussion(room.code); }}>Discussion controls · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedArchive(null); setSelectedClosure(null); setSelectedRemoval(null); setSelectedDiscussion(null); setSelectedSeatAi(room.code); }}>Participant AI · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedDiscussion(null); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(room.code); }}>Archive or unarchive · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedArchive(null); setSelectedDiscussion(null); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(room.code); }}>Close or reopen · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setSelectedDiscussion(null); setSelectedSeatAi(null); setSelectedRoom(room.code); }}>Room controls · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedDiscussion(null); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setSelectedLobby(room.code); }}>Configure lobby · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedDiscussion(null); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(room.code); setSelectedClosure(null); setSelectedArchive(null); }}>Remove or restore · {room.code}</Button><p><time dateTime={new Date(room.updatedAt).toISOString()}>{new Date(room.updatedAt).toLocaleString()}</time></p><p className="admin-secondary">Version {room.version}</p></TableCell>
</TableRow>)}</TableBody></Table>}
<nav className="admin-pagination" aria-label="Room pages">
<Button variant="outline" disabled={controlsBusy || directory.page <= 1} onClick={() => void load(applied, 1)}>First</Button>
@@ -204,7 +209,7 @@ export default function Administration() {
{!confirmAll ? <Button variant="outline" disabled={controlsBusy} onClick={() => setConfirmAll(true)}>Sign out everywhere…</Button> :
<div className="admin-confirm"><p>End all your administrator sessions now?</p><Button disabled={controlsBusy} onClick={() => void sessionAction('logoutAll')}>Confirm sign out everywhere</Button><Button variant="outline" disabled={controlsBusy} onClick={() => setConfirmAll(false)}>Cancel</Button></div>}
</section>
<p className="admin-secondary admin-development">Administration preview: create and configure lobbies, assign hosts, pause/resume rooms, 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.</p>
<p className="admin-secondary admin-development">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.</p>
</>}
</main>;
}
@@ -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); }
}
+153
View File
@@ -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<AdminDiscussionView | null>(null);
const [target, setTarget] = useState('');
const [reason, setReason] = useState('');
const [confirmed, setConfirmed] = useState(false);
const [confirmationCode, setConfirmationCode] = useState('');
const [pending, setPending] = useState<AdminDiscussionInput | null>(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<HTMLHeadingElement>(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<unknown>(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<unknown>(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<unknown>(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<unknown>(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 <section className="admin-room-control" aria-labelledby="admin-discussion-title" aria-busy={loading || busy}>
<div className="admin-section-heading"><h2 id="admin-discussion-title" ref={title} tabIndex={-1}>Discussion controls · {code}</h2><Button variant="outline" disabled={busy} onClick={onClose}>Close discussion controls</Button></div>
{notice && <output className="admin-notice">{notice}</output>}
{loading ? <output>Loading participant discussion…</output> : <>
{room ? <>
<p>Room: <strong>{room.removed ? 'Removed' : room.archived ? 'Archived' : room.closed ? 'Closed' : 'Open'}</strong>. Game: <strong>{room.status}</strong>.</p>
<p>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.</p>
{room.closed && <p>The room is closed to all new messages. These settings also apply when it reopens.</p>}
{room.players.length > 0 && <ul>{room.players.map(player => <li key={player.id}>{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'}</li>)}</ul>}
<Button variant="outline" disabled={busy} onClick={() => void refresh()}>Refresh discussion settings</Button>
</> : <Button disabled={busy} onClick={() => void refresh()}>Retry loading discussion settings</Button>}
{!canManage ? <p>Your viewer role can inspect these settings. An owner or operator can mute or unmute a seat.</p> : storageProblem ? <div className="admin-confirm">
<p>No new changes can be sent until this tab’s unreadable retry record is resolved. Discarding the record does not cancel a server change.</p>
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy || !room} onChange={event => setConfirmed(event.target.checked)} />I have inspected room {code} and want to remove its local retry record.</label>
<Button disabled={busy || !room || !confirmed} onClick={discardUnreadableRecord}>Discard unreadable local record</Button>
</div> : initialRequestPending ? null : pending ? <div className="admin-confirm">
<p>Saved request: <strong>{pending.muted ? 'Mute' : 'Unmute'} discussion</strong> for <strong>{room?.players.find(player => player.id === pending.target)?.name ?? pending.target}</strong> in room <strong>{code}</strong>.</p><p>Reason: {pending.reason}</p>
<p>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.</p>
<p>After refresh or sign-in, use All rooms in both directory filters to find later removed or archived rooms and reopen Discussion controls.</p>
<Button disabled={busy} onClick={() => void submit(pending)}>Retry saved request</Button>
</div> : room && !room.editable ? <p>{room.blockedReason}</p> : room && !room.players.some(player => player.control !== 'ai') ? <p>No human seats are available for discussion moderation.</p> : room && <form onSubmit={event => { event.preventDefault(); void submit(); }}>
<label htmlFor="admin-discussion-target">Participant<select id="admin-discussion-target" required value={target} disabled={busy} onChange={event => { setTarget(event.target.value); setConfirmed(false); }}>
<option value="">Choose a human seat</option>{room.players.filter(player => player.control !== 'ai').map(player => <option key={player.id} value={player.id}>{player.name} · {FACTIONS.find(faction => faction.id === player.faction)?.name ?? player.faction} · {player.muted ? 'Muted' : 'Allowed'}</option>)}
</select></label>
<label htmlFor="admin-discussion-reason">Reason for the audit history<Input id="admin-discussion-reason" value={reason} maxLength={300} required disabled={busy} onChange={event => { setReason(event.target.value); setConfirmed(false); }} /></label>
<p className="admin-secondary">Use a short operational reason. Do not copy private messages, credentials or private game information.</p>
<div className="admin-confirm">
<p>{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.</p>
<p>Game decisions, AI control, seat access, recovery and saved messages remain unchanged. Previously saved sends can still be confirmed.</p>
<label htmlFor="admin-discussion-code">Type {code} to confirm this room<Input id="admin-discussion-code" value={confirmationCode} maxLength={8} autoComplete="off" autoCapitalize="characters" spellCheck={false} disabled={busy} onChange={event => { setConfirmationCode(event.target.value); setConfirmed(false); }} /></label>
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy || !selected} onChange={event => setConfirmed(event.target.checked)} />I have reviewed this seat and understand how its discussion setting affects current and future owners.</label>
<Button type="submit" disabled={busy || !selected || !confirmed || !reason.trim() || confirmationCode !== code}>{selected?.muted ? 'Unmute discussion' : 'Mute discussion'}</Button>
</div>
</form>}
</>}
</section>;
}
+14 -7
View File
@@ -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<TalkDraft | null>(null);
const [pageChannel, setPageChannel] = useState<string | null>(null);
const [page, setPage] = useState<TalkPage>({ messages: [], before: null });
const [page, setPage] = useState<TalkPage>({ 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<TalkPage>(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 }) {
<p className="muted">No messages loaded in this conversation.</p>
)}
{closed && <p className="notice">This room is closed. Discussion history remains available; an exact saved message can still be confirmed.</p>}
{muted && <p className="notice">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. <Button variant="outline" onClick={() => setRefresh(value => value + 1)}>Refresh discussion availability</Button></p>}
<form
onSubmit={(event) => {
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)
+97
View File
@@ -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<string, unknown> => !!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<AdminDiscussionView> {
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<AdminDiscussionResult> {
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);
}
+26
View File
@@ -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',
{
+10 -2
View File
@@ -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.',
);
+67
View File
@@ -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.
+5 -2
View File
@@ -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
+13 -7
View File
@@ -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.
+16
View File
@@ -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
+10
View File
@@ -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
+26
View File
@@ -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)
);
File diff suppressed because it is too large. Load diff
+7
View File
@@ -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
}
]
}
+44
View File
@@ -0,0 +1,44 @@
import { randomId } from './random-id';
import { validAdminDiscussionInput, validAdminDiscussionView, type AdminDiscussionInput, type AdminDiscussionView, type AdminDiscussionResult } from './admin-discussion';
type Storage = Pick<globalThis.Storage, 'getItem' | 'setItem' | 'removeItem'>;
const key = (account: string, code: string) => `dune.admin-discussion-request.v1:${account}:${code}`;
const object = (value: unknown): value is Record<string, unknown> => !!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.');
}
+35
View File
@@ -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<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
const keys = (value: Record<string, unknown>, names: string[]) => Object.keys(value).length === names.length && names.every(name => Object.hasOwn(value, name));
const 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));
}
+1 -1
View File
@@ -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);
+8 -1
View File
@@ -13,7 +13,7 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden
assert.ok(cookie);
const room = (await created.json() as { code: string }).code;
for (const headers of [{}, { cookie }, { cookie: 'dune_admin_session=' + 'a'.repeat(64) }, { 'oai-authenticated-user-id': 'owner', 'oai-authenticated-user-email': 'owner@example.test' }] as Record<string, string>[]) {
for (const path of ['/api/admin/session', '/api/admin/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`, `/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' }),
+54
View File
@@ -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<string, string>();
return { values, getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); }, removeItem: (key: string) => { values.delete(key); } };
}
void test('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));
});
+118
View File
@@ -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<ReturnType<typeof fixture>>;
function pauseCommit(f: Fixture) {
let enter!: () => void, release!: () => void, calls = 0;
const waiting = new Promise<void>(resolve => { enter = resolve; }), gate = new Promise<void>(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(); }
});
+3 -3
View File
@@ -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 },
);
});
+50 -3
View File
@@ -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(); }
});
+45
View File
@@ -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');
}
+3 -1
View File
@@ -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) {