Add reversible administrator room archiving
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.
This commit is contained in:
1 parent
98b1b3261b
commit
e918e2e0c0
35 files changed
+3624
-38
No files matched your search
@@ -0,0 +1,217 @@
|
||||
import { AdminError, type AdminIdentity } from './admin-access';
|
||||
import {
|
||||
validAdminArchiveInput,
|
||||
type AdminArchiveInput,
|
||||
type AdminArchiveResult,
|
||||
type AdminArchiveView,
|
||||
} from '../lib/admin-archive';
|
||||
|
||||
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_closures WHERE room_code = r.code AND closed = 1) AS closed,EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1) AS removed,COALESCE(m.archived,0) AS archived,
|
||||
COALESCE(m.revision,0) AS revision,m.archived_at,m.updated_at,
|
||||
COALESCE(c.paused,0) AS paused,COALESCE(c.join_locked,0) AS join_locked
|
||||
FROM rooms r LEFT JOIN room_archives 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 = {
|
||||
closed: number;
|
||||
removed: number;
|
||||
code: string;
|
||||
version: number;
|
||||
archived: number;
|
||||
revision: number;
|
||||
archived_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): AdminArchiveView => ({
|
||||
code: row.code,
|
||||
closed: row.closed === 1,
|
||||
removed: row.removed === 1,
|
||||
version: row.version,
|
||||
archived: row.archived === 1,
|
||||
revision: row.revision,
|
||||
archivedAt: row.archived_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 archived and unreadable saves. */
|
||||
export async function readAdminArchive(
|
||||
database: D1Database,
|
||||
identity: AdminIdentity,
|
||||
code: string,
|
||||
now = Date.now(),
|
||||
): Promise<AdminArchiveView> {
|
||||
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 archive transition commit together.
|
||||
* Neither transition changes saved state, credentials, closure or AI pacing. */
|
||||
export async function applyAdminArchive(
|
||||
database: D1Database,
|
||||
identity: AdminIdentity,
|
||||
code: string,
|
||||
input: AdminArchiveInput,
|
||||
now = Date.now(),
|
||||
): Promise<AdminArchiveResult> {
|
||||
const args = credentials(identity, code, now);
|
||||
if (!validAdminArchiveInput(input))
|
||||
throw new AdminError(
|
||||
'Provide the room version, archive revision, requested state, a fresh operation identifier and a reason of 1–300 characters.',
|
||||
400,
|
||||
);
|
||||
const { operationId, expectedVersion, expectedRevision, archived } = input;
|
||||
const actorId = identity.id,
|
||||
reason = input.reason.trim();
|
||||
const digest = await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({ expectedVersion, expectedRevision, archived, 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_archives
|
||||
(operation_id,actor_admin_id,room_code,request_hash,expected_version,expected_revision,
|
||||
applied_version,applied_revision,archived,reason,before_metadata,after_metadata,created_at)
|
||||
SELECT ?,?,r.code,?,?,?,r.version + 1,COALESCE(m.revision,0) + 1,?,?,
|
||||
json_object('closed',json('true'),'removed',json('false'),'code',r.code,'version',r.version,'archived',json(CASE WHEN m.archived = 1 THEN 'true' ELSE 'false' END),
|
||||
'revision',COALESCE(m.revision,0),'archivedAt',m.archived_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('closed',json('true'),'removed',json('false'),'code',r.code,'version',r.version + 1,'archived',json(CASE WHEN ? = 1 THEN 'true' ELSE 'false' END),
|
||||
'revision',COALESCE(m.revision,0) + 1,'archivedAt',?,'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_archives 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.archived,0) != ?
|
||||
AND EXISTS (${mutationAuthority})
|
||||
AND EXISTS (SELECT 1 FROM room_closures WHERE room_code = r.code AND closed = 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_archives WHERE operation_id = ?)`)
|
||||
.bind(
|
||||
operationId,
|
||||
actorId,
|
||||
requestHash,
|
||||
expectedVersion,
|
||||
expectedRevision,
|
||||
Number(archived),
|
||||
reason,
|
||||
Number(archived),
|
||||
archived ? now : null,
|
||||
now,
|
||||
now,
|
||||
code,
|
||||
expectedVersion,
|
||||
expectedRevision,
|
||||
Number(archived),
|
||||
...args,
|
||||
operationId,
|
||||
),
|
||||
// Archive is directory metadata only. Preserve the entire saved JSON and its timestamps.
|
||||
database.prepare('UPDATE rooms SET version = version + 1 WHERE code = ? AND changes() = 1').bind(code),
|
||||
database
|
||||
.prepare(`INSERT INTO room_archives(room_code,archived,revision,archived_at,updated_at)
|
||||
SELECT room_code,archived,applied_revision,CASE WHEN archived = 1 THEN created_at ELSE NULL END,created_at
|
||||
FROM admin_room_archives WHERE operation_id = ? AND changes() = 1
|
||||
ON CONFLICT(room_code) DO UPDATE SET archived = excluded.archived,revision = excluded.revision,
|
||||
archived_at = excluded.archived_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_archives 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 archive 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 archive.', 409);
|
||||
if (!row.closed) throw new AdminError('Close this room before changing its archive state.', 409);
|
||||
if (row.version !== expectedVersion || row.revision !== expectedRevision)
|
||||
throw new AdminError(
|
||||
'The room or archive settings changed. Refresh before choosing a new operation.',
|
||||
409,
|
||||
);
|
||||
throw new AdminError(
|
||||
archived
|
||||
? 'This room is already archived.'
|
||||
: 'This room is already unarchived.',
|
||||
409,
|
||||
);
|
||||
}
|
||||
+5
-1
@@ -11,13 +11,14 @@ const authority = `SELECT a.role FROM admin_sessions s JOIN admin_accounts a ON
|
||||
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,
|
||||
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;
|
||||
@@ -38,6 +39,7 @@ type Receipt = {
|
||||
};
|
||||
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,
|
||||
@@ -139,6 +141,7 @@ export async function applyAdminClosure(
|
||||
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(
|
||||
@@ -211,6 +214,7 @@ export async function applyAdminClosure(
|
||||
}
|
||||
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.',
|
||||
|
||||
@@ -9,11 +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; removed?: number; control_closed?: number; 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; archived?: number; control_closed?: 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,
|
||||
removed: row.removed === 1, archived: row.archived === 1,
|
||||
status: 'unreadable', host: null, players: [], advanced: false, preview: false,
|
||||
expansions: [], modules: [], turn: null, phase: 'Unavailable',
|
||||
pending: { label: 'Saved room needs inspection', owners: [] },
|
||||
@@ -50,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', 'removal'];
|
||||
const allowed = ['q', 'status', 'rules', 'sort', 'page', 'availability', 'removal', 'archive'];
|
||||
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';
|
||||
@@ -59,8 +59,9 @@ export async function readAdminDirectory(database: D1Database, identity: AdminId
|
||||
const rawPage = search.get('page') ?? '1';
|
||||
const availability = search.get('availability') ?? 'all';
|
||||
const removal = search.get('removal') ?? 'active';
|
||||
const archive = search.get('archive') ?? 'unarchived';
|
||||
if (q.length > 80 || !['all', ...statuses].includes(status) || !['all', 'basic', 'advanced'].includes(rules) ||
|
||||
!['recent', 'oldest', 'code'].includes(sort) || !['all', 'running', 'paused', 'locked', 'closed'].includes(availability) || !['active', 'removed', 'all'].includes(removal) || !/^[1-9][0-9]{0,5}$/.test(rawPage)) throw new AdminError('Invalid room filters.', 400);
|
||||
!['recent', 'oldest', 'code'].includes(sort) || !['all', 'running', 'paused', 'locked', 'closed'].includes(availability) || !['active', 'removed', 'all'].includes(removal) || !['unarchived', 'archived', 'all'].includes(archive) || !/^[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";
|
||||
@@ -86,13 +87,14 @@ export async function readAdminDirectory(database: D1Database, identity: AdminId
|
||||
if (availability === 'closed') where.push('cl.closed = 1');
|
||||
if (availability === 'locked') where.push('c.join_locked = 1');
|
||||
if (removal !== 'all') { where.push('COALESCE(m.removed,0) = ?'); args.push(removal === 'removed' ? 1 : 0); }
|
||||
if (archive !== 'all') { where.push('COALESCE(ar.archived,0) = ?'); args.push(archive === 'archived' ? 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 LEFT JOIN room_removals m ON m.room_code = r.code LEFT JOIN room_closures cl ON cl.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 LEFT JOIN room_closures cl ON cl.room_code = r.code LEFT JOIN room_archives ar ON ar.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(m.removed,0) AS removed,COALESCE(cl.closed,0) AS control_closed,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(ar.archived,0) AS archived,COALESCE(m.removed,0) AS removed,COALESCE(cl.closed,0) AS control_closed,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);
|
||||
|
||||
+3
-1
@@ -11,13 +11,14 @@ const authority = `SELECT a.role FROM admin_sessions s JOIN admin_accounts a ON
|
||||
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_closures WHERE room_code = r.code AND closed = 1) AS closed,COALESCE(m.removed,0) AS removed,
|
||||
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_closures WHERE room_code = r.code AND closed = 1) AS closed,COALESCE(m.removed,0) AS removed,
|
||||
COALESCE(m.revision,0) AS revision,m.removed_at,m.updated_at,
|
||||
COALESCE(c.paused,0) AS paused,COALESCE(c.join_locked,0) AS join_locked
|
||||
FROM rooms r LEFT JOIN room_removals 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;
|
||||
closed: number;
|
||||
code: string;
|
||||
version: number;
|
||||
@@ -38,6 +39,7 @@ type Receipt = {
|
||||
};
|
||||
const project = (row: Row): AdminRemovalView => ({
|
||||
code: row.code,
|
||||
...(row.archived === 1 ? { archived: true as const } : {}),
|
||||
...(row.closed === 1 ? { closed: true as const } : {}),
|
||||
version: row.version,
|
||||
removed: row.removed === 1,
|
||||
|
||||
@@ -136,6 +136,51 @@ export const adminRoomClosures = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
// Archive organizes closed rooms without changing saved games or credentials.
|
||||
export const roomArchives = sqliteTable(
|
||||
'room_archives',
|
||||
{
|
||||
roomCode: text('room_code')
|
||||
.primaryKey()
|
||||
.references(() => rooms.code, { onDelete: 'cascade' }),
|
||||
archived: integer('archived').notNull().default(0),
|
||||
revision: integer('revision').notNull().default(0),
|
||||
archivedAt: integer('archived_at'),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
},
|
||||
(table) => [
|
||||
check('room_archives_archived', sql`${table.archived} IN (0, 1)`),
|
||||
check('room_archives_revision', sql`${table.revision} >= 0`),
|
||||
check(
|
||||
'room_archives_time',
|
||||
sql`(${table.archived} = 1 AND ${table.archivedAt} IS NOT NULL) OR (${table.archived} = 0 AND ${table.archivedAt} IS NULL)`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// Exact operation receipts also retain safe audit evidence independently of rooms/accounts.
|
||||
export const adminRoomArchives = sqliteTable(
|
||||
'admin_room_archives',
|
||||
{
|
||||
operationId: text('operation_id').primaryKey(),
|
||||
actorAdminId: text('actor_admin_id').notNull(),
|
||||
roomCode: text('room_code').notNull(),
|
||||
requestHash: text('request_hash').notNull(),
|
||||
expectedVersion: integer('expected_version').notNull(),
|
||||
expectedRevision: integer('expected_revision').notNull(),
|
||||
appliedVersion: integer('applied_version').notNull(),
|
||||
appliedRevision: integer('applied_revision').notNull(),
|
||||
archived: integer('archived').notNull(),
|
||||
reason: text('reason').notNull(),
|
||||
beforeMetadata: text('before_metadata').notNull(),
|
||||
afterMetadata: text('after_metadata').notNull(),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('admin_room_archives_room').on(table.roomCode, table.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
// Durable receipts are also the room operations audit. They intentionally survive
|
||||
// account/room removal, and contain no credentials, game state or private cards.
|
||||
export const adminRoomAudit = sqliteTable(
|
||||
|
||||
Reference in new issue
Block a user