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

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