Add owner-only room backup snapshots and audited exports
This commit is contained in:
1 parent
92564f8de5
commit
53a1ba77a1
19 files changed
+5613
-4
No files matched your search
@@ -0,0 +1,178 @@
|
||||
import { AdminError, type AdminIdentity } from './admin-access';
|
||||
|
||||
export type BackupMetadata = { id: string; roomCode: string; roomVersion: number; createdAt: number; sizeBytes: number; digest: string };
|
||||
export type BackupInput = { roomCode: string; operationId: string; expectedVersion: number; reason: string };
|
||||
export const MAX_BACKUP_BYTES = 1_800_000;
|
||||
export const MAX_ROOM_BACKUP_BYTES = 16 * 1024 * 1024;
|
||||
export const MAX_TOTAL_BACKUP_BYTES = 64 * 1024 * 1024;
|
||||
export const MAX_ROOM_BACKUPS = 32;
|
||||
export const MAX_BACKUP_DOWNLOADS = 32_768;
|
||||
const roomCodePattern = /^[A-Z2-9]{8}$/;
|
||||
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const authority = `SELECT 1 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='owner'`;
|
||||
const authArgs = (identity: AdminIdentity, now: number) => [identity.sessionHash, identity.id, now] as const;
|
||||
|
||||
// Explicit, versioned allowlist of all room-owned persistence. No admin access keys,
|
||||
// admin sessions or other rooms enter the backup. Add new room-owned tables here when introduced.
|
||||
const roomTables = {
|
||||
seats: ['token_hash', 'revoked', 'room_code', 'player_id'],
|
||||
room_controls: ['room_code', 'paused', 'join_locked', 'revision', 'updated_at'],
|
||||
room_removals: ['room_code', 'removed', 'revision', 'removed_at', 'updated_at'],
|
||||
room_closures: ['room_code', 'closed', 'revision', 'closed_at', 'updated_at'],
|
||||
room_archives: ['room_code', 'archived', 'revision', 'archived_at', 'updated_at'],
|
||||
seat_recovery_keys: ['room_code', 'player_id', 'recovery_hash', 'current_operation_hash'],
|
||||
seat_recovery_receipts: ['room_code', 'player_id', 'operation_hash', 'recovery_hash', 'session_hash'],
|
||||
seat_handover_offers: ['room_code', 'player_id', 'offer_hash', 'secret_hash', 'issuer_session_hash', 'expires_at', 'claim_operation_hash', 'session_hash', 'claimed_at'],
|
||||
seat_handover_claim_receipts: ['room_code', 'player_id', 'operation_hash', 'offer_hash', 'secret_hash', 'session_hash', 'claim_fence', 'claimed_at'],
|
||||
seat_ai_delegations: ['room_code', 'owner_id', 'grant_id', 'delegate_id', 'difficulty', 'owner_session_hash', 'delegate_session_hash', 'expires_at', 'used_at', 'revoked_at'],
|
||||
room_entry_receipts: ['operation_hash', 'request_hash', 'session_hash', 'room_code', 'player_id'],
|
||||
seat_discussion_controls: ['room_code', 'player_id', 'muted', 'revision', 'updated_at'],
|
||||
room_messages: ['sequence', 'room_code', 'id', 'sender_id', 'sender_session_hash', 'sender_name', 'sender_faction', 'recipient_id', 'recipient_name', 'body', 'created_at'],
|
||||
} as const;
|
||||
const entries = Object.entries(roomTables);
|
||||
const tableJson = entries.map(([table, columns]) =>
|
||||
`'${table}',json((SELECT json_group_array(json_object(${columns.map(column => `'${column}',${column}`).join(',')})) FROM (SELECT ${columns.join(',')} FROM ${table} WHERE room_code=r.code ORDER BY rowid)))`,
|
||||
).join(',');
|
||||
// Counting encoded JSON rows plus one separator each is a conservative upper
|
||||
// bound on the eventual payload. Guard BEFORE constructing any >2MB D1 cell.
|
||||
const skeleton = `json_object('format',1,'room',json_object('code',r.code,'state','','version',r.version,'updated_at',r.updated_at),'tables',json_object(${entries.map(([table]) => `'${table}',json('[]')`).join(',')}))`;
|
||||
// Quote at most 100,000 characters at a time: escaping a near-2MB saved
|
||||
// state in a single json_object/json_quote would itself exceed D1's cell limit.
|
||||
const stateBytes = `(WITH RECURSIVE chunks(pos) AS (
|
||||
SELECT 1 UNION ALL SELECT pos+100000 FROM chunks WHERE pos+100000 <= length(r.state)
|
||||
) SELECT COALESCE(SUM(length(CAST(json_quote(substr(r.state,pos,100000)) AS BLOB))-2),0) FROM chunks)`;
|
||||
const estimate = `length(CAST(${skeleton} AS BLOB)) + ${stateBytes} + ${entries.map(([table, columns]) =>
|
||||
`(SELECT COALESCE(SUM(length(CAST(json_object(${columns.map(column => `'${column}',${column}`).join(',')}) AS BLOB))+1),0) FROM ${table} WHERE room_code=r.code)`,
|
||||
).join(' + ')}`;
|
||||
const snapshot = `SELECT r.code,r.version,CASE WHEN (${estimate}) <= ${MAX_BACKUP_BYTES} THEN json_object('format',1,'room',json_object('code',r.code,'state',r.state,'version',r.version,'updated_at',r.updated_at),'tables',json_object(${tableJson})) ELSE NULL END payload FROM rooms r WHERE r.code=? AND EXISTS (${authority})`;
|
||||
|
||||
type RawMetadata = { operation_id: string; room_code: string; room_version: number; created_at: number; size_bytes: number; digest: string };
|
||||
type RawBackup = RawMetadata & { payload: string };
|
||||
const metadata = (row: RawMetadata): BackupMetadata => ({ id: row.operation_id, roomCode: row.room_code, roomVersion: row.room_version, createdAt: row.created_at, sizeBytes: row.size_bytes, digest: row.digest });
|
||||
const digestOf = async (value: string) => Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))), byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
const owner = (result: D1Result) => {
|
||||
if (!result.results.length) throw new AdminError('Owner access required. Sign in again.', 403);
|
||||
};
|
||||
type Receipt = RawMetadata & { actor_admin_id: string; request_hash: string };
|
||||
const receiptQuery = `SELECT operation_id,actor_admin_id,room_code,request_hash,room_version,created_at,size_bytes,digest FROM admin_room_backups WHERE operation_id=? AND EXISTS (${authority})`;
|
||||
function replay(receipt: Receipt, identity: AdminIdentity, roomCode: string, requestHash: string): BackupMetadata {
|
||||
if (receipt.actor_admin_id !== identity.id || receipt.room_code !== roomCode || receipt.request_hash !== requestHash)
|
||||
throw new AdminError('This backup operation belongs to another administrator or request.', 409);
|
||||
return metadata(receipt);
|
||||
}
|
||||
export function validBackupInput(value: unknown): value is BackupInput {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const input = value as Record<string, unknown>;
|
||||
return Object.keys(input).length === 4 && Object.keys(input).every(key => ['roomCode', 'operationId', 'expectedVersion', 'reason'].includes(key)) &&
|
||||
typeof input.roomCode === 'string' && roomCodePattern.test(input.roomCode) &&
|
||||
typeof input.operationId === 'string' && uuidPattern.test(input.operationId) &&
|
||||
typeof input.expectedVersion === 'number' && Number.isSafeInteger(input.expectedVersion) && input.expectedVersion >= 0 &&
|
||||
typeof input.reason === 'string' && input.reason.trim().length >= 1 && input.reason.length <= 300 && !/\p{Cc}/u.test(input.reason);
|
||||
}
|
||||
|
||||
/** Metadata-only directory, gated by current database authorization in the read. */
|
||||
export async function listAdminBackups(database: D1Database, identity: AdminIdentity, code: string, now = Date.now()): Promise<BackupMetadata[]> {
|
||||
if (!roomCodePattern.test(code)) throw new AdminError('Enter the eight-character room code.', 400);
|
||||
const results = await database.batch([
|
||||
database.prepare(authority).bind(...authArgs(identity, now)),
|
||||
database.prepare(`SELECT operation_id,room_code,room_version,created_at,size_bytes,digest FROM admin_room_backups WHERE room_code=? AND EXISTS (${authority}) ORDER BY created_at DESC,operation_id DESC LIMIT 100`).bind(code, ...authArgs(identity, now)),
|
||||
]);
|
||||
owner(results[0]);
|
||||
return (results[1].results as RawMetadata[]).map(metadata);
|
||||
}
|
||||
|
||||
/** An immutable room snapshot; staged read is compared byte-for-byte to a fresh
|
||||
* snapshot inside the atomic insert transaction, including unversioned room tables. */
|
||||
export async function captureAdminBackup(database: D1Database, identity: AdminIdentity, input: BackupInput, now = Date.now()): Promise<{ backup: BackupMetadata; replayed: boolean }> {
|
||||
if (!validBackupInput(input)) throw new AdminError('Provide a room, current version, operation identifier and operational reason.', 400);
|
||||
const { roomCode, operationId, expectedVersion } = input;
|
||||
const reason = input.reason.trim();
|
||||
const requestHash = await digestOf(JSON.stringify({ roomCode, expectedVersion, reason }));
|
||||
const existing = await database.prepare(receiptQuery).bind(operationId, ...authArgs(identity, now)).first<Receipt>();
|
||||
// Read candidate only when no receipt exists. Authorization is in the SQL.
|
||||
const candidate = existing ? null : await database.prepare(snapshot).bind(roomCode, ...authArgs(identity, now)).first<{ version: number; payload: string | null }>();
|
||||
const oversized = candidate?.version === expectedVersion && (candidate.payload === null || new TextEncoder().encode(candidate.payload).byteLength > MAX_BACKUP_BYTES);
|
||||
if (oversized) {
|
||||
// A competing request may have committed the very same ID after the staged read.
|
||||
const checked = await database.batch([
|
||||
database.prepare(authority).bind(...authArgs(identity, now)),
|
||||
database.prepare(receiptQuery).bind(operationId, ...authArgs(identity, now)),
|
||||
database.prepare(`SELECT r.version,(${estimate}) estimated_bytes FROM rooms r WHERE r.code=? AND EXISTS (${authority})`).bind(roomCode, ...authArgs(identity, now)),
|
||||
]);
|
||||
owner(checked[0]);
|
||||
const receipt = checked[1].results[0] as Receipt | undefined;
|
||||
if (receipt) return { backup: replay(receipt, identity, roomCode, requestHash), replayed: true };
|
||||
const state = checked[2].results[0] as { version: number; estimated_bytes: number } | undefined;
|
||||
if (!state) throw new AdminError('Room not found.', 404);
|
||||
if (state.version !== expectedVersion || state.estimated_bytes <= MAX_BACKUP_BYTES)
|
||||
throw new AdminError('Room or room-owned data changed. Refresh and retry with a new operation identifier.', 409);
|
||||
throw new AdminError('Room backup exceeds the 1,800,000-byte capture limit; nothing was saved.', 413);
|
||||
}
|
||||
const payload = candidate?.version === expectedVersion ? (candidate.payload ?? '') : '';
|
||||
const sizeBytes = new TextEncoder().encode(payload).byteLength;
|
||||
const digest = await digestOf(payload);
|
||||
const results = await database.batch([
|
||||
database.prepare(authority).bind(...authArgs(identity, now)),
|
||||
database.prepare(`INSERT INTO admin_room_backups (operation_id,actor_admin_id,room_code,request_hash,room_version,created_at,size_bytes,digest,payload,reason)
|
||||
SELECT ?,?,r.code,?,r.version,?,?,?,?,? FROM (${snapshot}) r
|
||||
WHERE r.version=? AND r.payload=? AND r.payload IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM admin_room_backups WHERE operation_id=?)
|
||||
AND NOT EXISTS (SELECT 1 FROM admin_room_backups WHERE room_code=r.code AND room_version=r.version AND payload=r.payload)
|
||||
AND (SELECT COUNT(*) FROM admin_room_backups WHERE room_code=r.code) < ?
|
||||
AND (SELECT COALESCE(SUM(size_bytes),0) FROM admin_room_backups WHERE room_code=r.code) + ? <= ?
|
||||
AND (SELECT COALESCE(SUM(size_bytes),0) FROM admin_room_backups) + ? <= ?`).bind(
|
||||
operationId, identity.id, requestHash, now, sizeBytes, digest, payload, reason,
|
||||
roomCode, ...authArgs(identity, now), expectedVersion, payload, operationId,
|
||||
MAX_ROOM_BACKUPS, sizeBytes, MAX_ROOM_BACKUP_BYTES, sizeBytes, MAX_TOTAL_BACKUP_BYTES,
|
||||
),
|
||||
database.prepare(receiptQuery).bind(operationId, ...authArgs(identity, now)),
|
||||
database.prepare(`SELECT r.version,(${estimate}) estimated_bytes,
|
||||
(SELECT COUNT(*) FROM admin_room_backups WHERE room_code=r.code) room_count,
|
||||
(SELECT COALESCE(SUM(size_bytes),0) FROM admin_room_backups WHERE room_code=r.code) room_bytes,
|
||||
(SELECT COALESCE(SUM(size_bytes),0) FROM admin_room_backups) total_bytes,
|
||||
EXISTS (SELECT 1 FROM admin_room_backups WHERE room_code=r.code AND room_version=r.version AND payload=?) duplicate
|
||||
FROM rooms r WHERE r.code=? AND EXISTS (${authority})`).bind(payload, roomCode, ...authArgs(identity, now)),
|
||||
]);
|
||||
owner(results[0]);
|
||||
const receipt = results[2].results[0] as Receipt | undefined;
|
||||
if (receipt) return { backup: replay(receipt, identity, roomCode, requestHash), replayed: results[1].meta.changes !== 1 };
|
||||
const state = results[3].results[0] as { version: number; estimated_bytes: number; room_count: number; room_bytes: number; total_bytes: number; duplicate: number } | undefined;
|
||||
if (!state) throw new AdminError('Room not found.', 404);
|
||||
if (state.version !== expectedVersion) throw new AdminError('Room changed. Refresh before creating another backup.', 409);
|
||||
if (state.estimated_bytes > MAX_BACKUP_BYTES) throw new AdminError('Room backup exceeds the 1,800,000-byte capture limit; nothing was saved.', 413);
|
||||
if (state.duplicate) throw new AdminError('This exact room version and snapshot was already backed up.', 409);
|
||||
if (state.room_count >= MAX_ROOM_BACKUPS || state.room_bytes + sizeBytes > MAX_ROOM_BACKUP_BYTES || state.total_bytes + sizeBytes > MAX_TOTAL_BACKUP_BYTES)
|
||||
throw new AdminError('Backup storage quota reached; no snapshot was saved.', 507);
|
||||
throw new AdminError('Room-owned data changed. Refresh and retry with a new operation identifier.', 409);
|
||||
}
|
||||
|
||||
/** A private payload is returned only if its owner-only export audit commits. */
|
||||
export async function downloadAdminBackup(database: D1Database, identity: AdminIdentity, id: string, now = Date.now()): Promise<{ backup: BackupMetadata; payload: string }> {
|
||||
if (!uuidPattern.test(id)) throw new AdminError('Invalid backup identifier.', 400);
|
||||
const downloadId = crypto.randomUUID();
|
||||
const results = await database.batch([
|
||||
database.prepare(authority).bind(...authArgs(identity, now)),
|
||||
database.prepare(`INSERT INTO admin_room_backup_downloads(download_id,operation_id,actor_admin_id,room_code,created_at)
|
||||
SELECT ?,b.operation_id,?,b.room_code,? FROM admin_room_backups b
|
||||
WHERE b.operation_id=? AND EXISTS (${authority})
|
||||
AND (SELECT COUNT(*) FROM admin_room_backup_downloads) < ?`).bind(downloadId, identity.id, now, id, ...authArgs(identity, now), MAX_BACKUP_DOWNLOADS),
|
||||
database.prepare(`SELECT operation_id,room_code,room_version,created_at,size_bytes,digest,payload FROM admin_room_backups
|
||||
WHERE operation_id=? AND EXISTS (SELECT 1 FROM admin_room_backup_downloads WHERE download_id=? AND actor_admin_id=?)
|
||||
AND EXISTS (${authority})`).bind(id, downloadId, identity.id, ...authArgs(identity, now)),
|
||||
database.prepare(`SELECT (SELECT COUNT(*) FROM admin_room_backup_downloads) count,
|
||||
EXISTS (SELECT 1 FROM admin_room_backups WHERE operation_id=?) present
|
||||
WHERE EXISTS (${authority})`).bind(id, ...authArgs(identity, now)),
|
||||
]);
|
||||
owner(results[0]);
|
||||
if (results[1].meta.changes !== 1) {
|
||||
const status = results[3].results[0] as { count: number; present: number } | undefined;
|
||||
if (status?.present && status.count >= MAX_BACKUP_DOWNLOADS)
|
||||
throw new AdminError('Backup download audit capacity reached; no private bytes were exported.', 507);
|
||||
throw new AdminError('Backup not found.', 404);
|
||||
}
|
||||
const row = results[2].results[0] as RawBackup | undefined;
|
||||
if (!row) throw new AdminError('Backup download could not be recorded.', 503);
|
||||
return { backup: metadata(row), payload: row.payload };
|
||||
}
|
||||
@@ -473,3 +473,37 @@ export const adminAudit = sqliteTable(
|
||||
},
|
||||
(table) => [index('admin_audit_created_at').on(table.createdAt)],
|
||||
);
|
||||
|
||||
// Immutable private room snapshots survive room/account deletion; only owner APIs expose them.
|
||||
export const adminRoomBackups = sqliteTable(
|
||||
'admin_room_backups',
|
||||
{
|
||||
operationId: text('operation_id').primaryKey(),
|
||||
actorAdminId: text('actor_admin_id').notNull(),
|
||||
roomCode: text('room_code').notNull(),
|
||||
requestHash: text('request_hash').notNull(),
|
||||
roomVersion: integer('room_version').notNull(),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
sizeBytes: integer('size_bytes').notNull(),
|
||||
digest: text('digest').notNull(),
|
||||
payload: text('payload').notNull(),
|
||||
reason: text('reason').notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('admin_room_backups_room').on(table.roomCode, table.createdAt),
|
||||
check('admin_room_backups_size', sql`${table.sizeBytes} BETWEEN 1 AND 1800000 AND ${table.sizeBytes} = length(CAST(${table.payload} AS BLOB))`),
|
||||
],
|
||||
);
|
||||
|
||||
// Each private export is independently recorded only on successful authorized download.
|
||||
export const adminRoomBackupDownloads = sqliteTable(
|
||||
'admin_room_backup_downloads',
|
||||
{
|
||||
downloadId: text('download_id').primaryKey(),
|
||||
operationId: text('operation_id').notNull(),
|
||||
actorAdminId: text('actor_admin_id').notNull(),
|
||||
roomCode: text('room_code').notNull(),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
},
|
||||
(table) => [index('admin_room_backup_downloads_room').on(table.roomCode, table.createdAt)],
|
||||
);
|
||||
Reference in new issue
Block a user