Organize closed rooms with archive filters, live-authorized atomic receipts and exact saved retries. Preserve saved games, timestamps, credentials, discussion and AI deadlines. Unarchive leaves play closed; reopen requires unarchive, and removal/restoration preserves both flags. Verified types, lint, 5379 offline tests, 52 HTTP tests and build. Twenty dedicated administrator HTTP groups and independent privacy/persistence review passed. Desktop/mobile browser QA confirmed a single archive after a lost response and refresh, readable recovery text, mobile unarchive and separate reopening, and saved-seat restoration. All 2,049 baseline rooms, 4,348 seats and related records preserved. Authenticated production admin acceptance awaits authorized QA access; full admin and rules gates remain open.
230 lines
9.5 KiB
TypeScript
230 lines
9.5 KiB
TypeScript
import { AdminError, type AdminIdentity } from './admin-access';
|
||
import {
|
||
validAdminClosureInput,
|
||
type AdminClosureInput,
|
||
type AdminClosureResult,
|
||
type AdminClosureView,
|
||
} from '../lib/admin-closure';
|
||
|
||
const authority = `SELECT a.role FROM admin_sessions s JOIN admin_accounts a ON a.id = s.admin_id
|
||
WHERE s.token_hash = ? AND a.id = ? AND a.enabled = 1 AND s.revoked_at IS NULL
|
||
AND s.expires_at > ? AND s.generation = a.session_generation
|
||
AND a.role IN ('owner','operator','viewer')`;
|
||
const mutationAuthority = authority + " AND a.role IN ('owner','operator')";
|
||
const roomSelect = `SELECT r.code,r.version,EXISTS (SELECT 1 FROM room_archives WHERE room_code = r.code AND archived = 1) AS archived,EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1) AS removed,COALESCE(m.closed,0) AS closed,
|
||
COALESCE(m.revision,0) AS revision,m.closed_at,m.updated_at,
|
||
COALESCE(c.paused,0) AS paused,COALESCE(c.join_locked,0) AS join_locked
|
||
FROM rooms r LEFT JOIN room_closures m ON m.room_code = r.code
|
||
LEFT JOIN room_controls c ON c.room_code = r.code
|
||
WHERE r.code = ? AND EXISTS (${authority})`;
|
||
type Row = {
|
||
archived: number;
|
||
removed: number;
|
||
code: string;
|
||
version: number;
|
||
closed: number;
|
||
revision: number;
|
||
closed_at: number | null;
|
||
updated_at: number | null;
|
||
paused: number;
|
||
join_locked: number;
|
||
};
|
||
type Receipt = {
|
||
operation_id: string;
|
||
actor_admin_id: string;
|
||
room_code: string;
|
||
request_hash: string;
|
||
applied_version: number;
|
||
applied_revision: number;
|
||
};
|
||
const project = (row: Row): AdminClosureView => ({
|
||
code: row.code,
|
||
...(row.archived === 1 ? { archived: true as const } : {}),
|
||
removed: row.removed === 1,
|
||
version: row.version,
|
||
closed: row.closed === 1,
|
||
revision: row.revision,
|
||
closedAt: row.closed_at,
|
||
updatedAt: row.updated_at,
|
||
paused: row.paused === 1,
|
||
joinLocked: row.join_locked === 1,
|
||
});
|
||
function credentials(identity: AdminIdentity, code: string, now: number) {
|
||
if (
|
||
!identity ||
|
||
typeof identity.id !== 'string' ||
|
||
typeof identity.sessionHash !== 'string' ||
|
||
identity.sessionHash.length !== 64 ||
|
||
!/^[0-9a-f]{64}$/.test(identity.sessionHash)
|
||
)
|
||
throw new AdminError('Administrator sign-in required.', 401);
|
||
if (
|
||
typeof code !== 'string' ||
|
||
code.length !== 8 ||
|
||
!/^[A-Z0-9]{8}$/.test(code)
|
||
)
|
||
throw new AdminError('Use an eight-character room code.', 400);
|
||
return [identity.sessionHash, identity.id, now];
|
||
}
|
||
function authorize(result: D1Result, mutation = false) {
|
||
const role = (result.results[0] as { role: string } | undefined)?.role;
|
||
if (!role) throw new AdminError('Administrator sign-in required.', 401);
|
||
if (mutation && role === 'viewer')
|
||
throw new AdminError(
|
||
'Your administrator role does not permit this action.',
|
||
403,
|
||
);
|
||
}
|
||
|
||
/** Public operational metadata remains available for closed and unreadable saves. */
|
||
export async function readAdminClosure(
|
||
database: D1Database,
|
||
identity: AdminIdentity,
|
||
code: string,
|
||
now = Date.now(),
|
||
): Promise<AdminClosureView> {
|
||
const args = credentials(identity, code, now);
|
||
const results = await database.batch([
|
||
database.prepare(authority).bind(...args),
|
||
database.prepare(roomSelect).bind(code, ...args),
|
||
]);
|
||
authorize(results[0]);
|
||
const row = results[1].results[0] as Row | undefined;
|
||
if (!row) throw new AdminError('Room not found.', 404);
|
||
return project(row);
|
||
}
|
||
|
||
/** An exact receipt, room-version fence and closure transition commit together.
|
||
* Neither transition replaces state or credentials; reopen only refreshes AI pacing. */
|
||
export async function applyAdminClosure(
|
||
database: D1Database,
|
||
identity: AdminIdentity,
|
||
code: string,
|
||
input: AdminClosureInput,
|
||
now = Date.now(),
|
||
): Promise<AdminClosureResult> {
|
||
const args = credentials(identity, code, now);
|
||
if (!validAdminClosureInput(input))
|
||
throw new AdminError(
|
||
'Provide the room version, closure revision, requested state, a fresh operation identifier and a reason of 1–300 characters.',
|
||
400,
|
||
);
|
||
const { operationId, expectedVersion, expectedRevision, closed } = input;
|
||
const actorId = identity.id,
|
||
reason = input.reason.trim();
|
||
const digest = await crypto.subtle.digest(
|
||
'SHA-256',
|
||
new TextEncoder().encode(
|
||
JSON.stringify({ expectedVersion, expectedRevision, closed, reason }),
|
||
),
|
||
);
|
||
const requestHash = Array.from(new Uint8Array(digest), (byte) =>
|
||
byte.toString(16).padStart(2, '0'),
|
||
).join('');
|
||
const results = await database.batch([
|
||
database.prepare(authority).bind(...args),
|
||
// Existing receipt IDs cannot enter this write, regardless of later room state.
|
||
database
|
||
.prepare(`INSERT INTO admin_room_closures
|
||
(operation_id,actor_admin_id,room_code,request_hash,expected_version,expected_revision,
|
||
applied_version,applied_revision,closed,reason,before_metadata,after_metadata,created_at)
|
||
SELECT ?,?,r.code,?,?,?,r.version + 1,COALESCE(m.revision,0) + 1,?,?,
|
||
json_object('code',r.code,'version',r.version,'closed',json(CASE WHEN m.closed = 1 THEN 'true' ELSE 'false' END),
|
||
'revision',COALESCE(m.revision,0),'closedAt',m.closed_at,'updatedAt',m.updated_at,
|
||
'paused',json(CASE WHEN c.paused = 1 THEN 'true' ELSE 'false' END),
|
||
'joinLocked',json(CASE WHEN c.join_locked = 1 THEN 'true' ELSE 'false' END)),
|
||
json_object('code',r.code,'version',r.version + 1,'closed',json(CASE WHEN ? = 1 THEN 'true' ELSE 'false' END),
|
||
'revision',COALESCE(m.revision,0) + 1,'closedAt',?,'updatedAt',?,
|
||
'paused',json(CASE WHEN c.paused = 1 THEN 'true' ELSE 'false' END),
|
||
'joinLocked',json(CASE WHEN c.join_locked = 1 THEN 'true' ELSE 'false' END)),?
|
||
FROM rooms r LEFT JOIN room_closures m ON m.room_code = r.code
|
||
LEFT JOIN room_controls c ON c.room_code = r.code
|
||
WHERE r.code = ? AND r.version = ? AND COALESCE(m.revision,0) = ? AND COALESCE(m.closed,0) != ?
|
||
AND EXISTS (${mutationAuthority})
|
||
AND NOT EXISTS (SELECT 1 FROM room_archives WHERE room_code = r.code AND archived = 1)
|
||
AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1)
|
||
AND NOT EXISTS (SELECT 1 FROM admin_room_closures WHERE operation_id = ?)`)
|
||
.bind(
|
||
operationId,
|
||
actorId,
|
||
requestHash,
|
||
expectedVersion,
|
||
expectedRevision,
|
||
Number(closed),
|
||
reason,
|
||
Number(closed),
|
||
closed ? now : null,
|
||
now,
|
||
now,
|
||
code,
|
||
expectedVersion,
|
||
expectedRevision,
|
||
Number(closed),
|
||
...args,
|
||
operationId,
|
||
),
|
||
// Closing never parses or changes JSON. Reopen tolerates malformed saved state.
|
||
database
|
||
.prepare(`UPDATE rooms SET version = version + 1,
|
||
state = CASE WHEN ? = 0 AND NOT EXISTS (
|
||
SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1
|
||
) THEN CASE WHEN json_valid(state) THEN CASE WHEN
|
||
json_extract(state, '$.status') IN ('setup','playing') AND
|
||
EXISTS (SELECT 1 FROM json_each(CASE WHEN json_type(state, '$.players') = 'array' THEN json_extract(state, '$.players') ELSE '[]' END) p
|
||
WHERE json_extract(CASE WHEN p.type = 'object' THEN p.value ELSE '{}' END, '$.bot') IS NOT NULL
|
||
OR json_extract(CASE WHEN p.type = 'object' THEN p.value ELSE '{}' END, '$.autopilot') IS NOT NULL)
|
||
THEN json_set(state, '$.botsPending', json('true'), '$.botNextActionAt', ?)
|
||
ELSE state END ELSE state END ELSE state END
|
||
WHERE code = ? AND changes() = 1`)
|
||
.bind(Number(closed), now + 1500, code),
|
||
database
|
||
.prepare(`INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at)
|
||
SELECT room_code,closed,applied_revision,CASE WHEN closed = 1 THEN created_at ELSE NULL END,created_at
|
||
FROM admin_room_closures WHERE operation_id = ? AND changes() = 1
|
||
ON CONFLICT(room_code) DO UPDATE SET closed = excluded.closed,revision = excluded.revision,
|
||
closed_at = excluded.closed_at,updated_at = excluded.updated_at`)
|
||
.bind(operationId),
|
||
database
|
||
.prepare(`SELECT operation_id,actor_admin_id,room_code,request_hash,applied_version,applied_revision
|
||
FROM admin_room_closures WHERE operation_id = ? AND EXISTS (${mutationAuthority})`)
|
||
.bind(operationId, ...args),
|
||
database.prepare(roomSelect).bind(code, ...args),
|
||
]);
|
||
authorize(results[0], true);
|
||
const receipt = results[4].results[0] as Receipt | undefined;
|
||
const row = results[5].results[0] as Row | undefined;
|
||
if (receipt) {
|
||
if (
|
||
receipt.actor_admin_id !== actorId ||
|
||
receipt.room_code !== code ||
|
||
receipt.request_hash !== requestHash
|
||
)
|
||
throw new AdminError(
|
||
'This closure operation is bound to a different administrator or request.',
|
||
409,
|
||
);
|
||
if (!row) throw new AdminError('Room not found.', 404);
|
||
return {
|
||
operationId,
|
||
replayed: results[1].meta.changes !== 1,
|
||
appliedRevision: receipt.applied_revision,
|
||
appliedVersion: receipt.applied_version,
|
||
room: project(row),
|
||
};
|
||
}
|
||
if (!row) throw new AdminError('Room not found.', 404);
|
||
if (row.removed) throw new AdminError('Restore this removed room before changing its closure.', 409);
|
||
if (row.archived) throw new AdminError('Unarchive this room before reopening it.', 409);
|
||
if (row.version !== expectedVersion || row.revision !== expectedRevision)
|
||
throw new AdminError(
|
||
'The room or closure settings changed. Refresh before choosing a new operation.',
|
||
409,
|
||
);
|
||
throw new AdminError(
|
||
closed
|
||
? 'This room is already closed.'
|
||
: 'This room is already open.',
|
||
409,
|
||
);
|
||
}
|