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.
215 lines
7.8 KiB
TypeScript
215 lines
7.8 KiB
TypeScript
import { applyAction, createGame, newPlayer } from '../game/engine';
|
||
import { AdminError, type AdminIdentity } from './admin-access';
|
||
import {
|
||
validAdminRoomCreationInput,
|
||
type AdminRoomCreationInput,
|
||
type AdminRoomCreationResult,
|
||
} from '../lib/admin-room-creation';
|
||
|
||
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 MAX_CODE_ATTEMPTS = 5;
|
||
|
||
async function hash(value: string) {
|
||
const digest = await crypto.subtle.digest(
|
||
'SHA-256',
|
||
new TextEncoder().encode(value),
|
||
);
|
||
return Array.from(new Uint8Array(digest), (byte) =>
|
||
byte.toString(16).padStart(2, '0'),
|
||
).join('');
|
||
}
|
||
function roomCode() {
|
||
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||
return Array.from(
|
||
crypto.getRandomValues(new Uint8Array(8)),
|
||
(byte) => alphabet[byte % alphabet.length],
|
||
).join('');
|
||
}
|
||
function configuration(input: AdminRoomCreationInput) {
|
||
return {
|
||
name: input.name.trim(),
|
||
faction: input.faction,
|
||
advanced: input.advanced,
|
||
techTokens: input.techTokens,
|
||
strongholdCards: input.strongholdCards,
|
||
bots: input.bots.map((bot) => ({
|
||
faction: bot.faction,
|
||
difficulty: bot.difficulty,
|
||
})),
|
||
};
|
||
}
|
||
function createLobby(
|
||
code: string,
|
||
hostId: string,
|
||
input: ReturnType<typeof configuration>,
|
||
) {
|
||
let game = createGame(
|
||
code,
|
||
newPlayer(hostId, input.name, input.faction),
|
||
input.advanced,
|
||
[],
|
||
);
|
||
for (const bot of input.bots)
|
||
game = applyAction(game, hostId, { type: 'addBot', ...bot });
|
||
if (input.techTokens)
|
||
game = applyAction(game, hostId, { type: 'techTokens', enabled: true });
|
||
if (input.strongholdCards)
|
||
game = applyAction(game, hostId, {
|
||
type: 'strongholdCards',
|
||
enabled: true,
|
||
});
|
||
return game;
|
||
}
|
||
|
||
/** Creation and its receipt are one live-authorized transaction. Replays never restore revoked access. */
|
||
export async function createAdminRoom(
|
||
database: D1Database,
|
||
identity: AdminIdentity,
|
||
input: AdminRoomCreationInput,
|
||
now = Date.now(),
|
||
): Promise<AdminRoomCreationResult> {
|
||
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 (!validAdminRoomCreationInput(input))
|
||
throw new AdminError(
|
||
'Choose a host name, unique base factions, valid AI difficulties and supported lobby rules, with a fresh private retry proof and a reason of 1–300 characters.',
|
||
400,
|
||
);
|
||
const requestedConfiguration = configuration(input);
|
||
const publicConfiguration = JSON.stringify(requestedConfiguration);
|
||
const operationId = input.operationId;
|
||
const reason = input.reason.trim();
|
||
const [sessionHash, requestHash] = await Promise.all([
|
||
hash(input.sessionToken),
|
||
hash(JSON.stringify({ configuration: requestedConfiguration, reason })),
|
||
]);
|
||
const args = [identity.sessionHash, identity.id, now];
|
||
const hostId = crypto.randomUUID();
|
||
for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
|
||
const code = roomCode();
|
||
const game = createLobby(code, hostId, requestedConfiguration);
|
||
const results = await database.batch([
|
||
database.prepare(authority).bind(...args),
|
||
database
|
||
.prepare(`INSERT INTO rooms(code,state,version,updated_at)
|
||
SELECT ?,?,0,? WHERE EXISTS (${mutationAuthority})
|
||
AND NOT EXISTS (SELECT 1 FROM admin_room_creations WHERE operation_id = ? OR session_hash = ?)
|
||
AND NOT EXISTS (SELECT 1 FROM seats WHERE token_hash = ?)
|
||
AND NOT EXISTS (SELECT 1 FROM rooms WHERE code = ?)`)
|
||
.bind(
|
||
code,
|
||
JSON.stringify(game),
|
||
now,
|
||
...args,
|
||
operationId,
|
||
sessionHash,
|
||
sessionHash,
|
||
code,
|
||
),
|
||
database
|
||
.prepare(`INSERT INTO seats(token_hash,room_code,player_id)
|
||
SELECT ?,?,? WHERE changes() = 1`)
|
||
.bind(sessionHash, code, hostId),
|
||
database
|
||
.prepare(`INSERT INTO admin_room_creations
|
||
(operation_id,actor_admin_id,room_code,host_id,request_hash,session_hash,configuration,reason,created_at)
|
||
SELECT ?,?,?,?,?,?,?,?,? WHERE changes() = 1`)
|
||
.bind(
|
||
operationId,
|
||
identity.id,
|
||
code,
|
||
hostId,
|
||
requestHash,
|
||
sessionHash,
|
||
publicConfiguration,
|
||
reason,
|
||
now,
|
||
),
|
||
database
|
||
.prepare(`SELECT c.operation_id,c.actor_admin_id,c.room_code,c.host_id,c.request_hash,c.session_hash,
|
||
EXISTS (SELECT 1 FROM room_removals WHERE room_code = c.room_code AND removed = 1) AS room_removed,
|
||
EXISTS (SELECT 1 FROM seats s JOIN rooms r ON r.code = s.room_code
|
||
WHERE s.token_hash = c.session_hash AND s.room_code = c.room_code AND s.player_id = c.host_id AND s.revoked = 0
|
||
AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1)
|
||
AND CASE WHEN json_valid(r.state) THEN EXISTS (
|
||
SELECT 1 FROM json_each(CASE WHEN json_type(r.state, '$.players') = 'array' THEN json_extract(r.state, '$.players') ELSE '[]' END) p
|
||
WHERE json_extract(CASE WHEN p.type = 'object' THEN p.value ELSE '{}' END, '$.id') = c.host_id
|
||
) ELSE 0 END) AS host_access
|
||
FROM admin_room_creations c WHERE c.operation_id = ? AND EXISTS (${mutationAuthority})`)
|
||
.bind(operationId, ...args),
|
||
database
|
||
.prepare(`SELECT
|
||
EXISTS (SELECT 1 FROM seats WHERE token_hash = ?) OR EXISTS (SELECT 1 FROM admin_room_creations WHERE session_hash = ?) AS token_used,
|
||
EXISTS (SELECT 1 FROM rooms WHERE code = ?) AS code_used
|
||
WHERE EXISTS (${mutationAuthority})`)
|
||
.bind(sessionHash, sessionHash, code, ...args),
|
||
]);
|
||
const role = (results[0].results[0] as { role: string } | undefined)?.role;
|
||
if (!role) throw new AdminError('Administrator sign-in required.', 401);
|
||
if (role === 'viewer')
|
||
throw new AdminError(
|
||
'Your administrator role does not permit this action.',
|
||
403,
|
||
);
|
||
const receipt = results[4].results[0] as
|
||
| {
|
||
operation_id: string;
|
||
actor_admin_id: string;
|
||
room_code: string;
|
||
host_id: string;
|
||
request_hash: string;
|
||
session_hash: string;
|
||
host_access: number;
|
||
room_removed: number;
|
||
}
|
||
| undefined;
|
||
if (receipt) {
|
||
if (
|
||
receipt.actor_admin_id !== identity.id ||
|
||
receipt.request_hash !== requestHash ||
|
||
receipt.session_hash !== sessionHash
|
||
)
|
||
throw new AdminError(
|
||
'This creation operation is bound to different settings, private proof or administrator.',
|
||
409,
|
||
);
|
||
return {
|
||
operationId: receipt.operation_id,
|
||
code: receipt.room_code,
|
||
hostId: receipt.host_id,
|
||
replayed: results[1].meta.changes !== 1,
|
||
hostAccess: receipt.host_access === 1,
|
||
...(receipt.room_removed === 1 ? { roomRemoved: true as const } : {}),
|
||
};
|
||
}
|
||
const conflict = results[5].results[0] as {
|
||
token_used: number;
|
||
code_used: number;
|
||
};
|
||
if (conflict.token_used)
|
||
throw new AdminError(
|
||
'This host session proof has already been used. Start a new creation request with a fresh private proof.',
|
||
409,
|
||
);
|
||
if (!conflict.code_used)
|
||
throw new AdminError(
|
||
'Room creation could not be confirmed. Retry the exact saved request.',
|
||
409,
|
||
);
|
||
}
|
||
throw new AdminError(
|
||
'No unused room invitation could be reserved. Retry the exact saved request.',
|
||
409,
|
||
);
|
||
}
|