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.
98 lines
9.6 KiB
TypeScript
98 lines
9.6 KiB
TypeScript
import { AdminError, type AdminIdentity } from './admin-access';
|
|
import { FACTIONS } from '../game/catalog';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { validAdminDiscussionInput, type AdminDiscussionInput, type AdminDiscussionView, type AdminDiscussionResult } from '../lib/admin-discussion';
|
|
|
|
const authority = `SELECT a.role FROM admin_sessions s JOIN admin_accounts a ON a.id=s.admin_id
|
|
WHERE s.token_hash=? AND a.id=? AND a.enabled=1 AND s.revoked_at IS NULL
|
|
AND s.expires_at>? AND s.generation=a.session_generation AND a.role IN ('owner','operator','viewer')`;
|
|
const mutationAuthority = authority + " AND a.role IN ('owner','operator')";
|
|
const roomSelect = `SELECT r.code,r.version,r.state,
|
|
EXISTS (SELECT 1 FROM room_removals WHERE room_code=r.code AND removed=1) AS removed,
|
|
EXISTS (SELECT 1 FROM room_closures WHERE room_code=r.code AND closed=1) AS closed,
|
|
EXISTS (SELECT 1 FROM room_archives WHERE room_code=r.code AND archived=1) AS archived,
|
|
(SELECT json_group_array(json_object('id',player_id,'muted',muted,'revision',revision)) FROM seat_discussion_controls WHERE room_code=r.code) AS controls
|
|
FROM rooms r WHERE r.code=? AND EXISTS (${authority})`;
|
|
const receiptSelect = `SELECT operation_id,actor_admin_id,room_code,request_hash,expected_version,applied_revision
|
|
FROM admin_discussion_operations WHERE operation_id=? AND EXISTS (${mutationAuthority})`;
|
|
type Row = { code: string; version: number; state: string; removed: number; closed: number; archived: number; controls: string };
|
|
type Receipt = { operation_id: string; actor_admin_id: string; room_code: string; request_hash: string; expected_version: number; applied_revision: number };
|
|
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
const counter = (value: unknown) => Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) < Number.MAX_SAFE_INTEGER;
|
|
function argsFor(identity: AdminIdentity, code: string, now: number) {
|
|
if (!identity || typeof identity.id !== 'string' || typeof identity.sessionHash !== 'string' || !/^[0-9a-f]{64}$/.test(identity.sessionHash)) throw new AdminError('Administrator sign-in required.', 401);
|
|
if (typeof code !== 'string' || code.length !== 8 || !/^[A-Z2-9]{8}$/.test(code)) throw new AdminError('Enter the eight-character room code.', 400);
|
|
return [identity.sessionHash, identity.id, now];
|
|
}
|
|
function authorize(result: D1Result, mutation = false) {
|
|
const role = (result.results[0] as { role: string } | undefined)?.role;
|
|
if (!role) throw new AdminError('Administrator sign-in required.', 401);
|
|
if (mutation && role === 'viewer') throw new AdminError('Your administrator role does not permit this action.', 403);
|
|
}
|
|
export function projectAdminDiscussion(row: Row): AdminDiscussionView {
|
|
const base: AdminDiscussionView = { code: row.code, version: row.version, removed: row.removed === 1, closed: row.closed === 1, archived: row.archived === 1,
|
|
status: 'unreadable', editable: false, blockedReason: 'This room needs inspection before changing participant discussion.', players: [] };
|
|
try {
|
|
const game: unknown = JSON.parse(row.state), controls: unknown = JSON.parse(row.controls);
|
|
if (!object(game) || !['lobby','setup','playing','finished'].includes(String(game.status)) || !Array.isArray(game.players) || game.players.length < 1 || game.players.length > 6 ||
|
|
!game.players.every(player => object(player) && typeof player.id === 'string' && !!player.id.trim() && player.id.length <= 80 && typeof player.name === 'string' && player.name.length <= 80 &&
|
|
FACTIONS.some(faction => faction.id === player.faction) && [player.bot, player.autopilot].every(value => value === undefined || DIFFICULTIES.some(difficulty => difficulty === value))) ||
|
|
new Set(game.players.map(player => player.id)).size !== game.players.length || !Array.isArray(controls) || !controls.every(control => object(control) && typeof control.id === 'string' && [0,1].includes(Number(control.muted)) && counter(control.revision))) return base;
|
|
const players = game.players.map(player => {
|
|
const control = controls.find(control => control.id === player.id);
|
|
return { id: player.id as string, name: player.name as string, faction: player.faction as string,
|
|
control: player.bot ? 'ai' as const : player.autopilot ? 'autopilot' as const : 'human' as const, muted: control?.muted === 1, revision: control?.revision ?? 0 };
|
|
});
|
|
return { ...base, status: game.status as AdminDiscussionView['status'], editable: !base.removed, blockedReason: base.removed ? 'Restore this removed room before changing participant discussion.' : null, players };
|
|
} catch { return base; }
|
|
}
|
|
function replay(receipt: Receipt | undefined, row: Row | undefined, identity: AdminIdentity, code: string, hash: string, replayed: boolean): AdminDiscussionResult | null {
|
|
if (!receipt) return null;
|
|
if (receipt.actor_admin_id !== identity.id || receipt.room_code !== code || receipt.request_hash !== hash) throw new AdminError('This discussion operation belongs to a different administrator or request.', 409);
|
|
const room = row ? projectAdminDiscussion(row) : { code, version: receipt.expected_version, removed: true, closed: false, archived: false, status: 'unreadable' as const,
|
|
editable: false, blockedReason: 'The room is no longer available.', players: [] };
|
|
return { operationId: receipt.operation_id, appliedRevision: receipt.applied_revision, replayed, room };
|
|
}
|
|
export async function readAdminDiscussion(database: D1Database, identity: AdminIdentity, code: string, now = Date.now()): Promise<AdminDiscussionView> {
|
|
const args = argsFor(identity, code, now);
|
|
const result = await database.batch([database.prepare(authority).bind(...args), database.prepare(roomSelect).bind(code, ...args)]);
|
|
authorize(result[0]); const row = result[1].results[0] as Row | undefined;
|
|
if (!row) throw new AdminError('Room not found.', 404);
|
|
return projectAdminDiscussion(row);
|
|
}
|
|
export async function applyAdminDiscussion(database: D1Database, identity: AdminIdentity, code: string, input: AdminDiscussionInput, now = Date.now()): Promise<AdminDiscussionResult> {
|
|
const args = argsFor(identity, code, now);
|
|
if (!validAdminDiscussionInput(input)) throw new AdminError('Choose a human participant, discussion setting, current versions and a short operational reason.', 400);
|
|
const { operationId, expectedVersion, expectedRevision, target, muted } = input, reason = input.reason.trim();
|
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify({ expectedVersion, expectedRevision, target, muted, reason })));
|
|
const hash = Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('');
|
|
const before = await database.batch([database.prepare(authority).bind(...args), database.prepare(receiptSelect).bind(operationId, ...args), database.prepare(roomSelect).bind(code, ...args)]);
|
|
authorize(before[0], true);
|
|
const row = before[2].results[0] as Row | undefined, previous = replay(before[1].results[0] as Receipt | undefined, row, identity, code, hash, true);
|
|
if (previous) return previous;
|
|
if (!row) throw new AdminError('Room not found.', 404);
|
|
const view = projectAdminDiscussion(row), player = view.players.find(player => player.id === target && player.control !== 'ai');
|
|
if (!view.editable) throw new AdminError(view.blockedReason ?? 'Refresh participant availability.', 409);
|
|
if (view.version !== expectedVersion || !player || player.revision !== expectedRevision || player.muted === muted) throw new AdminError('The room or participant discussion setting changed. Refresh before choosing a new operation.', 409);
|
|
const result = await database.batch([
|
|
database.prepare(authority).bind(...args),
|
|
database.prepare(`INSERT INTO seat_discussion_controls(room_code,player_id,muted,revision,updated_at)
|
|
SELECT r.code,?,?,?,? FROM rooms r WHERE r.code=? AND r.version=? AND EXISTS (${mutationAuthority})
|
|
AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code=r.code AND removed=1)
|
|
AND EXISTS (SELECT 1 FROM json_each(r.state,'$.players') p WHERE json_extract(p.value,'$.id')=? AND json_extract(p.value,'$.bot') IS NULL)
|
|
AND COALESCE((SELECT revision FROM seat_discussion_controls WHERE room_code=r.code AND player_id=?),0)=?
|
|
AND COALESCE((SELECT muted FROM seat_discussion_controls WHERE room_code=r.code AND player_id=?),0)<>?
|
|
AND NOT EXISTS (SELECT 1 FROM admin_discussion_operations WHERE operation_id=?)
|
|
ON CONFLICT(room_code,player_id) DO UPDATE SET muted=excluded.muted,revision=excluded.revision,updated_at=excluded.updated_at
|
|
WHERE seat_discussion_controls.revision=?`)
|
|
.bind(target, Number(muted), expectedRevision + 1, now, code, expectedVersion, ...args, target, target, expectedRevision, target, Number(muted), operationId, expectedRevision),
|
|
database.prepare(`INSERT INTO admin_discussion_operations(operation_id,actor_admin_id,room_code,request_hash,expected_version,expected_revision,applied_revision,target,muted,reason,created_at)
|
|
SELECT ?,?,?,?,?,?,?,?,?,?,? WHERE changes()=1`).bind(operationId, identity.id, code, hash, expectedVersion, expectedRevision, expectedRevision + 1, target, Number(muted), reason, now),
|
|
database.prepare(receiptSelect).bind(operationId, ...args), database.prepare(roomSelect).bind(code, ...args),
|
|
]);
|
|
authorize(result[0], true);
|
|
const committed = replay(result[3].results[0] as Receipt | undefined, result[4].results[0] as Row | undefined, identity, code, hash, result[1].meta.changes !== 1);
|
|
if (committed) return committed;
|
|
throw new AdminError('The room, participant setting or administrator access changed. Refresh before choosing a new operation.', 409);
|
|
}
|