Add recoverable administrator room removal and restoration
Preserve room JSON, seats, credentials and messages while removed rooms deny ordinary reads and writes. Add deliberate room-scoped controls, removed-directory filters, persistent player notices and live-authorized atomic version/revision audit receipts. Retain uncertain room/seat requests and hidden recovery drafts through removal; restore the prior pause/join settings without reviving revoked credentials. Fix AI readiness after a joining human or human faction change so real lobby starts remain legal. Verified types, lint, 5314 offline tests, 52 HTTP tests and build. Fourteen authenticated HTTP groups and independent privacy/persistence review passed. Desktop/mobile browser QA confirmed a saved removal after a lost response and refresh, one audit on exact retry, persistent notices and recovery draft continuity. All 1,945 baseline rooms, 4,121 seats and related records preserved. Positive authenticated production admin acceptance remains pending authorized QA operator access; full admin and rules gates remain open.
This commit is contained in:
1 parent
c0cc4de9e1
commit
238de3860d
44 files changed
+4626
-90
No files matched your search
@@ -9,10 +9,11 @@ const text = (value: unknown, max = 80) => typeof value === 'string' ? value.sli
|
||||
const integer = (value: unknown) => Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : null;
|
||||
|
||||
/** Only emit allowlisted fields. Never call viewGame with an administrator's or host's seat. */
|
||||
type DirectoryRow = { code: string; version: number; updated_at: number; state: string; control_paused?: number; control_join_locked?: number; control_revision?: number; control_updated_at?: number | null };
|
||||
type DirectoryRow = { code: string; version: number; updated_at: number; state: string; removed?: number; control_paused?: number; control_join_locked?: number; control_revision?: number; control_updated_at?: number | null };
|
||||
export function projectAdminRoom(row: DirectoryRow): AdminRoom {
|
||||
const base: AdminRoom = {
|
||||
code: row.code, version: row.version, updatedAt: row.updated_at,
|
||||
removed: row.removed === 1,
|
||||
status: 'unreadable', host: null, players: [], advanced: false, preview: false,
|
||||
expansions: [], modules: [], turn: null, phase: 'Unavailable',
|
||||
pending: { label: 'Saved room needs inspection', owners: [] },
|
||||
@@ -49,7 +50,7 @@ export function projectAdminRoom(row: DirectoryRow): AdminRoom {
|
||||
}
|
||||
|
||||
export async function readAdminDirectory(database: D1Database, identity: AdminIdentity, search: URLSearchParams, now = Date.now()): Promise<AdminDirectory> {
|
||||
const allowed = ['q', 'status', 'rules', 'sort', 'page', 'availability'];
|
||||
const allowed = ['q', 'status', 'rules', 'sort', 'page', 'availability', 'removal'];
|
||||
if ([...search.keys()].some(k => !allowed.includes(k) || search.getAll(k).length !== 1)) throw new AdminError('Invalid room filters.', 400);
|
||||
const q = (search.get('q') ?? '').trim();
|
||||
const status = search.get('status') ?? 'all';
|
||||
@@ -57,8 +58,9 @@ export async function readAdminDirectory(database: D1Database, identity: AdminId
|
||||
const sort = search.get('sort') ?? 'recent';
|
||||
const rawPage = search.get('page') ?? '1';
|
||||
const availability = search.get('availability') ?? 'all';
|
||||
const removal = search.get('removal') ?? 'active';
|
||||
if (q.length > 80 || !['all', ...statuses].includes(status) || !['all', 'basic', 'advanced'].includes(rules) ||
|
||||
!['recent', 'oldest', 'code'].includes(sort) || !['all', 'running', 'paused', 'locked'].includes(availability) || !/^[1-9][0-9]{0,5}$/.test(rawPage)) throw new AdminError('Invalid room filters.', 400);
|
||||
!['recent', 'oldest', 'code'].includes(sort) || !['all', 'running', 'paused', 'locked'].includes(availability) || !['active', 'removed', 'all'].includes(removal) || !/^[1-9][0-9]{0,5}$/.test(rawPage)) throw new AdminError('Invalid room filters.', 400);
|
||||
const page = Number(rawPage), pageSize = 25;
|
||||
// json_valid protects the rest of the directory if a legacy save is malformed.
|
||||
const state = "CASE WHEN json_valid(state) THEN state ELSE '{}' END";
|
||||
@@ -82,13 +84,14 @@ export async function readAdminDirectory(database: D1Database, identity: AdminId
|
||||
if (availability === 'paused') where.push('c.paused = 1');
|
||||
if (availability === 'running') where.push('COALESCE(c.paused, 0) = 0');
|
||||
if (availability === 'locked') where.push('c.join_locked = 1');
|
||||
if (removal !== 'all') { where.push('COALESCE(m.removed,0) = ?'); args.push(removal === 'removed' ? 1 : 0); }
|
||||
const predicate = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
||||
const order = sort === 'code' ? 'r.code ASC' : `r.updated_at ${sort === 'oldest' ? 'ASC' : 'DESC'}, r.code ASC`;
|
||||
const source = 'FROM rooms r LEFT JOIN room_controls c ON c.room_code = r.code';
|
||||
const source = 'FROM rooms r LEFT JOIN room_controls c ON c.room_code = r.code LEFT JOIN room_removals m ON m.room_code = r.code';
|
||||
const results = await database.batch([
|
||||
database.prepare(authority).bind(...credentials),
|
||||
database.prepare(`SELECT COUNT(*) AS total ${source} ${predicate}`).bind(...args),
|
||||
database.prepare(`SELECT r.code,r.version,r.updated_at,r.state,COALESCE(c.paused,0) AS control_paused,COALESCE(c.join_locked,0) AS control_join_locked,COALESCE(c.revision,0) AS control_revision,c.updated_at AS control_updated_at ${source} ${predicate} ORDER BY ${order} LIMIT ? OFFSET ?`).bind(...args, pageSize, (page - 1) * pageSize),
|
||||
database.prepare(`SELECT r.code,r.version,r.updated_at,r.state,COALESCE(m.removed,0) AS removed,COALESCE(c.paused,0) AS control_paused,COALESCE(c.join_locked,0) AS control_join_locked,COALESCE(c.revision,0) AS control_revision,c.updated_at AS control_updated_at ${source} ${predicate} ORDER BY ${order} LIMIT ? OFFSET ?`).bind(...args, pageSize, (page - 1) * pageSize),
|
||||
]);
|
||||
if (!results[0].results.length) throw new AdminError('Administrator sign-in required.', 401);
|
||||
const total = Number((results[1].results[0] as { total: number }).total);
|
||||
|
||||
Reference in new issue
Block a user