Add recoverable administrator room removal and restoration

Preserve room JSON, seats, credentials and messages while removed rooms deny ordinary reads and writes. Add deliberate room-scoped controls, removed-directory filters, persistent player notices and live-authorized atomic version/revision audit receipts. Retain uncertain room/seat requests and hidden recovery drafts through removal; restore the prior pause/join settings without reviving revoked credentials.

Fix AI readiness after a joining human or human faction change so real lobby starts remain legal.

Verified types, lint, 5314 offline tests, 52 HTTP tests and build. Fourteen authenticated HTTP groups and independent privacy/persistence review passed. Desktop/mobile browser QA confirmed a saved removal after a lost response and refresh, one audit on exact retry, persistent notices and recovery draft continuity. All 1,945 baseline rooms, 4,121 seats and related records preserved. Positive authenticated production admin acceptance remains pending authorized QA operator access; full admin and rules gates remain open.
This commit is contained in:
Quintine committed 2026-09-22 12:01:22 +10:00
1 parent c0cc4de9e1
commit 238de3860d
44 files changed
+4626 -90

No files matched your search

+23 -2
View File
@@ -13,6 +13,7 @@ export class TableTalkError extends Error {
constructor(
message: string,
readonly status = 409,
readonly code?: string,
) {
super(message);
}
@@ -21,11 +22,26 @@ function database() {
if (!env.DB) throw new Error('Game storage is unavailable.');
return env.DB;
}
async function requireRoomNotRemoved(code: string) {
const removed = await database()
.prepare(
'SELECT 1 AS removed FROM room_removals WHERE room_code = ? AND removed = 1',
)
.bind(code)
.first();
if (removed)
throw new TableTalkError(
'An administrator recoverably removed this room. Keep your saved seat and retry details; access returns if the room is restored.',
410,
'ROOM_REMOVED',
);
}
// Every read and write checks the exact active session AND room membership.
// A separate message table keeps private activity out of game versions/logs.
const viewer = `FROM rooms r JOIN seats s ON s.room_code = r.code
JOIN json_each(r.state, '$.players') p ON json_extract(p.value, '$.id') = s.player_id
WHERE r.code = ? AND s.player_id = ? AND s.token_hash = ? AND s.revoked = 0
AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1)
AND json_extract(p.value, '$.bot') IS NULL`;
const channel = `(m.recipient_id IS NULL AND ? IS NULL) OR
(? IS NOT NULL AND ((m.sender_id = s.player_id AND m.recipient_id = ?) OR
@@ -71,7 +87,10 @@ export async function readTableTalk(
auth.tokenHash,
)
.first<{ messages: string }>();
if (!row) throw new TableTalkError('Your seat could not be verified.');
if (!row) {
await requireRoomNotRemoved(code);
throw new TableTalkError('Your seat could not be verified.');
}
const newestFirst: TalkMessage[] = JSON.parse(row.messages);
const messages = newestFirst.slice(0, TALK_PAGE_SIZE).reverse();
return {
@@ -134,9 +153,11 @@ export async function sendTableTalk(
draft.text,
)
.first<{ accepted: number }>();
if (!receipt)
if (!receipt) {
await requireRoomNotRemoved(code);
throw new TableTalkError(
'Message not confirmed. Check your seat and recipient, wait a second, then try again.',
);
}
return { id: draft.id, replayed: result.meta.changes === 0 };
}