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.
397 lines
14 KiB
TypeScript
397 lines
14 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import { readFileSync } from 'node:fs';
|
|
import { runInNewContext } from 'node:vm';
|
|
import ts from 'typescript';
|
|
import * as contract from '../lib/table-talk';
|
|
import type * as Talk from '../db/table-talk';
|
|
import { unitStore } from './fixture-nexus-room-store';
|
|
|
|
async function fixture() {
|
|
const store = unitStore();
|
|
assert.ok(store.sqlite instanceof DatabaseSync);
|
|
const a = await store.rooms.createRoom(
|
|
'Atreides speaker',
|
|
'atreides',
|
|
false,
|
|
[],
|
|
);
|
|
const b = await store.rooms.joinRoom(
|
|
a.view.code,
|
|
'Emperor listener',
|
|
'emperor',
|
|
);
|
|
const c = await store.rooms.joinRoom(a.view.code, 'Guild observer', 'guild');
|
|
const auth = await Promise.all(
|
|
[a, b, c].map((seat) => store.rooms.authenticate(a.view.code, seat.token!)),
|
|
);
|
|
function load() {
|
|
const exports = {};
|
|
runInNewContext(
|
|
ts.transpileModule(
|
|
readFileSync(new URL('../db/table-talk.ts', import.meta.url), 'utf8'),
|
|
{
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.CommonJS,
|
|
target: ts.ScriptTarget.ES2022,
|
|
},
|
|
},
|
|
).outputText,
|
|
{
|
|
exports,
|
|
JSON,
|
|
require: (name: string) => {
|
|
if (name === 'cloudflare:workers')
|
|
return { env: { DB: store.database } };
|
|
if (name === '../lib/table-talk') return contract;
|
|
throw new Error(`Unexpected module: ${name}`);
|
|
},
|
|
},
|
|
);
|
|
const loaded = exports as typeof Talk;
|
|
return {
|
|
sendTableTalk: async (...args: Parameters<typeof loaded.sendTableTalk>) =>
|
|
structuredClone(await loaded.sendTableTalk(...args)),
|
|
readTableTalk: async (...args: Parameters<typeof loaded.readTableTalk>) =>
|
|
structuredClone(await loaded.readTableTalk(...args)),
|
|
};
|
|
}
|
|
return { ...store, code: a.view.code, auth, talk: load(), load };
|
|
}
|
|
const draft = (text: string, recipientId: string | null = null) => ({
|
|
id: crypto.randomUUID(),
|
|
text,
|
|
recipientId,
|
|
});
|
|
const gameRows = (f: Awaited<ReturnType<typeof fixture>>) =>
|
|
f.sqlite.prepare('SELECT * FROM rooms').all();
|
|
|
|
void test('public and private discussion preserves game rows and hides direct activity from third seats', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const before = gameRows(f);
|
|
const publicText = draft('Can we discuss an alliance?');
|
|
const privateText = draft(
|
|
'PRIVATE OFFER: Arrakeen for Sietch Tabr.',
|
|
f.auth[1].playerId,
|
|
);
|
|
await f.talk.sendTableTalk(f.code, f.auth[0], publicText, 1000);
|
|
await f.talk.sendTableTalk(f.code, f.auth[0], privateText, 2000);
|
|
const publicPage = await f.talk.readTableTalk(f.code, f.auth[2], null);
|
|
assert.deepEqual(
|
|
publicPage.messages.map((m) => m.text),
|
|
[publicText.text],
|
|
);
|
|
assert.equal(publicPage.before, null);
|
|
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, muted: false },
|
|
);
|
|
for (const [viewer, peer] of [
|
|
[0, 1],
|
|
[1, 0],
|
|
]) {
|
|
const page = await f.talk.readTableTalk(
|
|
f.code,
|
|
f.auth[viewer],
|
|
f.auth[peer].playerId,
|
|
);
|
|
assert.equal(page.messages[0].text, privateText.text);
|
|
for (const key of [
|
|
'sequence',
|
|
'senderSessionHash',
|
|
'sender_session_hash',
|
|
'tokenHash',
|
|
])
|
|
assert.equal(JSON.stringify(page).includes(key), false);
|
|
}
|
|
assert.deepEqual(
|
|
await f.load().readTableTalk(f.code, f.auth[1], f.auth[0].playerId),
|
|
await f.talk.readTableTalk(f.code, f.auth[1], f.auth[0].playerId),
|
|
);
|
|
assert.deepEqual(gameRows(f), before);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('exact message retries are idempotent under races, and altered or foreign receipts cannot disclose or mutate', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const input = draft('One sealed offer.', f.auth[1].playerId);
|
|
const results = await Promise.all([
|
|
f.talk.sendTableTalk(f.code, f.auth[0], input, 5000),
|
|
f.talk.sendTableTalk(f.code, f.auth[0], input, 5000),
|
|
]);
|
|
assert.deepEqual(
|
|
results.map((r) => r.replayed).sort((a, b) => Number(a) - Number(b)),
|
|
[false, true],
|
|
);
|
|
for (const [auth, changed] of [
|
|
[f.auth[0], { ...input, text: 'Altered' }],
|
|
[f.auth[0], { ...input, recipientId: null }],
|
|
[f.auth[2], input],
|
|
[f.auth[0], { ...input, senderId: f.auth[1].playerId }],
|
|
] as const)
|
|
await assert.rejects(f.talk.sendTableTalk(f.code, auth, changed, 6000));
|
|
assert.equal(
|
|
f.sqlite.prepare('SELECT count(*) AS n FROM room_messages').get()!.n,
|
|
1,
|
|
);
|
|
assert.equal(
|
|
(await f.talk.sendTableTalk(f.code, f.auth[0], input, 5000)).replayed,
|
|
true,
|
|
);
|
|
await assert.rejects(
|
|
f.talk.sendTableTalk(f.code, f.auth[0], draft('Too fast'), 5500),
|
|
);
|
|
await f.talk.sendTableTalk(
|
|
f.code,
|
|
f.auth[0],
|
|
draft('After interval'),
|
|
6000,
|
|
);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('SQL fences stale sender credentials and recipients at commit; new seat owner inherits history without reviving old sends', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const old = draft('Before recovery', f.auth[1].playerId);
|
|
await f.talk.sendTableTalk(f.code, f.auth[0], old, 1000);
|
|
f.hooks.beforeStatement = async (sql) => {
|
|
if (!sql.startsWith('INSERT INTO room_messages')) return;
|
|
delete f.hooks.beforeStatement;
|
|
f.sqlite
|
|
.prepare('UPDATE seats SET revoked = 1 WHERE token_hash = ?')
|
|
.run(f.auth[0].tokenHash);
|
|
};
|
|
await assert.rejects(
|
|
f.talk.sendTableTalk(f.code, f.auth[0], draft('Stale controller'), 2000),
|
|
);
|
|
await assert.rejects(f.talk.readTableTalk(f.code, f.auth[0], null));
|
|
const fresh = { ...f.auth[0], tokenHash: 'new-test-session' };
|
|
f.sqlite
|
|
.prepare(
|
|
'INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)',
|
|
)
|
|
.run(fresh.tokenHash, f.code, fresh.playerId);
|
|
assert.equal(
|
|
(await f.talk.readTableTalk(f.code, fresh, f.auth[1].playerId))
|
|
.messages[0].text,
|
|
old.text,
|
|
);
|
|
await assert.rejects(f.talk.sendTableTalk(f.code, fresh, old, 3000));
|
|
f.hooks.beforeStatement = async (sql) => {
|
|
if (!sql.startsWith('INSERT INTO room_messages')) return;
|
|
delete f.hooks.beforeStatement;
|
|
const game = await f.rooms.readRoom(f.code);
|
|
game.players = game.players.filter((p) => p.id !== f.auth[1].playerId);
|
|
f.sqlite
|
|
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
|
|
.run(JSON.stringify(game), f.code);
|
|
};
|
|
await assert.rejects(
|
|
f.talk.sendTableTalk(
|
|
f.code,
|
|
fresh,
|
|
draft('Recipient removed', f.auth[1].playerId),
|
|
4000,
|
|
),
|
|
);
|
|
await assert.rejects(f.talk.readTableTalk(f.code, f.auth[1], null));
|
|
assert.equal(
|
|
f.sqlite.prepare('SELECT count(*) AS n FROM room_messages').get()!.n,
|
|
1,
|
|
);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('pagination filters before limiting and never accepts a hidden message as a cursor', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const ids: string[] = [];
|
|
let privateId = '';
|
|
for (let index = 0; index < 54; index++) {
|
|
const message = draft(`Public ${index}`);
|
|
ids.push(message.id);
|
|
await f.talk.sendTableTalk(f.code, f.auth[0], message, index * 2000);
|
|
const secret = draft(`Private ${index}`, f.auth[1].playerId);
|
|
privateId = secret.id;
|
|
await f.talk.sendTableTalk(
|
|
f.code,
|
|
f.auth[0],
|
|
secret,
|
|
index * 2000 + 1000,
|
|
);
|
|
}
|
|
const first = await f.talk.readTableTalk(f.code, f.auth[2], null);
|
|
assert.equal(first.messages.length, 50);
|
|
assert.deepEqual(
|
|
first.messages.map((m) => m.id),
|
|
ids.slice(4),
|
|
);
|
|
assert.equal(first.before, ids[4]);
|
|
const older = await f.talk.readTableTalk(
|
|
f.code,
|
|
f.auth[2],
|
|
null,
|
|
first.before,
|
|
);
|
|
assert.deepEqual(
|
|
older.messages.map((m) => m.id),
|
|
ids.slice(0, 4),
|
|
);
|
|
assert.equal(older.before, null);
|
|
assert.deepEqual(
|
|
await f.talk.readTableTalk(f.code, f.auth[2], null, privateId),
|
|
{ messages: [], before: null, muted: false },
|
|
);
|
|
assert.deepEqual(
|
|
await f.talk.readTableTalk(f.code, f.auth[2], null, crypto.randomUUID()),
|
|
{ messages: [], before: null, muted: false },
|
|
);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('discussion remains independent of sealed decisions, AI ownership and game completion', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
for (const auth of f.auth)
|
|
await f.rooms.act(
|
|
f.code,
|
|
auth,
|
|
(await f.rooms.readRoom(f.code)).version,
|
|
{ type: 'ready' },
|
|
);
|
|
await f.rooms.act(
|
|
f.code,
|
|
f.auth[0],
|
|
(await f.rooms.readRoom(f.code)).version,
|
|
{ type: 'start' },
|
|
);
|
|
const game = await f.rooms.readRoom(f.code);
|
|
game.players[0].autopilot = 'Hard';
|
|
game.players[2].bot = 'Easy';
|
|
f.sqlite
|
|
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
|
|
.run(JSON.stringify(game), f.code);
|
|
const before = gameRows(f);
|
|
await f.talk.sendTableTalk(
|
|
f.code,
|
|
f.auth[0],
|
|
draft('Human owner can still talk.'),
|
|
1000,
|
|
);
|
|
await assert.rejects(
|
|
f.talk.sendTableTalk(
|
|
f.code,
|
|
f.auth[0],
|
|
draft('Bot recipient', f.auth[2].playerId),
|
|
2000,
|
|
),
|
|
);
|
|
await assert.rejects(
|
|
f.talk.sendTableTalk(f.code, f.auth[2], draft('Bot sender'), 2000),
|
|
);
|
|
await assert.rejects(f.talk.readTableTalk(f.code, f.auth[2], null));
|
|
assert.deepEqual(gameRows(f), before);
|
|
game.status = 'finished';
|
|
f.sqlite
|
|
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
|
|
.run(JSON.stringify(game), f.code);
|
|
await f.talk.sendTableTalk(
|
|
f.code,
|
|
f.auth[0],
|
|
draft('Postgame discussion.'),
|
|
3000,
|
|
);
|
|
} finally {
|
|
f.sqlite.close();
|
|
}
|
|
});
|
|
|
|
void test('a credential rotation after a committed insert still returns that exact successful receipt', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const input = draft('Committed before rotation.');
|
|
f.hooks.afterStatement = async (sql) => {
|
|
if (!sql.startsWith('INSERT INTO room_messages')) return;
|
|
delete f.hooks.afterStatement;
|
|
f.sqlite
|
|
.prepare('UPDATE seats SET revoked = 1 WHERE token_hash = ?')
|
|
.run(f.auth[0].tokenHash);
|
|
};
|
|
assert.deepEqual(
|
|
await f.talk.sendTableTalk(f.code, f.auth[0], input, 1000),
|
|
{ id: input.id, replayed: false },
|
|
);
|
|
assert.equal(
|
|
(await f.talk.readTableTalk(f.code, f.auth[1], null)).messages[0].id,
|
|
input.id,
|
|
);
|
|
await assert.rejects(f.talk.sendTableTalk(f.code, f.auth[0], input, 2000));
|
|
assert.equal(
|
|
f.sqlite.prepare('SELECT count(*) AS n FROM room_messages').get()!.n,
|
|
1,
|
|
);
|
|
} finally {
|
|
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(); }
|
|
});
|