From e7d4efc3db09ba03f9b8409b8735a4f2eab2d058 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:03:12 +1000 Subject: [PATCH] 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. --- README.md | 4 +- app/admin/admin.css | 7 + app/admin/page.tsx | 16 +- app/api/admin/rooms/route.ts | 22 +- components/admin-room-creation.tsx | 145 +++ db/admin-room-creation.ts | 210 ++++ db/schema.ts | 20 + docs/ADMIN_PANEL.md | 8 +- docs/ADMIN_ROOM_CREATION.md | 88 ++ docs/CURRENT_STATUS.md | 24 +- docs/IMPLEMENTATION_STATUS.md | 10 + drizzle/0010_admin_room_creation.sql | 14 + drizzle/meta/0010_snapshot.json | 1243 ++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/admin-room-creation-client.ts | 62 ++ lib/admin-room-creation.ts | 95 ++ tests/admin-boundary-http.test.ts | 8 + tests/admin-room-creation-client.test.ts | 63 ++ tests/admin-room-creation.test.ts | 769 +++++++++++++ tools/admin-creation-verification.mjs | 57 + tools/verify-admin.mjs | 4 +- 21 files changed, 2855 insertions(+), 21 deletions(-) create mode 100644 components/admin-room-creation.tsx create mode 100644 db/admin-room-creation.ts create mode 100644 docs/ADMIN_ROOM_CREATION.md create mode 100644 drizzle/0010_admin_room_creation.sql create mode 100644 drizzle/meta/0010_snapshot.json create mode 100644 lib/admin-room-creation-client.ts create mode 100644 lib/admin-room-creation.ts create mode 100644 tests/admin-room-creation-client.test.ts create mode 100644 tests/admin-room-creation.test.ts create mode 100644 tools/admin-creation-verification.mjs diff --git a/README.md b/README.md index 03dbe68b..414b8641 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,9 @@ The **Administration** link opens `/admin`, with separate administrator sign-in, session controls and a searchable room directory. **Room controls** lets owners and operators pause/resume rooms and lock/unlock new joins. See [room controls](docs/ADMIN_ROOM_CONTROLS.md) and [initial owner setup](docs/ADMIN_PANEL.md#initial-owner-and-access-recovery). -Room creation, archive/removal, participant support and backup tools are still being built. +Owners/operators can [create a configured lobby](docs/ADMIN_ROOM_CREATION.md) with +an explicitly owned new host seat and initial AI players. Neutral lobby management, +archive/removal, participant support and backup tools are still being built. ## Saved seats and reconnecting diff --git a/app/admin/admin.css b/app/admin/admin.css index 65626c45..57652119 100644 --- a/app/admin/admin.css +++ b/app/admin/admin.css @@ -38,12 +38,19 @@ .admin-room-control .admin-check input { width: 22px; height: 22px; min-height: 22px; flex-shrink: 0; accent-color: var(--primary); } .admin-room-control .admin-confirm { margin-top: 16px; } .admin-room-control h2:focus-visible { outline: 2px solid var(--primary); outline-offset: 5px; } +.admin-room-actions, .admin-created-result { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; } +.admin-created-result h3, .admin-created-result p { flex-basis: 100%; } +.admin-create-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr)); gap: 16px; } +.admin-create-bot { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; align-items: end; gap: 12px; margin-bottom: 16px; } +.admin-room-creation fieldset { min-width: 0; border: 1px solid var(--border); padding: 16px; } +.admin-room-link { display: inline-flex; min-height: 44px; align-items: center; padding: 8px 16px; border: 1px solid var(--primary); border-radius: 4px; color: var(--primary); } @media (max-width: 700px) { .admin-shell { padding: 20px 16px; } .admin-header, .admin-section-heading { align-items: start; flex-direction: column; gap: 16px; } .admin-header h1 { font-size: 1.8rem; } .admin-login { padding: 20px; margin: 24px 0; } .admin-room-control { padding: 18px; } + .admin-create-bot { grid-template-columns: 1fr; } .admin-room-table { display: block; } .admin-room-table thead { display: none; } .admin-room-table tbody { display: block; } diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 20cd9692..8d8958d1 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -9,6 +9,7 @@ import { ClientRequestError, requestJson } from '@/lib/client-request'; import type { AdminDirectory } from '@/lib/admin-directory'; import { FACTIONS } from '@/game/catalog'; import { AdminRoomControls } from '@/components/admin-room-controls'; +import { AdminRoomCreation } from '@/components/admin-room-creation'; import './admin.css'; type Account = { id: string; name: string; role: 'owner' | 'operator' | 'viewer' }; @@ -27,17 +28,18 @@ export default function Administration() { const [applied, setApplied] = useState(initialFilters); const [confirmAll, setConfirmAll] = useState(false); const [selectedRoom, setSelectedRoom] = useState(null); + const [creatingRoom, setCreatingRoom] = useState(false); const [operationBusy, setOperationBusy] = useState(false); const controlsBusy = busy || operationBusy; const epoch = useRef(0); const clearSession = useCallback(() => { epoch.current++; setAccount(null); setDirectory(null); setConfirmAll(false); - setBusy(false); setSelectedRoom(null); + setBusy(false); setSelectedRoom(null); setCreatingRoom(false); }, []); const accessChanged = useCallback(() => { clearSession(); - setNotice('Administrator access changed. Sign in again to confirm any saved room-settings request.'); + setNotice('Administrator access changed. Sign in again to confirm any saved room request.'); }, [clearSession]); const load = useCallback(async (next: Filters, page = 1) => { const current = ++epoch.current; @@ -109,7 +111,11 @@ export default function Administration() { : <>

Rooms

Public room details. Cards, sealed choices and private discussion stay hidden.

-
+
{account.role !== 'viewer' && } +
+ {creatingRoom && account.role !== 'viewer' && setCreatingRoom(false)} onDenied={accessChanged} onBusyChange={setOperationBusy} + onCreated={code => { const next = { ...initialFilters, q: code }; setFilters(next); void load(next); }} />}
{ event.preventDefault(); void load(filters); }}> + + : record?.kind === 'pending' ?
+

Unconfirmed room: host {record.input.name} · {record.input.advanced ? 'Advanced preview' : 'Basic'} · {record.input.bots.length} AI players.

+

The exact request is kept privately in this tab across refresh. Keep the tab and its storage until the result is confirmed. Retrying finds the same room.

+ +
: result ?
+

Room {result.code}

+

Share invitation code {result.code}. Players can enter it on the home page to join an available faction.

+ {result.hostAccess ? <>

When creation was confirmed, this browser received the host seat. Open the lobby to change settings, invite human players, or use Pass your seat to another player to give someone else the host seat.

Open your host seat :

The original host seat has since been transferred or its access revoked. This confirmation does not restore old seat access.

} + +
: { event.preventDefault(); void submit(); }}> +

Create a lobby with a new human host seat in this browser. Players join by invitation; AI seats can be included below. Nothing starts until the host and players complete normal readiness.

+
+ + + +
+ {advanced &&

Advanced preview is unfinished. Only the six classic factions are available, and starting requires the existing preview confirmation.

} + + +
Initial AI players +

The other seats remain available for human players. AI strategy tuning is still in development.

+ {bots.map((bot, index) =>
+ + + +
)} + +
+ +

Use an operational reason; leave out credentials and private game information.

+
+ +
+
} +
; +} diff --git a/db/admin-room-creation.ts b/db/admin-room-creation.ts new file mode 100644 index 00000000..a48f6735 --- /dev/null +++ b/db/admin-room-creation.ts @@ -0,0 +1,210 @@ +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, +) { + 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 { + 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, + ); +} diff --git a/db/schema.ts b/db/schema.ts index ab52c659..90779b82 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -68,6 +68,26 @@ export const adminRoomAudit = sqliteTable( ], ); +// Durable creation receipts retain public audit metadata after room/account removal. +// Secret material is hashed; replay never inserts a replacement seat. +export const adminRoomCreations = sqliteTable( + 'admin_room_creations', + { + operationId: text('operation_id').primaryKey(), + actorAdminId: text('actor_admin_id').notNull(), + roomCode: text('room_code').notNull(), + hostId: text('host_id').notNull(), + requestHash: text('request_hash').notNull(), + sessionHash: text('session_hash').notNull().unique(), + configuration: text('configuration').notNull(), + reason: text('reason').notNull(), + createdAt: integer('created_at').notNull(), + }, + (table) => [ + index('admin_room_creations_room').on(table.roomCode, table.createdAt), + ], +); + export const seatRecoveryKeys = sqliteTable( 'seat_recovery_keys', { diff --git a/docs/ADMIN_PANEL.md b/docs/ADMIN_PANEL.md index f686ffed..0b733008 100644 --- a/docs/ADMIN_PANEL.md +++ b/docs/ADMIN_PANEL.md @@ -11,7 +11,7 @@ recovery and persistence systems where suitable. | --- | --- | --- | | Administrator access | Personal access keys, server-enforced owner/operator/viewer roles, persistent eight-hour sessions, individual/all-session sign-out and operator provisioning/revocation. A room host is not a site administrator. Account-management UI and further operational permissions remain. | Prototyped | | Room directory | Search room codes/public player names, filter status/rules/availability, sort and paginate; show host, roster, modules, game change time, pause/join flags and bounded decision ownership. Detailed setup/shared-window ownership remains. | Prototyped | -| Create and configure | Create rooms with supported rules and human/AI seats, manage lobby settings and invitations, and assign a host through an explicit workflow. Preserve normal prerequisites and readiness rules. | Missing | +| Create and configure | [Create a lobby](ADMIN_ROOM_CREATION.md) with an explicitly owned new host seat, Basic/Advanced preview and initial AI configuration; invite humans and use ordinary lobby controls/voluntary host-seat handover. Neutral administration of lobby configuration, reserved human seats and reassignment to another existing player remain. | Prototyped, partial | | Lifecycle and removal | [Pause/resume and joining locks](ADMIN_ROOM_CONTROLS.md) have controls, durable audit, exact retries and player/AI enforcement. Close/end, archive, restore and recoverable/permanent removal remain missing. | Prototyped, partial | | Participant support | Remove disruptive participants or revoke access, manage appropriate restrictions, assist saved-seat recovery and host reassignment, and use supported AI takeover/replacement without losing forces, cards or decisions. | Missing | | Saved-game operations | Create/list/download backups, validate imports, restore a selected checkpoint safely, and diagnose or resume interrupted automatic work through authoritative game actions. | Missing | @@ -36,6 +36,8 @@ All three roles may read the current directory and sign out their own sessions. Owners/operators may pause/resume rooms and lock/unlock new joins through [Room controls](ADMIN_ROOM_CONTROLS.md). The role is checked from the live account, never accepted from a client. +Owners/operators can also [create a configured lobby](ADMIN_ROOM_CREATION.md), +with explicit new-host ownership and saved exact retries. Disabling an account invalidates its sessions; later re-enabling it cannot restore them. The database refuses to disable, demote or delete the final enabled owner. No default key, public account creation or host-to-administrator promotion exists. @@ -103,8 +105,8 @@ checks its ID before proceeding: it creates a named QA room and signs out all sessions of that QA account. Never supply a human operator's normal key. It checks authentication, cookie properties, ordinary-host denial, directory privacy, session revocation and unchanged game/seat continuation. Operator/owner QA keys -also create one additional room for joining locks, pause/takeback/resume and -account-bound request checks. Private reports retain +also create dedicated rooms for joining locks, pause/takeback/resume, +administrator creation, invitations and voluntary host handover. Private reports retain room IDs and safe outcomes, never keys/cookies. The container verifier provisions its own disposable QA account and checks admin-session continuity across restart and replacement, followed by the HTTP flow and HTTPS-proxy cookie behavior. diff --git a/docs/ADMIN_ROOM_CREATION.md b/docs/ADMIN_ROOM_CREATION.md new file mode 100644 index 00000000..d6bf7a08 --- /dev/null +++ b/docs/ADMIN_ROOM_CREATION.md @@ -0,0 +1,88 @@ +# Creating rooms from Administration + +Owners and operators can choose **Create a room** in the administrator directory. +Select a host name/faction, Basic or Advanced preview, optional Tech Tokens and +Stronghold Cards, and zero to five AI players with individual difficulties. +Other human players join through the ordinary invitation. The new human host +stays unready; nothing starts automatically. Tech Tokens still require three +players at start, and Stronghold Cards require Advanced rules. Expansion starts +and complete-mode acceptance remain gated. + +Creation explicitly gives this browser the **new** host seat. Open it to change +lobby settings, configure player circles, invite humans and start normally. To +set up for another person, use the existing **Pass your seat to another player** +flow in that lobby; the host player identity and room progress survive transfer. +This workflow grants no access to any existing participant's seat. + +This is a connected room-creation prototype, not complete neutral administrator +lobby management. Creating reserved human seats, assigning a different existing +participant as host and managing lobbies without a playing seat remain part of +[the full panel requirements](ADMIN_PANEL.md). + +## Saved confirmation + +Before sending, the client saves one account-scoped creation request privately +in this tab. It contains the exact setup, operation identifier and a new host +session proof. Keep this tab and its storage until confirmation. A timeout or +lost response leaves **Retry saved room creation**; after refresh, reopen +**Create a room** to confirm that same request. It never automatically submits a +second creation. Expired or switched administrator access retains the original +account's pending record for later sign-in. + +A matching response replaces the private retry proof with a safe room receipt. +The result stays visible until closed or **Create another room** is selected. +Invitation codes are public to intended players; the private session proof must +never be placed in a URL, log or shared invitation. This temporary retry record +does not replace the host's long-term saved-seat recovery kit. + +An exact server retry returns the original room without resetting its lobby, +readiness or later game progress. If the original host session was revoked or +transferred, the receipt reports that access is unavailable and sends no seat +cookie. A stale creation retry cannot recover a transferred seat or overwrite +the recipient's credentials. + +## Authority and persistence + +The [creation endpoint](../app/api/admin/rooms/route.ts) requires the configured +origin, an administrator session, an owner/operator role and the displayed +account identifier. Ordinary room hosts and viewer administrators cannot use it. +The [database operation](../db/admin-room-creation.ts) rechecks the live account, +role, session generation and expiry inside its atomic write transaction. + +Existing engine actions construct the configured lobby in memory. The room, +hashed host session and durable creation receipt/audit commit together or roll +back together. The receipt binds the actor, ordered setup, reason and private +session hash. Reusing an operation for different input or another account fails; +reusing a session proof already recorded in any seat also fails. Random invitation +collisions retry a bounded number of times without replacing another room. + +The additive [migration](../drizzle/0010_admin_room_creation.sql) stores safe +configuration, actor, reason, time and hashed receipt identifiers. It does not +copy private game contents or plaintext credentials into the audit. Administrator +responses contain only the room/host identifiers and confirmation flags. Private +gameplay is loaded through the normal authenticated room route. + +## Verification + +Focused tests cover supported setup and start gates, ordered AI seats, exact +concurrent retries, transaction rollback, live authority, credential reuse, +restart recovery and transfers. Client tests check account-scoped private proof, +storage failure and replacing it only with a matching safe receipt. + +The dedicated-operator HTTP verifier creates one extra lobby for this flow, +checks invitation joining and performs a voluntary transfer within that QA room. +It confirms that subsequent creation retries do not revive the former host's +access. The lobby remains saved and does not start AI play. No existing human +room is used. The expanded authenticated HTTP verifier passed all eight groups. + +Local browser QA lost one successful creation response, refreshed, reopened the +saved request and confirmed exactly one room and receipt. Its configured host +seat opened through the ordinary route; readiness survived another refresh, and +the QA lobby was left unready. The completed receipt also survived refresh. +At a reported 390-pixel viewport, the form used 16-pixel text and a 44-pixel create +button without horizontal overflow. A requested 1280-pixel override continued to +report 390 pixels, so this run does not establish desktop layout acceptance. + +Source, browser, container and deployed acceptance are separate +evidence; authenticated production checks still require explicitly authorized QA +administrator access. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 83014ad0..76623d41 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -6,13 +6,19 @@ Expansion starts and full rules/publication acceptance remain gated.** ## Current checkpoint and work -Current implementation adds [administrator pause/resume and joining locks](ADMIN_ROOM_CONTROLS.md): -usable controls, availability filters, player guidance, stopped human/AI -decisions, durable audit and account-bound exact retries. Recovery, discussion -and AI takeback remain available. Focused persistence, authenticated local HTTP -and desktop/mobile browser checks accompany the checkpoint. Remaining lifecycle -and full-admin requirements stay open; authenticated production acceptance waits -for the required QA-access confirmation. +Current work adds [administrator room creation](ADMIN_ROOM_CREATION.md): a new +explicitly owned human host seat, supported rules/modules, initial AI seats, +invitation joining and exact saved retries. Creation uses ordinary engine setup +and preserves normal readiness/start gates. Neutral administrator configuration, +reserved human seats and reassignment to existing participants remain unfinished. + +Checkpoint `8c922b4` added [pause/resume and joining locks](ADMIN_ROOM_CONTROLS.md) +and passed types/lint, 5,223 offline tests, 52 HTTP tests and build, with independent +review and dedicated local administrator/browser checks. It is deployed on the +NAS: container verification passed, migration 0009 applied, all eight prior rooms +and ten seats were preserved, and a saved seat restored through public HTTPS. +Anonymous administrator requests remain denied. Authenticated production admin +acceptance still requires the pending QA-access confirmation. Checkpoint `f29d056` was pushed and deployed to the NAS instance. Readable create/join progress and Continue notices passed actual HTTPS browser acceptance, @@ -27,8 +33,8 @@ Continue control. It does not add confirmation steps to game rules or AI turns. Administrator access/directory checkpoint `c2d392e` passed types, lint, 5,196 offline tests, 52 HTTP tests and build, plus focused authenticated/browser checks. -Its production acceptance is being combined with this readability follow-up to -avoid two deployments; saved games and full administration gates remain intact. +Its authenticated production acceptance remains pending; the current deployment +preserves saved games and full administration gates. The user added a complete [administration panel](ADMIN_PANEL.md) to the goal on 22 September: secure admin access, room creation/removal and lifecycle controls, diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 20a899f7..05048840 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,15 @@ # Dune implementation status +## 22 September 2026 — Administrator room creation + +Added [configured lobby creation](ADMIN_ROOM_CREATION.md) with explicit new-host +ownership, initial AI factions/difficulties, supported optional rules, invitations +and the ordinary voluntary host-seat transfer path. Atomic live-authorized writes +and saved account-bound requests keep retries from duplicating rooms or reviving +revoked private-seat access. Normal readiness and preview gates remain. Full +neutral lobby administration, participant support and authenticated production +acceptance remain unfinished; verification evidence accompanies the checkpoint. + ## 22 September 2026 — Administrator room pause and joining locks Added reversible pause/resume and joining-lock controls, availability filters diff --git a/drizzle/0010_admin_room_creation.sql b/drizzle/0010_admin_room_creation.sql new file mode 100644 index 00000000..52af645f --- /dev/null +++ b/drizzle/0010_admin_room_creation.sql @@ -0,0 +1,14 @@ +CREATE TABLE `admin_room_creations` ( + `operation_id` text PRIMARY KEY NOT NULL, + `actor_admin_id` text NOT NULL, + `room_code` text NOT NULL, + `host_id` text NOT NULL, + `request_hash` text NOT NULL, + `session_hash` text NOT NULL, + `configuration` text NOT NULL, + `reason` text NOT NULL, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `admin_room_creations_session_hash_unique` ON `admin_room_creations` (`session_hash`);--> statement-breakpoint +CREATE INDEX `admin_room_creations_room` ON `admin_room_creations` (`room_code`,`created_at`); \ No newline at end of file diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 00000000..82bbb5ec --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,1243 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "05bc871d-07db-45ed-9501-8d65bbce33c6", + "prevId": "4e2806dd-1d29-4921-b818-b4f882ba9d73", + "tables": { + "admin_accounts": { + "name": "admin_accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "session_generation": { + "name": "session_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_accounts_key_hash_unique": { + "name": "admin_accounts_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "admin_accounts_role": { + "name": "admin_accounts_role", + "value": "\"admin_accounts\".\"role\" IN ('owner', 'operator', 'viewer')" + }, + "admin_accounts_enabled": { + "name": "admin_accounts_enabled", + "value": "\"admin_accounts\".\"enabled\" IN (0, 1)" + }, + "admin_accounts_generation": { + "name": "admin_accounts_generation", + "value": "\"admin_accounts\".\"session_generation\" >= 0" + } + } + }, + "admin_audit": { + "name": "admin_audit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_admin_id": { + "name": "target_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + } + }, + "indexes": { + "admin_audit_created_at": { + "name": "admin_audit_created_at", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_room_audit": { + "name": "admin_room_audit", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'room_control'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_revision": { + "name": "expected_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_paused": { + "name": "before_paused", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_join_locked": { + "name": "before_join_locked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after_paused": { + "name": "after_paused", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after_join_locked": { + "name": "after_join_locked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_room_audit_room": { + "name": "admin_room_audit_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_room_creations": { + "name": "admin_room_creations", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_hash": { + "name": "session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_room_creations_session_hash_unique": { + "name": "admin_room_creations_session_hash_unique", + "columns": [ + "session_hash" + ], + "isUnique": true + }, + "admin_room_creations_room": { + "name": "admin_room_creations_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_sessions": { + "name": "admin_sessions", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "admin_id": { + "name": "admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "admin_sessions_id_unique": { + "name": "admin_sessions_id_unique", + "columns": [ + "id" + ], + "isUnique": true + }, + "admin_sessions_account": { + "name": "admin_sessions_account", + "columns": [ + "admin_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "admin_sessions_admin_id_admin_accounts_id_fk": { + "name": "admin_sessions_admin_id_admin_accounts_id_fk", + "tableFrom": "admin_sessions", + "tableTo": "admin_accounts", + "columnsFrom": [ + "admin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "room_controls": { + "name": "room_controls", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "paused": { + "name": "paused", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "join_locked": { + "name": "join_locked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "room_controls_room_code_rooms_code_fk": { + "name": "room_controls_room_code_rooms_code_fk", + "tableFrom": "room_controls", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "room_controls_paused": { + "name": "room_controls_paused", + "value": "\"room_controls\".\"paused\" IN (0, 1)" + }, + "room_controls_join_locked": { + "name": "room_controls_join_locked", + "value": "\"room_controls\".\"join_locked\" IN (0, 1)" + }, + "room_controls_revision": { + "name": "room_controls_revision", + "value": "\"room_controls\".\"revision\" >= 0" + } + } + }, + "room_entry_receipts": { + "name": "room_entry_receipts", + "columns": { + "operation_hash": { + "name": "operation_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_hash": { + "name": "session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "room_entry_receipts_room_code_rooms_code_fk": { + "name": "room_entry_receipts_room_code_rooms_code_fk", + "tableFrom": "room_entry_receipts", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "room_messages": { + "name": "room_messages", + "columns": { + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_session_hash": { + "name": "sender_session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_name": { + "name": "sender_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_faction": { + "name": "sender_faction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_id": { + "name": "recipient_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_name": { + "name": "recipient_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "room_messages_room_id": { + "name": "room_messages_room_id", + "columns": [ + "room_code", + "id" + ], + "isUnique": true + }, + "room_messages_channel": { + "name": "room_messages_channel", + "columns": [ + "room_code", + "recipient_id", + "sequence" + ], + "isUnique": false + }, + "room_messages_sender": { + "name": "room_messages_sender", + "columns": [ + "room_code", + "sender_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "room_messages_room_code_rooms_code_fk": { + "name": "room_messages_room_code_rooms_code_fk", + "tableFrom": "room_messages", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rooms": { + "name": "rooms", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "rooms_updated_at": { + "name": "rooms_updated_at", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "seat_ai_delegations": { + "name": "seat_ai_delegations", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delegate_id": { + "name": "delegate_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "difficulty": { + "name": "difficulty", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_session_hash": { + "name": "owner_session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delegate_session_hash": { + "name": "delegate_session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "seat_ai_delegations_grant_id_unique": { + "name": "seat_ai_delegations_grant_id_unique", + "columns": [ + "grant_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "seat_ai_delegations_room_code_rooms_code_fk": { + "name": "seat_ai_delegations_room_code_rooms_code_fk", + "tableFrom": "seat_ai_delegations", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "seat_ai_delegations_room_code_owner_id_grant_id_pk": { + "columns": [ + "room_code", + "owner_id", + "grant_id" + ], + "name": "seat_ai_delegations_room_code_owner_id_grant_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "seat_handover_claim_receipts": { + "name": "seat_handover_claim_receipts", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation_hash": { + "name": "operation_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "offer_hash": { + "name": "offer_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_hash": { + "name": "session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claim_fence": { + "name": "claim_fence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "seat_handover_claim_receipts_claim_fence_unique": { + "name": "seat_handover_claim_receipts_claim_fence_unique", + "columns": [ + "claim_fence" + ], + "isUnique": true + } + }, + "foreignKeys": { + "seat_handover_claim_receipts_room_code_rooms_code_fk": { + "name": "seat_handover_claim_receipts_room_code_rooms_code_fk", + "tableFrom": "seat_handover_claim_receipts", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "seat_handover_claim_receipts_room_code_player_id_operation_hash_pk": { + "columns": [ + "room_code", + "player_id", + "operation_hash" + ], + "name": "seat_handover_claim_receipts_room_code_player_id_operation_hash_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "seat_handover_offers": { + "name": "seat_handover_offers", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "offer_hash": { + "name": "offer_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "issuer_session_hash": { + "name": "issuer_session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claim_operation_hash": { + "name": "claim_operation_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_hash": { + "name": "session_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "seat_handover_offers_offer_hash_unique": { + "name": "seat_handover_offers_offer_hash_unique", + "columns": [ + "offer_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "seat_handover_offers_room_code_rooms_code_fk": { + "name": "seat_handover_offers_room_code_rooms_code_fk", + "tableFrom": "seat_handover_offers", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "seat_handover_offers_room_code_player_id_pk": { + "columns": [ + "room_code", + "player_id" + ], + "name": "seat_handover_offers_room_code_player_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "seat_recovery_keys": { + "name": "seat_recovery_keys", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recovery_hash": { + "name": "recovery_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current_operation_hash": { + "name": "current_operation_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "seat_recovery_keys_room_code_rooms_code_fk": { + "name": "seat_recovery_keys_room_code_rooms_code_fk", + "tableFrom": "seat_recovery_keys", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "seat_recovery_keys_room_code_player_id_pk": { + "columns": [ + "room_code", + "player_id" + ], + "name": "seat_recovery_keys_room_code_player_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "seat_recovery_receipts": { + "name": "seat_recovery_receipts", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation_hash": { + "name": "operation_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recovery_hash": { + "name": "recovery_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_hash": { + "name": "session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "seat_recovery_receipts_room_code_rooms_code_fk": { + "name": "seat_recovery_receipts_room_code_rooms_code_fk", + "tableFrom": "seat_recovery_receipts", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "seat_recovery_receipts_room_code_player_id_operation_hash_pk": { + "columns": [ + "room_code", + "player_id", + "operation_hash" + ], + "name": "seat_recovery_receipts_room_code_player_id_operation_hash_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "seats": { + "name": "seats", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "revoked": { + "name": "revoked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "seats_room_code_rooms_code_fk": { + "name": "seats_room_code_rooms_code_fk", + "tableFrom": "seats", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 79dd79c2..e20b8da9 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1790035625313, "tag": "0009_admin_room_controls", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1790038013633, + "tag": "0010_admin_room_creation", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/admin-room-creation-client.ts b/lib/admin-room-creation-client.ts new file mode 100644 index 00000000..d51ec184 --- /dev/null +++ b/lib/admin-room-creation-client.ts @@ -0,0 +1,62 @@ +import { randomId } from './random-id'; +import { validAdminRoomCreationInput, type AdminRoomCreationInput, type AdminRoomCreationResult } from './admin-room-creation'; + +type Storage = Pick; +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; + 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 { + 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; + 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.'); +} diff --git a/lib/admin-room-creation.ts b/lib/admin-room-creation.ts new file mode 100644 index 00000000..402146d6 --- /dev/null +++ b/lib/admin-room-creation.ts @@ -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 => + !!value && typeof value === 'object' && !Array.isArray(value); +const exactKeys = (value: Record, 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; +} diff --git a/tests/admin-boundary-http.test.ts b/tests/admin-boundary-http.test.ts index 672e083d..89d5d5dd 100644 --- a/tests/admin-boundary-http.test.ts +++ b/tests/admin-boundary-http.test.ts @@ -26,6 +26,14 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden }); assert.equal(write.status, 401); assert.deepEqual(Object.keys(await write.json()), ['error']); + const create = await fetch(base + '/api/admin/rooms', { + method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' }, + body: JSON.stringify({ operationId: crypto.randomUUID(), sessionToken: 'b'.repeat(64), name: 'Denied creation QA', faction: 'atreides', advanced: false, techTokens: false, strongholdCards: false, bots: [], reason: 'Must not create a room' }), + signal: AbortSignal.timeout(15000), + }); + assert.equal(create.status, 401); + assert.equal(create.headers.get('set-cookie'), null); + assert.deepEqual(Object.keys(await create.json()), ['error']); } }); void test('admin HTTP rejects login CSRF, oversized bodies and invalid credentials without setting sessions', async () => { diff --git a/tests/admin-room-creation-client.test.ts b/tests/admin-room-creation-client.test.ts new file mode 100644 index 00000000..4c5a669f --- /dev/null +++ b/tests/admin-room-creation-client.test.ts @@ -0,0 +1,63 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { clearAdminRoomCreation, completeAdminRoomCreation, newAdminRoomCreationRequest, readAdminRoomCreation, saveAdminRoomCreation, validAdminRoomCreationResult } from '../lib/admin-room-creation-client'; +import type { AdminRoomCreationResult } from '../lib/admin-room-creation'; + +const fields = () => ({ name: ' Host QA ', faction: 'atreides' as const, advanced: true, techTokens: true, strongholdCards: true, + bots: [{ faction: 'fremen' as const, difficulty: 'Easy' as const }, { faction: 'guild' as const, difficulty: 'Hard' as const }], reason: ' Dedicated QA lobby ' }); +function storage() { + const values = new Map(); + return { values, getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); }, removeItem: (key: string) => { values.delete(key); } }; +} +const receipt = (operationId: string): AdminRoomCreationResult => ({ operationId, code: 'ABCD2345', hostId: crypto.randomUUID(), replayed: true, hostAccess: true }); + +void test('creation retry preserves one exact private host proof and roster order in its original administrator scope', () => { + const tab = storage(), input = newAdminRoomCreationRequest(fields()); + assert.equal(input.name, 'Host QA'); assert.equal(input.reason, 'Dedicated QA lobby'); + assert.match(input.sessionToken, /^[0-9a-f]{64}$/); + saveAdminRoomCreation(tab, 'operator-a', input); + assert.deepEqual(readAdminRoomCreation(tab, 'operator-a'), { kind: 'pending', input }); + assert.equal(readAdminRoomCreation(tab, 'operator-b'), null); + assert.throws(() => saveAdminRoomCreation(tab, 'operator-a', newAdminRoomCreationRequest(fields())), /Resolve the saved/); + assert.throws(() => saveAdminRoomCreation(tab, 'operator-a', { ...input, bots: [...input.bots].reverse() }), /Resolve the saved/); + saveAdminRoomCreation(tab, 'operator-a', input); + assert.deepEqual(readAdminRoomCreation(tab, 'operator-a'), { kind: 'pending', input }); +}); + +void test('only a matching valid receipt replaces the private retry proof with safe completed-room metadata', () => { + const tab = storage(), input = newAdminRoomCreationRequest(fields()); + saveAdminRoomCreation(tab, 'a', input); + assert.throws(() => completeAdminRoomCreation(tab, 'a', input, receipt(crypto.randomUUID())), /did not confirm/); + assert.equal(readAdminRoomCreation(tab, 'a')?.kind, 'pending'); + const result = receipt(input.operationId); + completeAdminRoomCreation(tab, 'a', input, result); + assert.deepEqual(readAdminRoomCreation(tab, 'a'), { kind: 'completed', result }); + const raw = [...tab.values.values()].join(''); + assert.equal(raw.includes(input.sessionToken), false); + assert.equal(raw.includes(input.reason), false); + assert.throws(() => saveAdminRoomCreation(tab, 'a', newAdminRoomCreationRequest(fields())), /Resolve the saved/); + clearAdminRoomCreation(tab, 'a'); + assert.equal(readAdminRoomCreation(tab, 'a'), null); +}); + +void test('unavailable, corrupt or replaced tab storage never silently authorizes a different creation', () => { + const tab = storage(), input = newAdminRoomCreationRequest(fields()); + assert.throws(() => saveAdminRoomCreation({ ...tab, setItem() {} }, 'a', input), /could not be saved/); + saveAdminRoomCreation(tab, 'a', input); + const key = [...tab.values.keys()][0]; + tab.values.set(key, '{broken'); + assert.throws(() => readAdminRoomCreation(tab, 'a'), /unreadable/); + assert.throws(() => saveAdminRoomCreation(tab, 'a', input), /unreadable/); + assert.equal(tab.values.get(key), '{broken'); + tab.values.set(key, JSON.stringify({ kind: 'pending', input: newAdminRoomCreationRequest(fields()) })); + assert.throws(() => completeAdminRoomCreation(tab, 'a', input, receipt(input.operationId)), /record changed/); +}); + +void test('revoked host receipts remain readable without claiming access and malformed/secret-bearing results cannot clear proof', () => { + const result = { ...receipt(crypto.randomUUID()), hostAccess: false }; + assert.equal(validAdminRoomCreationResult(result), true); + for (const invalid of [null, [], { ...result, hostAccess: undefined }, { ...result, code: result.code + '\n' }, { ...result, hostId: 'not-an-id' }, { ...result, sessionToken: 'private' }]) + assert.equal(validAdminRoomCreationResult(invalid), false); + assert.throws(() => newAdminRoomCreationRequest({ ...fields(), strongholdCards: true, advanced: false })); + assert.throws(() => newAdminRoomCreationRequest({ ...fields(), bots: [{ faction: 'atreides', difficulty: 'Easy' }] })); +}); diff --git a/tests/admin-room-creation.test.ts b/tests/admin-room-creation.test.ts new file mode 100644 index 00000000..93098772 --- /dev/null +++ b/tests/admin-room-creation.test.ts @@ -0,0 +1,769 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { randomBytes, randomUUID, webcrypto } from 'node:crypto'; +import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { runInNewContext } from 'node:vm'; +import ts from 'typescript'; +import { adminStore, pauseAdminBatch, sha256 } from './admin-access-fixture'; +import { + adminLogin, + requireAdmin, + adminLogoutAll, + AdminError, + type AdminRole, +} from '../db/admin-access'; +import { createAdminRoom } from '../db/admin-room-creation'; +import { + validAdminRoomCreationInput, + type AdminRoomCreationInput, +} from '../lib/admin-room-creation'; +import * as engine from '../game/engine'; +import * as bots from '../game/bots'; +import * as seatAiDelegation from '../lib/seat-ai-delegation'; +import type * as Rooms from '../db/rooms'; + +const secret = () => randomBytes(32).toString('hex'); +const input = ( + patch: Partial = {}, +): AdminRoomCreationInput => ({ + operationId: randomUUID(), + sessionToken: secret(), + name: 'Creation QA host', + faction: 'atreides', + advanced: false, + techTokens: false, + strongholdCards: false, + bots: [{ faction: 'harkonnen', difficulty: 'Hard' }], + reason: 'Dedicated QA room creation', + ...patch, +}); +const status = (wanted: number) => (error: unknown) => + error instanceof AdminError && error.status === wanted; +const rows = (sqlite: DatabaseSync, table: string) => + sqlite.prepare(`SELECT * FROM ${table}`).all(); +const saved = (sqlite: DatabaseSync, code: string) => + JSON.parse( + String( + sqlite.prepare('SELECT state FROM rooms WHERE code=?').get(code)!.state, + ), + ) as engine.Game; + +function loadRooms(database: D1Database) { + const exports = {}; + runInNewContext( + ts.transpileModule( + readFileSync(new URL('../db/rooms.ts', import.meta.url), 'utf8'), + { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + }, + ).outputText, + { + exports, + crypto: webcrypto, + TextEncoder, + structuredClone, + JSON, + require: (name: string) => { + if (name === 'cloudflare:workers') return { env: { DB: database } }; + if (name === '@/game/engine') return engine; + if (name === '@/game/bots') return bots; + if (name === '@/lib/seat-ai-delegation') return seatAiDelegation; + throw new Error('Unexpected dependency ' + name); + }, + }, + ); + return exports as typeof Rooms; +} +async function fixture(role: AdminRole = 'operator') { + const store = adminStore(); + const account = store.provision(role); + const login = await adminLogin(store.database, account.key); + const identity = await requireAdmin(store.database, login.token); + const rooms = loadRooms(store.database); + const create = (request = input(), now = 10000) => + createAdminRoom(store.database, identity, request, now); + return { ...store, identity, account, login, rooms, create }; +} + +void test('admin creation migration adds only its durable receipt table and indexes', () => { + const store = adminStore(':memory:', false); + try { + for (const file of readdirSync(new URL('../drizzle/', import.meta.url)) + .filter((file) => file.endsWith('.sql') && file < '0010') + .sort()) + store.sqlite.exec( + readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'), + ); + store.sqlite + .prepare( + 'INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)', + ) + .run('PRESERVE', '{"private":"saved game"}', 17, 123); + store.sqlite + .prepare( + 'INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)', + ) + .run(sha256(secret()), 'PRESERVE', randomUUID()); + const account = store.provision('owner'); + const before = ['rooms', 'seats', 'admin_accounts', 'admin_audit'].map( + (table) => rows(store.sqlite, table), + ); + store.sqlite.exec( + readFileSync( + new URL('../drizzle/0010_admin_room_creation.sql', import.meta.url), + 'utf8', + ), + ); + assert.deepEqual( + ['rooms', 'seats', 'admin_accounts', 'admin_audit'].map((table) => + rows(store.sqlite, table), + ), + before, + ); + assert.deepEqual(rows(store.sqlite, 'admin_room_creations'), []); + assert.throws( + () => + store.sqlite + .prepare('UPDATE admin_accounts SET enabled=0 WHERE id=?') + .run(account.id), + /last|final|owner/i, + ); + } finally { + store.sqlite.close(); + } +}); + +void test('admin creates zero through five AI seats through ordinary lobby actions with one unready human host', async () => { + const f = await fixture(); + try { + const factions = [ + 'harkonnen', + 'emperor', + 'fremen', + 'guild', + 'beneGesserit', + ] as const; + for (let n = 0; n <= 5; n++) { + const request = input({ + bots: factions.slice(0, n).map((faction, index) => ({ + faction, + difficulty: (['Easy', 'Medium', 'Hard', 'Brutal'] as const)[ + index % 4 + ], + })), + }); + const result = await f.create(request); + assert.deepEqual(Object.keys(result).sort(), [ + 'code', + 'hostAccess', + 'hostId', + 'operationId', + 'replayed', + ]); + assert.equal(result.operationId, request.operationId); + assert.equal(result.replayed, false); + assert.equal(result.hostAccess, true); + assert.match(result.code, /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{8}$/); + const game = saved(f.sqlite, result.code); + assert.equal(game.status, 'lobby'); + assert.equal(game.version, 0); + assert.equal(game.host, result.hostId); + assert.equal(game.players.length, n + 1); + assert.equal(game.players[0].ready, false); + assert.equal(game.players[0].bot, undefined); + assert.deepEqual( + game.players.slice(1).map((player) => ({ + faction: player.faction, + difficulty: player.bot, + })), + request.bots, + ); + assert.ok(game.players.slice(1).every((player) => player.ready)); + assert.deepEqual(game.deck, []); + assert.equal(game.botsPending ?? false, false); + assert.equal(game.botNextActionAt, undefined); + assert.deepEqual(game.expansions, []); + assert.equal(game.advancedPreview, undefined); + const seat = f.sqlite + .prepare('SELECT * FROM seats WHERE room_code=?') + .all(result.code); + assert.equal(seat.length, 1); + assert.equal(seat[0].token_hash, sha256(request.sessionToken)); + assert.equal(seat[0].player_id, result.hostId); + const auth = await f.rooms.authenticate( + result.code, + request.sessionToken, + ); + const view = await f.rooms.readSeatView(result.code, auth); + assert.equal(view.me, result.hostId); + assert.equal(view.host, result.hostId); + assert.equal(JSON.stringify(view).includes(request.reason), false); + } + assert.equal(rows(f.sqlite, 'rooms').length, 6); + assert.equal(rows(f.sqlite, 'seats').length, 6); + } finally { + f.sqlite.close(); + } +}); + +void test('Basic and explicit Advanced preview genuinely start, retain modules, offer human choices and legally continue AI after JSON restore', async () => { + const f = await fixture(); + try { + for (const advanced of [false, true]) { + const request = input({ + advanced, + techTokens: true, + strongholdCards: advanced, + bots: [ + { faction: 'harkonnen', difficulty: 'Easy' }, + { faction: 'guild', difficulty: 'Medium' }, + ], + }); + const created = await f.create(request); + const auth = await f.rooms.authenticate( + created.code, + request.sessionToken, + ); + let now = 10000; + const clock = { + now: () => now, + sleep: async (ms: number) => { + now += ms; + }, + }; + let view = await f.rooms.readSeatView(created.code, auth); + assert.equal(view.status, 'lobby'); + assert.ok(view.techTokens); + assert.equal(!!view.strongholdCards, advanced); + await assert.rejects( + f.rooms.act( + created.code, + auth, + view.version, + { type: 'start', ...(advanced ? { advancedPreview: true } : {}) }, + clock, + ), + /ready/i, + ); + view = await f.rooms.act( + created.code, + auth, + view.version, + { type: 'ready' }, + clock, + ); + if (advanced) + await assert.rejects( + f.rooms.act( + created.code, + auth, + view.version, + { type: 'start' }, + clock, + ), + /Advanced.*unfinished|preview/i, + ); + view = await f.rooms.act( + created.code, + auth, + view.version, + { type: 'start', ...(advanced ? { advancedPreview: true } : {}) }, + clock, + ); + assert.equal(view.status, 'setup'); + assert.equal(view.advancedPreview, advanced); + const ownerChoices = view.players.find( + (player) => player.id === view.me, + )!.traitorChoices; + assert.ok(ownerChoices?.length); + await loadRooms(f.database).continueRoomBots(created.code, 4, clock); + const restored = await loadRooms(f.database).readSeatView( + created.code, + auth, + ); + assert.deepEqual( + restored.players.find((player) => player.id === view.me)! + .traitorChoices, + ownerChoices, + ); + assert.ok(restored.version > view.version); + for (const player of restored.players.filter( + (player) => player.id !== view.me, + )) { + assert.equal('hand' in player, false); + assert.equal('traitors' in player, false); + assert.equal('traitorChoices' in player, false); + } + } + } finally { + f.sqlite.close(); + } +}); + +void test('Tech Tokens remain selectable in a small lobby but ordinary start still requires three players', async () => { + const f = await fixture(); + try { + const request = input({ techTokens: true }); + const created = await f.create(request); + const auth = await f.rooms.authenticate(created.code, request.sessionToken); + const view = await f.rooms.act(created.code, auth, 0, { type: 'ready' }); + const before = rows(f.sqlite, 'rooms'); + await assert.rejects( + f.rooms.act(created.code, auth, view.version, { type: 'start' }), + /three players/i, + ); + assert.deepEqual(rows(f.sqlite, 'rooms'), before); + const added = await f.rooms.act(created.code, auth, view.version, { + type: 'addBot', + faction: 'guild', + difficulty: 'Hard', + }); + const ready = await f.rooms.act(created.code, auth, added.version, { + type: 'ready', + }); + assert.equal( + (await f.rooms.act(created.code, auth, ready.version, { type: 'start' })) + .status, + 'setup', + ); + } finally { + f.sqlite.close(); + } +}); + +void test('strict creation input rejects malformed proof, missing or unknown fields, duplicate/expansion factions and unsupported profiles before persistence', async () => { + const f = await fixture(); + try { + const missing = input(); + delete (missing as Partial).reason; + const invalid: unknown[] = [ + null, + [], + missing, + ...[ + { operationId: randomUUID() + '\n' }, + { sessionToken: secret() + '\n' }, + { sessionToken: 'short' }, + { name: ' ' }, + { name: 'x'.repeat(33) }, + { name: 'line\nbreak' }, + { reason: ' ' }, + { reason: 'x'.repeat(301) }, + { reason: 'line\nbreak' }, + { advanced: 'true' }, + { strongholdCards: true }, + { faction: 'ixians' }, + { expansion: ['ix'] }, + { start: true }, + { hostId: randomUUID() }, + { bots: Array(6).fill({ faction: 'guild', difficulty: 'Hard' }) }, + { bots: [{ faction: 'atreides', difficulty: 'Hard' }] }, + { + bots: [ + { faction: 'guild', difficulty: 'Hard' }, + { faction: 'guild', difficulty: 'Easy' }, + ], + }, + { bots: [{ faction: 'ixians', difficulty: 'Hard' }] }, + { bots: [{ faction: 'guild', difficulty: 'Impossible' }] }, + { bots: [{ faction: 'guild', difficulty: 'Hard', ready: true }] }, + ].map((patch) => ({ ...input(), ...patch })), + ]; + for (const request of invalid) { + assert.equal(validAdminRoomCreationInput(request), false); + await assert.rejects( + f.create(request as AdminRoomCreationInput), + status(400), + ); + } + assert.equal(rows(f.sqlite, 'rooms').length, 0); + assert.equal(rows(f.sqlite, 'seats').length, 0); + assert.equal(rows(f.sqlite, 'admin_room_creations').length, 0); + } finally { + f.sqlite.close(); + } +}); + +void test('live owner/operator roles authorize creation and viewer, unknown session and forged role cannot create or replay', async () => { + for (const role of ['owner', 'operator', 'viewer'] as const) { + const f = await fixture(role); + try { + const request = input(); + const attempt = createAdminRoom( + f.database, + { ...f.identity, role: 'owner' }, + request, + ); + if (role === 'viewer') { + await assert.rejects(attempt, status(403)); + assert.equal(rows(f.sqlite, 'rooms').length, 0); + } else { + await attempt; + if (role === 'operator') { + f.sqlite + .prepare("UPDATE admin_accounts SET role='viewer' WHERE id=?") + .run(f.identity.id); + await assert.rejects(f.create(request), status(403)); + } + } + await assert.rejects( + createAdminRoom( + f.database, + { ...f.identity, sessionHash: secret() }, + input(), + ), + status(401), + ); + } finally { + f.sqlite.close(); + } + } +}); + +void test('creation and receipt reads recheck disable, logout, expiry and demotion inside the transaction', async () => { + for (const replay of [false, true]) + for (const mode of ['disable', 'logout', 'expiry', 'demote'] as const) { + const f = await fixture(); + try { + const request = input(); + if (replay) await f.create(request); + const before = ['rooms', 'seats', 'admin_room_creations'].map((table) => + rows(f.sqlite, table), + ); + const gate = pauseAdminBatch(f); + const pending = f.create(request); + await gate.waiting; + if (mode === 'disable') + f.sqlite + .prepare('UPDATE admin_accounts SET enabled=0 WHERE id=?') + .run(f.identity.id); + else if (mode === 'logout') + await adminLogoutAll(f.database, f.identity); + else if (mode === 'expiry') + f.sqlite + .prepare( + 'UPDATE admin_sessions SET expires_at=1 WHERE token_hash=?', + ) + .run(f.identity.sessionHash); + else + f.sqlite + .prepare("UPDATE admin_accounts SET role='viewer' WHERE id=?") + .run(f.identity.id); + gate.release(); + await assert.rejects(pending, status(mode === 'demote' ? 403 : 401)); + assert.deepEqual( + ['rooms', 'seats', 'admin_room_creations'].map((table) => + rows(f.sqlite, table), + ), + before, + ); + } finally { + f.sqlite.close(); + } + } +}); + +void test('same-operation concurrency and normalized exact retries commit one room, seat and public audit receipt', async () => { + const f = await fixture(); + try { + const request = input({ + name: ' Creation QA host ', + reason: ' Dedicated QA room creation ', + bots: [ + { faction: 'guild', difficulty: 'Hard' }, + { faction: 'harkonnen', difficulty: 'Easy' }, + ], + }); + const gate = pauseAdminBatch(f); + const pending = f.create(request); + await gate.waiting; + const winner = await f.create(request); + gate.release(); + const retry = await pending; + assert.deepEqual(retry, { ...winner, replayed: true }); + assert.deepEqual( + await f.create({ + ...request, + name: request.name.trim(), + reason: request.reason.trim(), + }), + retry, + ); + const before = ['rooms', 'seats', 'admin_room_creations'].map((table) => + rows(f.sqlite, table), + ); + for (const patch of [ + { name: 'Changed host' }, + { sessionToken: secret() }, + { advanced: true }, + { techTokens: true }, + { bots: [...request.bots].reverse() }, + { reason: 'Changed reason' }, + ]) + await assert.rejects(f.create({ ...request, ...patch }), status(409)); + const other = f.provision('operator'); + const login = await adminLogin(f.database, other.key); + const identity = await requireAdmin(f.database, login.token); + await assert.rejects( + createAdminRoom(f.database, identity, request), + status(409), + ); + assert.deepEqual( + ['rooms', 'seats', 'admin_room_creations'].map((table) => + rows(f.sqlite, table), + ), + before, + ); + const audit = rows(f.sqlite, 'admin_room_creations')[0]; + assert.equal(audit.actor_admin_id, f.identity.id); + assert.equal(audit.room_code, winner.code); + assert.equal(audit.host_id, winner.hostId); + assert.equal(audit.created_at, 10000); + assert.equal(audit.session_hash, sha256(request.sessionToken)); + const config = JSON.parse(String(audit.configuration)); + assert.deepEqual(Object.keys(config).sort(), [ + 'advanced', + 'bots', + 'faction', + 'name', + 'strongholdCards', + 'techTokens', + ]); + assert.deepEqual(config.bots, request.bots); + for (const value of [ + request.sessionToken, + f.identity.sessionHash, + 'traitorChoices', + 'deck', + ]) + assert.equal(JSON.stringify(audit).includes(value), false); + } finally { + f.sqlite.close(); + } +}); + +void test('session hashes cannot be reused from active/revoked seats or durable creation receipts after room removal', async () => { + const f = await fixture(); + try { + const original = input(); + const created = await f.create(original); + for (const revoked of [0, 1]) { + f.sqlite + .prepare('UPDATE seats SET revoked=? WHERE token_hash=?') + .run(revoked, sha256(original.sessionToken)); + await assert.rejects( + f.create(input({ sessionToken: original.sessionToken })), + status(409), + ); + } + f.sqlite.prepare('DELETE FROM rooms WHERE code=?').run(created.code); + assert.deepEqual(await f.create(original), { + ...created, + replayed: true, + hostAccess: false, + }); + await assert.rejects( + f.create(input({ sessionToken: original.sessionToken })), + status(409), + ); + const ordinaryToken = secret(); + const ordinary = await f.rooms.createRoom( + 'Ordinary QA owner', + 'atreides', + false, + [], + { operationId: randomUUID(), sessionToken: ordinaryToken }, + ); + await assert.rejects( + f.create(input({ sessionToken: ordinaryToken })), + status(409), + ); + f.sqlite + .prepare('UPDATE seats SET revoked=1 WHERE room_code=?') + .run(ordinary.view.code); + await assert.rejects( + f.create(input({ sessionToken: ordinaryToken })), + status(409), + ); + assert.equal(rows(f.sqlite, 'rooms').length, 1); + assert.equal(rows(f.sqlite, 'admin_room_creations').length, 1); + } finally { + f.sqlite.close(); + } +}); + +void test('seat/audit storage failures roll back all new room records and preserve other saved games', async () => { + const f = await fixture(); + try { + await f.create(); + for (const table of ['seats', 'admin_room_creations']) { + const before = ['rooms', 'seats', 'admin_room_creations'].map((name) => + rows(f.sqlite, name), + ); + f.sqlite.exec( + `CREATE TRIGGER reject_creation BEFORE INSERT ON ${table} BEGIN SELECT RAISE(ABORT,'QA creation failure'); END;`, + ); + await assert.rejects(f.create(), /QA creation failure/); + assert.deepEqual( + ['rooms', 'seats', 'admin_room_creations'].map((name) => + rows(f.sqlite, name), + ), + before, + ); + f.sqlite.exec('DROP TRIGGER reject_creation'); + } + } finally { + f.sqlite.close(); + } +}); + +void test('room invitation collisions retry boundedly without replacing existing games or retaining partial seats', async (t) => { + const f = await fixture(); + try { + f.sqlite + .prepare( + 'INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)', + ) + .run('AAAAAAAA', '{"private":"preserve"}', 7, 42); + const before = rows(f.sqlite, 'rooms')[0]; + let calls = 0; + const random = t.mock.method(globalThis.crypto, 'getRandomValues', (< + T extends ArrayBufferView | null, + >( + array: T, + ): T => { + (array as Uint8Array).fill(calls++ === 0 ? 0 : 1); + return array; + }) as typeof crypto.getRandomValues); + const first = await f.create(input({ bots: [] })); + assert.equal(first.code, 'BBBBBBBB'); + assert.equal(calls, 2); + assert.deepEqual(rows(f.sqlite, 'rooms')[0], before); + random.mock.restore(); + const collision = t.mock.method(globalThis.crypto, 'getRandomValues', (< + T extends ArrayBufferView | null, + >( + array: T, + ): T => { + (array as Uint8Array).fill(0); + return array; + }) as typeof crypto.getRandomValues); + const request = input({ bots: [] }); + await assert.rejects(f.create(request), status(409)); + assert.equal(collision.mock.callCount(), 5); + assert.equal(rows(f.sqlite, 'rooms').length, 2); + assert.equal(rows(f.sqlite, 'seats').length, 1); + assert.equal(rows(f.sqlite, 'admin_room_creations').length, 1); + collision.mock.restore(); + assert.equal((await f.create(request)).replayed, false); + } finally { + t.mock.restoreAll(); + f.sqlite.close(); + } +}); + +void test('durable creation receipt and hash-only host seat survive database close/reopen with exact latest-state replay', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dune-admin-create-')); + let store = adminStore(join(dir, 'rooms.sqlite')); + try { + const account = store.provision('operator'); + const login = await adminLogin(store.database, account.key); + const identity = await requireAdmin(store.database, login.token); + const request = input(); + const created = await createAdminRoom(store.database, identity, request); + const rooms = loadRooms(store.database); + const auth = await rooms.authenticate(created.code, request.sessionToken); + const ready = await rooms.act(created.code, auth, 0, { type: 'ready' }); + const before = rows(store.sqlite, 'rooms'); + store.sqlite.close(); + store = adminStore(join(dir, 'rooms.sqlite'), false); + assert.deepEqual(await createAdminRoom(store.database, identity, request), { + ...created, + replayed: true, + }); + assert.deepEqual(rows(store.sqlite, 'rooms'), before); + const restored = await loadRooms(store.database).readSeatView( + created.code, + auth, + ); + assert.deepEqual(restored, ready); + assert.equal(rows(store.sqlite, 'admin_room_creations').length, 1); + } finally { + store.sqlite.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +void test('exact creation retries never regrant a revoked, recovered, handed-over or removed host seat', async () => { + for (const lost of [ + 'revoked', + 'recovered', + 'handover', + 'missing-player', + 'malformed-game', + ] as const) { + const f = await fixture(); + try { + const request = input(); + const created = await f.create(request); + const auth = await f.rooms.authenticate( + created.code, + request.sessionToken, + ); + if (lost === 'revoked') + f.sqlite + .prepare('UPDATE seats SET revoked=1 WHERE token_hash=?') + .run(auth.tokenHash); + else if (lost === 'recovered') { + const recoverySecret = secret(); + await f.rooms.setRecoveryKey(created.code, auth, 0, recoverySecret); + await f.rooms.recoverSeat(created.code, { + playerId: auth.playerId, + recoverySecret, + operationId: randomUUID(), + newSessionToken: secret(), + }); + } else if (lost === 'handover') { + const offer = { offerId: randomUUID(), handoverSecret: secret() }; + await f.rooms.createSeatHandover(created.code, auth, 0, offer); + await f.rooms.claimSeatHandover(created.code, { + playerId: auth.playerId, + ...offer, + operationId: randomUUID(), + newSessionToken: secret(), + }); + } else if (lost === 'missing-player') + f.sqlite + .prepare( + "UPDATE rooms SET state=json_set(state,'$.players',json('[]')) WHERE code=?", + ) + .run(created.code); + else + f.sqlite + .prepare('UPDATE rooms SET state=? WHERE code=?') + .run('{invalid', created.code); + const before = ['rooms', 'seats', 'admin_room_creations'].map((table) => + rows(f.sqlite, table), + ); + assert.deepEqual(await f.create(request), { + ...created, + replayed: true, + hostAccess: false, + }); + assert.deepEqual( + ['rooms', 'seats', 'admin_room_creations'].map((table) => + rows(f.sqlite, table), + ), + before, + ); + } finally { + f.sqlite.close(); + } + } +}); diff --git a/tools/admin-creation-verification.mjs b/tools/admin-creation-verification.mjs new file mode 100644 index 00000000..f5b31b6c --- /dev/null +++ b/tools/admin-creation-verification.mjs @@ -0,0 +1,57 @@ +// Create a dedicated QA lobby; never inspect or mutate an existing human room. +import assert from 'node:assert/strict'; +import { randomBytes, randomUUID } from 'node:crypto'; + +export async function verifyRoomCreation(request, adminCookie, report) { + const secret = () => randomBytes(32).toString('hex'); + const input = { operationId: randomUUID(), sessionToken: secret(), name: 'Admin creation QA ' + Date.now(), faction: 'atreides', advanced: true, + techTokens: true, strongholdCards: true, bots: [{ faction: 'fremen', difficulty: 'Easy' }, { faction: 'guild', difficulty: 'Hard' }], reason: 'Dedicated admin room creation acceptance' }; + try { + const mismatched = await request('/api/admin/rooms', input, adminCookie, undefined, randomUUID()); + assert.equal(mismatched.status, 401); assert.equal(mismatched.rawCookie, null); + const invalid = await request('/api/admin/rooms', { ...input, advanced: false }, adminCookie); + assert.equal(invalid.status, 400); assert.equal(invalid.rawCookie, null); + const made = await request('/api/admin/rooms', input, adminCookie); + assert.equal(made.status, 200); + assert.deepEqual(Object.keys(made.data).sort(), ['code', 'hostAccess', 'hostId', 'operationId', 'replayed']); + assert.equal(made.data.operationId, input.operationId); assert.equal(made.data.replayed, false); assert.equal(made.data.hostAccess, true); + const code = made.data.code, path = '/api/rooms/' + code, host = made.cookie; + report.creationRoom = code; + for (const attribute of ['HttpOnly', 'SameSite=Strict', 'Path=/api/rooms/' + code]) assert.ok(made.rawCookie.includes(attribute)); + const first = await request(path, undefined, host); + assert.equal(first.status, 200); assert.equal(first.data.me, made.data.hostId); assert.equal(first.data.host, made.data.hostId); + assert.equal(first.data.status, 'lobby'); assert.equal(first.data.advanced, true); + assert.ok(first.data.techTokens); assert.ok(first.data.strongholdCards); assert.equal(first.data.players.length, 3); + assert.equal(first.data.players.find(player => player.id === first.data.me).ready, false); + assert.deepEqual(first.data.players.filter(player => player.bot).map(player => [player.faction, player.bot]), input.bots.map(bot => [bot.faction, bot.difficulty])); + const ready = await request(path, { version: first.data.version, action: { type: 'ready' } }, host); + assert.equal(ready.status, 200); + const replay = await request('/api/admin/rooms', input, adminCookie); + assert.equal(replay.status, 200); assert.equal(replay.data.replayed, true); assert.equal(replay.data.code, code); assert.equal(replay.cookie, host); + assert.deepEqual((await request(path, undefined, host)).data, ready.data, 'Creation retry must not reset configured or readied lobby'); + const directory = await request('/api/admin/rooms?q=' + encodeURIComponent(input.name), undefined, adminCookie); + assert.equal(directory.status, 200); assert.equal(directory.data.total, 1); + const joined = await request(path, { type: 'join', name: 'Creation invited human QA', faction: 'emperor' }); + assert.equal(joined.status, 200); assert.equal(joined.data.players.length, 4); + report.checks.push('Admin creation preserves supported rules and ordered AI seats; exact retry finds one lobby and invitations admit a human'); + + const current = (await request(path, undefined, host)).data; + const offer = { offerId: randomUUID(), handoverSecret: secret() }; + const offered = await request(path + '/control', { type: 'createSeatHandover', version: current.version, ...offer }, host); + assert.equal(offered.status, 200); + const claimed = await request(path + '/control', { type: 'claimSeatHandover', playerId: current.me, ...offer, operationId: randomUUID(), newSessionToken: secret() }); + assert.equal(claimed.status, 200); + const transferred = await request(path, undefined, claimed.cookie); + assert.equal(transferred.status, 200); assert.equal(transferred.data.me, current.me); assert.equal(transferred.data.host, current.me); + const afterTransfer = await request('/api/admin/rooms', input, adminCookie); + assert.equal(afterTransfer.status, 200); assert.equal(afterTransfer.data.code, code); + assert.equal(afterTransfer.data.replayed, true); assert.equal(afterTransfer.data.hostAccess, false); assert.equal(afterTransfer.rawCookie, null); + assert.notEqual((await request(path, undefined, host)).status, 200); + assert.deepEqual((await request(path, undefined, claimed.cookie)).data, transferred.data); + report.checks.push('Voluntary host handover preserves lobby; later admin creation retry cannot revive old private-seat access'); + } catch (error) { + report.failureRequest ??= report.lastRequest; + report.creationFailure = 'Administrator room creation acceptance failed; the dedicated lobby remains saved.'; + throw error; + } +} diff --git a/tools/verify-admin.mjs b/tools/verify-admin.mjs index 0e75afad..b5b4e729 100644 --- a/tools/verify-admin.mjs +++ b/tools/verify-admin.mjs @@ -6,6 +6,7 @@ import { readFile, mkdir, realpath, writeFile } from 'node:fs/promises'; import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { verifyRoomLifecycle } from './admin-lifecycle-verification.mjs'; +import { verifyRoomCreation } from './admin-creation-verification.mjs'; const options = {}; for (let n = 2; n < process.argv.length; n += 2) { @@ -30,7 +31,7 @@ async function request(path, body, cookie, origin = base, expectedAdminId = admi const response = await fetch(base + path, { method: body === undefined ? 'GET' : 'POST', headers: { ...(body === undefined ? {} : { 'content-type': 'application/json', origin }), ...(cookie ? { cookie } : {}), - ...(expectedAdminId && /^\/api\/admin\/rooms\/[^/]+\/control$/.test(path) ? { 'X-Dune-Admin-Id': expectedAdminId } : {}) }, + ...(expectedAdminId && (path === '/api/admin/rooms' && body !== undefined || /^\/api\/admin\/rooms\/[^/]+\/control$/.test(path)) ? { 'X-Dune-Admin-Id': expectedAdminId } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15000), }); report.lastRequest = { path, status: response.status }; @@ -83,6 +84,7 @@ try { assert.equal(lifecycleSession.status, 200); session1 = lifecycleSession.cookie; await verifyRoomLifecycle(request, session1, report); + await verifyRoomCreation(request, session1, report); } report.passed = true; } catch (error) {