Files
Dune/db/table-talk.ts
T
Quintine 98b1b3261b Add administrative room close and reopen controls
Preserve readable private tables, discussion and pending games while closure blocks new play, joins, messages, AI and seat mutations. Add live-authorized atomic closure audits, room-version/revision fences, dedicated controls and exact saved retries. Reopen with the saved pause/join settings and current credentials; restoring a removed but closed room keeps its AI stopped.

Verified types, lint, 5356 offline tests, 52 HTTP tests and build. Seventeen dedicated authenticated HTTP groups and independent privacy/persistence review passed. Desktop/mobile browser QA confirmed one close after a lost response and refresh, persistent readable notices, retained private draft, mobile reopening and saved-seat refresh. All 2,001 baseline rooms, 4,243 seats and related records preserved. Positive production admin acceptance awaits authorized QA access; full admin and rules gates remain open.
2026-09-22 12:31:02 +10:00

167 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { env } from 'cloudflare:workers';
import type { SeatAuth } from './rooms';
import {
TALK_PAGE_SIZE,
talkId,
validTalkDraft,
type TalkDraft,
type TalkMessage,
type TalkPage,
} from '../lib/table-talk';
export class TableTalkError extends Error {
constructor(
message: string,
readonly status = 409,
readonly code?: string,
) {
super(message);
}
}
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
(m.sender_id = ? AND m.recipient_id = s.player_id)))`;
export async function readTableTalk(
code: string,
auth: SeatAuth,
recipientId: string | null,
before: string | null = null,
): Promise<TalkPage> {
if (
(recipientId !== null &&
(!talkId(recipientId) || recipientId === auth.playerId)) ||
(before !== null && !talkId(before))
)
throw new TableTalkError('Choose a valid conversation.', 400);
const row = await database()
.prepare(`SELECT (
SELECT json_group_array(json(message)) FROM (
SELECT json_object('id', m.id, 'senderId', m.sender_id, 'senderName', m.sender_name,
'senderFaction', m.sender_faction, 'recipientId', m.recipient_id,
'recipientName', m.recipient_name, 'text', m.body, 'createdAt', m.created_at) AS message
FROM room_messages m WHERE m.room_code = r.code AND (${channel})
AND (? IS NULL OR m.sequence < (SELECT m.sequence FROM room_messages m
WHERE m.room_code = r.code AND m.id = ? AND (${channel})))
ORDER BY m.sequence DESC LIMIT ${TALK_PAGE_SIZE + 1}
)
) AS messages ${viewer}`)
.bind(
recipientId,
recipientId,
recipientId,
recipientId,
before,
before,
recipientId,
recipientId,
recipientId,
recipientId,
code,
auth.playerId,
auth.tokenHash,
)
.first<{ messages: string }>();
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 {
messages,
before: newestFirst.length > TALK_PAGE_SIZE ? messages[0].id : null,
};
}
export async function sendTableTalk(
code: string,
auth: SeatAuth,
input: unknown,
now = Date.now(),
) {
if (!validTalkDraft(input))
throw new TableTalkError('Enter a message of 1–1000 characters.', 400);
const draft: TalkDraft = input;
if (draft.recipientId === auth.playerId)
throw new TableTalkError('Choose another human seat.', 400);
const result = await database()
.prepare(`INSERT INTO room_messages
(room_code, id, sender_id, sender_session_hash, sender_name, sender_faction, recipient_id, recipient_name, body, created_at)
SELECT r.code, ?, s.player_id, s.token_hash, json_extract(p.value, '$.name'), json_extract(p.value, '$.faction'), ?,
(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 (? 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
AND recent.sender_id = s.player_id AND recent.created_at > ?)
ON CONFLICT(room_code, id) DO NOTHING`)
.bind(
draft.id,
draft.recipientId,
draft.recipientId,
draft.text,
now,
code,
auth.playerId,
auth.tokenHash,
draft.recipientId,
draft.recipientId,
now - 1000,
)
.run();
// The INSERT itself authenticated the writer. A later rotation must not turn
// a committed send into a definite rejection; this receipt contains only the caller's ID.
if (result.meta.changes === 1) return { id: draft.id, replayed: false };
// No foreign receipt/body is ever returned, even for a deliberately reused UUID.
const receipt = await database()
.prepare(`SELECT 1 AS accepted ${viewer}
AND EXISTS (SELECT 1 FROM room_messages m WHERE m.room_code = r.code AND m.id = ?
AND m.sender_id = s.player_id AND m.sender_session_hash = s.token_hash
AND m.recipient_id IS ? AND m.body = ?)`)
.bind(
code,
auth.playerId,
auth.tokenHash,
draft.id,
draft.recipientId,
draft.text,
)
.first<{ accepted: number }>();
if (!receipt) {
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');
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 };
}