Add configured room creation to administration

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.
This commit is contained in:
Quintine committed 2026-09-22 11:03:12 +10:00
1 parent 8c922b446b
commit e7d4efc3db
21 files changed
+2855 -21

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
import { randomId } from './random-id';
import { validAdminRoomCreationInput, type AdminRoomCreationInput, type AdminRoomCreationResult } from './admin-room-creation';
type Storage = Pick<globalThis.Storage, 'getItem' | 'setItem' | 'removeItem'>;
export type AdminRoomCreationRecord = { kind: 'pending'; input: AdminRoomCreationInput } | { kind: 'completed'; result: AdminRoomCreationResult };
const key = (account: string) => `dune.admin-room-creation.v1:${account}`;
const uuid = (value: unknown) => typeof value === 'string' && value.length === 36 && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value);
export function validAdminRoomCreationResult(value: unknown): value is AdminRoomCreationResult {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const result = value as Record<string, unknown>;
return Object.keys(result).length === 5 && typeof result.code === 'string' && result.code.length === 8 && /^[A-Z2-9]{8}$/.test(result.code) &&
uuid(result.hostId) && uuid(result.operationId) && typeof result.replayed === 'boolean' && typeof result.hostAccess === 'boolean';
}
export function newAdminRoomCreationRequest(fields: Omit<AdminRoomCreationInput, 'operationId' | 'sessionToken'>): AdminRoomCreationInput {
const input = { ...fields, name: fields.name.trim(), reason: fields.reason.trim(), operationId: randomId(),
sessionToken: Array.from(globalThis.crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, '0')).join('') };
if (!validAdminRoomCreationInput(input)) throw new Error('Check the host, rules, AI seats and operational reason.');
return input;
}
export function readAdminRoomCreation(storage: Storage, account: string): AdminRoomCreationRecord | null {
const raw = storage.getItem(key(account));
if (raw === null) return null;
let value: unknown;
try { value = raw.length <= 4096 ? JSON.parse(raw) : null; } catch { value = null; }
if (value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 2) {
const record = value as Record<string, unknown>;
if (record.kind === 'pending' && validAdminRoomCreationInput(record.input)) return value as AdminRoomCreationRecord;
if (record.kind === 'completed' && validAdminRoomCreationResult(record.result)) return value as AdminRoomCreationRecord;
}
throw new Error('This tab’s saved room-creation record is unreadable. Keep it until you have checked the room directory; no new request has been sent.');
}
function write(storage: Storage, account: string, record: AdminRoomCreationRecord) {
const text = JSON.stringify(record);
storage.setItem(key(account), text);
if (storage.getItem(key(account)) !== text) throw new Error('The room-creation record could not be saved in this tab.');
}
export function saveAdminRoomCreation(storage: Storage, account: string, input: AdminRoomCreationInput) {
if (!validAdminRoomCreationInput(input)) throw new Error('Invalid room-creation request.');
const previous = readAdminRoomCreation(storage, account);
if (previous && (previous.kind !== 'pending' || JSON.stringify(previous.input) !== JSON.stringify(input)))
throw new Error('Resolve the saved room-creation record before creating another room.');
write(storage, account, { kind: 'pending', input });
}
export function completeAdminRoomCreation(storage: Storage, account: string, input: AdminRoomCreationInput, result: AdminRoomCreationResult) {
if (!validAdminRoomCreationResult(result) || result.operationId !== input.operationId) throw new Error('The server did not confirm this room-creation request.');
const previous = readAdminRoomCreation(storage, account);
if (!previous || previous.kind !== 'pending' || JSON.stringify(previous.input) !== JSON.stringify(input))
throw new Error('The saved room-creation record changed. Keep the current record and inspect the room directory.');
// Replace the private retry proof with a safe room receipt in one storage write.
write(storage, account, { kind: 'completed', result });
}
export function clearAdminRoomCreation(storage: Storage, account: string) {
storage.removeItem(key(account));
if (storage.getItem(key(account)) !== null) throw new Error('The local room-creation record could not be cleared.');
}
+95
View File
@@ -0,0 +1,95 @@
import { FACTIONS, type FactionId } from '../game/catalog';
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
export type AdminRoomCreationInput = {
operationId: string;
sessionToken: string;
name: string;
faction: FactionId;
advanced: boolean;
techTokens: boolean;
strongholdCards: boolean;
bots: { faction: FactionId; difficulty: Difficulty }[];
reason: string;
};
/** Only creation confirmation; it never contains a game or another seat's data. */
export type AdminRoomCreationResult = {
operationId: string;
code: string;
hostId: string;
replayed: boolean;
hostAccess: boolean;
};
const object = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
const exactKeys = (value: Record<string, unknown>, keys: string[]) =>
Object.keys(value).length === keys.length &&
keys.every((key) => Object.hasOwn(value, key));
const baseFaction = (value: unknown): value is FactionId =>
FACTIONS.some(
(faction) => faction.id === value && faction.expansion === 'base',
);
const noControls = (value: string) =>
value
.split('')
.every((char) => char.charCodeAt(0) >= 32 && char.charCodeAt(0) !== 127);
/** Matches the admitted ordinary lobby profile; actual engine actions construct it. */
export function validAdminRoomCreationInput(
value: unknown,
): value is AdminRoomCreationInput {
if (
!object(value) ||
!exactKeys(value, [
'operationId',
'sessionToken',
'name',
'faction',
'advanced',
'techTokens',
'strongholdCards',
'bots',
'reason',
]) ||
typeof value.operationId !== 'string' ||
value.operationId.length !== 36 ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
value.operationId,
) ||
typeof value.sessionToken !== 'string' ||
value.sessionToken.length !== 64 ||
!/^[0-9a-f]{64}$/.test(value.sessionToken) ||
typeof value.name !== 'string' ||
value.name.length > 256 ||
value.name.trim().length < 1 ||
value.name.trim().length > 32 ||
!noControls(value.name) ||
!baseFaction(value.faction) ||
typeof value.advanced !== 'boolean' ||
typeof value.techTokens !== 'boolean' ||
typeof value.strongholdCards !== 'boolean' ||
(value.strongholdCards && !value.advanced) ||
!Array.isArray(value.bots) ||
value.bots.length > 5 ||
typeof value.reason !== 'string' ||
!value.reason.trim() ||
value.reason.length > 300 ||
!noControls(value.reason)
)
return false;
const factions = new Set([value.faction]);
for (const bot of value.bots) {
if (
!object(bot) ||
!exactKeys(bot, ['faction', 'difficulty']) ||
!baseFaction(bot.faction) ||
!DIFFICULTIES.some((difficulty) => difficulty === bot.difficulty) ||
factions.has(bot.faction)
)
return false;
factions.add(bot.faction);
}
return true;
}