Files
Dune/db/admin-lobby-configuration.ts
T
Quintine e896570f07
Verify and publish checkpoint container / container (push) Successful in 11m2s
Add optional short alliance victory
2026-10-28 21:40:24 +11:00

316 lines
10 KiB
TypeScript

import { AdminError, type AdminIdentity } from './admin-access';
import {
applyAdminLobbyAction,
projectAdminLobby,
AdminLobbyRuleError,
} from '../game/admin-lobby-configuration';
import {
validAdminLobbyInput,
type AdminLobbyInput,
type AdminLobbyResult,
type AdminLobbyView,
} from '../lib/admin-lobby-configuration';
import type { Game } from '../game/engine';
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.state,r.version,EXISTS (SELECT 1 FROM room_closures WHERE room_code = r.code AND closed = 1) AS closed,COALESCE(c.paused,0) AS paused,
EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1) AS removed,
COALESCE(c.join_locked,0) AS join_locked,COALESCE(c.revision,0) AS control_revision,c.updated_at AS control_updated_at,
(SELECT json_group_array(DISTINCT s.player_id) FROM seats s WHERE s.room_code = r.code AND s.revoked = 0) AS active_humans
FROM rooms r LEFT JOIN room_controls c ON c.room_code = r.code WHERE r.code = ? AND EXISTS (${authority})`;
const receiptSelect = `SELECT operation_id,actor_admin_id,room_code,request_hash,applied_version
FROM admin_lobby_operations WHERE operation_id = ? AND EXISTS (${mutationAuthority})`;
type Row = {
code: string;
state: string;
version: number;
removed: number;
closed: number;
paused: number;
join_locked: number;
control_revision: number;
control_updated_at: number | null;
active_humans: string;
};
type Receipt = {
operation_id: string;
actor_admin_id: string;
room_code: string;
request_hash: string;
applied_version: number;
};
function credentials(identity: AdminIdentity, code: string, now: number) {
if (
!identity ||
typeof identity.id !== 'string' ||
typeof identity.sessionHash !== 'string' ||
identity.sessionHash.length !== 64 ||
!/^[0-9a-f]{64}$/.test(identity.sessionHash)
)
throw new AdminError('Administrator sign-in required.', 401);
if (
typeof code !== 'string' ||
code.length !== 8 ||
!/^[A-Z0-9]{8}$/.test(code)
)
throw new AdminError('Use an 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,
);
}
function decode(row: Row) {
let game: unknown = null;
try {
game = JSON.parse(row.state);
} catch {
/* Unreadable saves get an allowlisted, non-editable projection. */
}
const activeHumans = new Set<string>(JSON.parse(row.active_humans));
const control = {
...(row.closed === 1 ? { closed: true as const } : {}),
paused: row.paused === 1,
joinLocked: row.join_locked === 1,
revision: row.control_revision,
updatedAt: row.control_updated_at,
};
const view = projectAdminLobby(
row.code,
row.version,
game,
activeHumans,
control,
);
if (row.closed === 1) { view.editable = false; view.blockedReason = 'Reopen this closed room before configuring its lobby.'; }
if (row.removed === 1) {
view.editable = false;
view.blockedReason =
'Restore this removed room before configuring its lobby.';
}
return {
game: game as Game,
activeHumans,
view,
};
}
function missing(code: string, version: number): AdminLobbyView {
return {
code,
version,
status: 'unreadable',
editable: false,
blockedReason: 'The room is no longer available.',
host: null,
advanced: false,
techTokens: false,
strongholdCards: false,
shortAllianceVictory: false,
players: [],
control: { paused: false, joinLocked: false, revision: 0, updatedAt: null },
};
}
function replay(
receipt: Receipt | undefined,
row: Row | undefined,
identity: AdminIdentity,
code: string,
requestHash: string,
replayed: boolean,
): AdminLobbyResult | null {
if (!receipt) return null;
if (
receipt.actor_admin_id !== identity.id ||
receipt.room_code !== code ||
receipt.request_hash !== requestHash
)
throw new AdminError(
'This lobby operation is bound to a different administrator or request.',
409,
);
return {
operationId: receipt.operation_id,
appliedVersion: receipt.applied_version,
replayed,
lobby: row ? decode(row).view : missing(code, receipt.applied_version),
};
}
const auditConfiguration = (view: AdminLobbyView) =>
JSON.stringify({
host: view.host,
advanced: view.advanced,
techTokens: view.techTokens,
strongholdCards: view.strongholdCards,
shortAllianceVictory: view.shortAllianceVictory,
players: view.players.map(
({ id, name, faction, bot, position, ready }) => ({
id,
name,
faction,
bot,
position,
ready,
}),
),
});
export async function readAdminLobby(
database: D1Database,
identity: AdminIdentity,
code: string,
now = Date.now(),
): Promise<AdminLobbyView> {
const args = credentials(identity, code, now);
const results = await database.batch([
database.prepare(authority).bind(...args),
database.prepare(roomSelect).bind(code, ...args),
]);
authorize(results[0]);
const row = results[1].results[0] as Row | undefined;
if (!row) throw new AdminError('Room not found.', 404);
return decode(row).view;
}
export async function configureAdminLobby(
database: D1Database,
identity: AdminIdentity,
code: string,
input: AdminLobbyInput,
now = Date.now(),
): Promise<AdminLobbyResult> {
const args = credentials(identity, code, now);
if (!validAdminLobbyInput(input))
throw new AdminError(
'Choose one supported lobby change, its current version, a fresh operation identifier and an operational reason.',
400,
);
const operationId = input.operationId,
expectedVersion = input.expectedVersion,
reason = input.reason.trim();
// Canonicalize keys so equivalent JSON requests bind the same exact intent.
const action = Object.fromEntries(
Object.entries(input.action).sort(([a], [b]) => a.localeCompare(b)),
) as AdminLobbyInput['action'];
const actionText = JSON.stringify(action);
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(
JSON.stringify({ expectedVersion, action, reason }),
),
);
const requestHash = 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;
const previous = replay(
before[1].results[0] as Receipt | undefined,
row,
identity,
code,
requestHash,
true,
);
if (previous) return previous;
if (!row) throw new AdminError('Room not found.', 404);
const snapshot = decode(row);
if (!snapshot.view.editable)
throw new AdminError(
snapshot.view.blockedReason ?? 'This lobby cannot be configured.',
409,
);
if (row.version !== expectedVersion)
throw new AdminError(
'The room changed. Refresh before choosing a new lobby operation.',
409,
);
snapshot.game.version = row.version;
let next: Game;
try {
next = applyAdminLobbyAction(snapshot.game, action, snapshot.activeHumans);
} catch (error) {
throw new AdminError(
error instanceof AdminLobbyRuleError
? error.message
: 'This saved lobby cannot be configured.',
409,
);
}
next.version = row.version + 1;
const afterView = projectAdminLobby(
code,
next.version,
next,
snapshot.activeHumans,
snapshot.view.control,
);
const hostTarget = action.type === 'assignHost' ? action.target : '';
const results = await database.batch([
database.prepare(authority).bind(...args),
database
.prepare(`UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ?
AND json_extract(state, '$.status') = 'lobby' AND EXISTS (${mutationAuthority})
AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1)
AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)
AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1)
AND NOT EXISTS (SELECT 1 FROM admin_lobby_operations WHERE operation_id = ?)
AND (? = '' OR EXISTS (SELECT 1 FROM seats WHERE room_code = rooms.code AND player_id = ? AND revoked = 0))`)
.bind(
JSON.stringify(next),
now,
code,
expectedVersion,
...args,
operationId,
hostTarget,
hostTarget,
),
database
.prepare(`INSERT INTO admin_lobby_operations(operation_id,actor_admin_id,room_code,request_hash,expected_version,applied_version,action,reason,before_configuration,after_configuration,created_at)
SELECT ?,?,?,?,?,?,?,?,?,?,? WHERE changes() = 1`)
.bind(
operationId,
identity.id,
code,
requestHash,
expectedVersion,
next.version,
actionText,
reason,
auditConfiguration(snapshot.view),
auditConfiguration(afterView),
now,
),
database.prepare(receiptSelect).bind(operationId, ...args),
database.prepare(roomSelect).bind(code, ...args),
]);
authorize(results[0], true);
const committed = replay(
results[3].results[0] as Receipt | undefined,
results[4].results[0] as Row | undefined,
identity,
code,
requestHash,
results[1].meta.changes !== 1,
);
if (committed) return committed;
throw new AdminError(
'The room, pause setting, host recipient or administrator access changed. Refresh before choosing a new lobby operation.',
409,
);
}