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

+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.',
);