Create an explicitly owned new host seat with supported rules/modules and ordered AI seats through one live-authorized transaction. Save exact account-bound retries across refresh, record durable audit metadata, and never restore a revoked or transferred host credential. Connect invitation joining and ordinary voluntary host handover. Preserve normal readiness and Advanced preview gates. Verified types, lint, 5240 offline tests, 52 HTTP tests and build. Eight authenticated HTTP groups and independent privacy/persistence review passed. Browser QA confirmed one room after a lost response and refresh/retry, working host controls and saved continuation. Mobile layout passed; requested desktop viewport override did not take effect. Existing games preserved. Authenticated deployed administrator acceptance remains pending explicit QA access.
211 lines
7.5 KiB
TypeScript
211 lines
7.5 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 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 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;
|
||
}
|
||
| 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,
|
||
};
|
||
}
|
||
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,
|
||
);
|
||
}
|