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

+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(); }
});