Add administrative room close and reopen controls
Preserve readable private tables, discussion and pending games while closure blocks new play, joins, messages, AI and seat mutations. Add live-authorized atomic closure audits, room-version/revision fences, dedicated controls and exact saved retries. Reopen with the saved pause/join settings and current credentials; restoring a removed but closed room keeps its AI stopped. Verified types, lint, 5356 offline tests, 52 HTTP tests and build. Seventeen dedicated authenticated HTTP groups and independent privacy/persistence review passed. Desktop/mobile browser QA confirmed one close after a lost response and refresh, persistent readable notices, retained private draft, mobile reopening and saved-seat refresh. All 2,001 baseline rooms, 4,243 seats and related records preserved. Positive production admin acceptance awaits authorized QA access; full admin and rules gates remain open.
This commit is contained in:
1 parent
238de3860d
commit
98b1b3261b
49 files changed
+4255
-86
No files matched your search
@@ -0,0 +1,225 @@
|
||||
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_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 = {
|
||||
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,
|
||||
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_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.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,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user