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,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { AdminBackups } from '@/components/admin-backups';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ClientRequestError, requestJson } from '@/lib/client-request';
|
||||||
|
import '../admin.css';
|
||||||
|
|
||||||
|
type Account = { id: string; role: 'owner' | 'operator' | 'viewer' };
|
||||||
|
const errorMessage = (error: unknown) => error instanceof Error ? error.message : 'Administrator access could not be checked.';
|
||||||
|
|
||||||
|
export default function BackupsPage() {
|
||||||
|
const [owner, setOwner] = useState<Account | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [notice, setNotice] = useState('');
|
||||||
|
const [revision, setRevision] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
let canceled = false;
|
||||||
|
requestJson<{ admin: Account }>('/api/admin/session', { cache: 'no-store' })
|
||||||
|
.then(result => {
|
||||||
|
if (canceled) return;
|
||||||
|
if (result.admin?.role !== 'owner' || !result.admin.id) {
|
||||||
|
setOwner(null);
|
||||||
|
setNotice('Room backups are available only to an owner. Sign in with an owner account to continue.');
|
||||||
|
} else { setOwner(result.admin); setNotice(''); }
|
||||||
|
})
|
||||||
|
.catch(error => { if (!canceled) {
|
||||||
|
setOwner(null);
|
||||||
|
setNotice(error instanceof ClientRequestError && error.status === 401
|
||||||
|
? 'Administrator sign-in required. Sign in to administration with an owner account.' : errorMessage(error));
|
||||||
|
} })
|
||||||
|
.finally(() => { if (!canceled) setLoading(false); });
|
||||||
|
return () => { canceled = true; };
|
||||||
|
}, [revision]);
|
||||||
|
|
||||||
|
return <main className="admin-shell">
|
||||||
|
<header className="admin-header"><div><a className="admin-return" href="/admin">Administration</a><h1>Room backups</h1></div>
|
||||||
|
<Button variant="outline" disabled={loading} onClick={() => { setOwner(null); setLoading(true); setRevision(value => value + 1); }}>Check access again</Button></header>
|
||||||
|
<p>Owner-only snapshots preserve a room without changing live play. Backups cannot be restored from this page.</p>
|
||||||
|
<p role="note" className="admin-notice">Privacy: downloaded snapshots contain private game state and may contain access credentials. Keep files secure; do not share them with players or upload them to public storage. This page shows metadata only, never the snapshot contents.</p>
|
||||||
|
{notice && <p role="alert" className="admin-notice">{notice}</p>}
|
||||||
|
{loading ? <output>Checking owner access…</output> : owner ?
|
||||||
|
<AdminBackups key={owner.id} accountId={owner.id} onDenied={() => {
|
||||||
|
setOwner(null); setNotice('Owner access changed or expired. Sign in again before using room backups.');
|
||||||
|
}} /> : <p><a className="admin-room-link" href="/admin">Sign in to administration</a></p>}
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
@@ -124,6 +124,7 @@ export default function Administration() {
|
|||||||
<section aria-labelledby="admin-rooms-title">
|
<section aria-labelledby="admin-rooms-title">
|
||||||
<div className="admin-section-heading"><div><h2 id="admin-rooms-title">Rooms</h2><p className="admin-secondary">Public room details. Cards, sealed choices and private discussion stay hidden.</p></div>
|
<div className="admin-section-heading"><div><h2 id="admin-rooms-title">Rooms</h2><p className="admin-secondary">Public room details. Cards, sealed choices and private discussion stay hidden.</p></div>
|
||||||
<div className="admin-room-actions">{account.role !== 'viewer' && <Button disabled={controlsBusy} onClick={() => { setSelectedDiscussion(null); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setCreatingRoom(true); }}>Create a room</Button>}
|
<div className="admin-room-actions">{account.role !== 'viewer' && <Button disabled={controlsBusy} onClick={() => { setSelectedDiscussion(null); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setCreatingRoom(true); }}>Create a room</Button>}
|
||||||
|
{account.role === 'owner' && <a className="admin-room-link" href="/admin/backups">Room backups</a>}
|
||||||
<Button variant="outline" disabled={controlsBusy} onClick={() => void load(applied, directory?.page ?? 1)}><RefreshCw aria-hidden="true" /> Refresh</Button></div></div>
|
<Button variant="outline" disabled={controlsBusy} onClick={() => void load(applied, directory?.page ?? 1)}><RefreshCw aria-hidden="true" /> Refresh</Button></div></div>
|
||||||
{creatingRoom && account.role !== 'viewer' && <AdminRoomCreation key={account.id} accountId={account.id}
|
{creatingRoom && account.role !== 'viewer' && <AdminRoomCreation key={account.id} accountId={account.id}
|
||||||
onClose={() => setCreatingRoom(false)} onDenied={accessChanged} onBusyChange={setOperationBusy}
|
onClose={() => setCreatingRoom(false)} onDenied={accessChanged} onBusyChange={setOperationBusy}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { env } from 'cloudflare:workers';
|
||||||
|
import { AdminError, requireAdmin } from '@/db/admin-access';
|
||||||
|
import { downloadAdminBackup } from '@/db/admin-backups';
|
||||||
|
import { adminFailure, adminToken } from '@/lib/admin-http';
|
||||||
|
|
||||||
|
export async function GET(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const identity = await requireAdmin(env.DB, adminToken(request), ['owner']);
|
||||||
|
const expected = request.headers.get('X-Dune-Admin-Id');
|
||||||
|
if (expected !== null && expected !== identity.id)
|
||||||
|
throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401);
|
||||||
|
const { id } = await context.params;
|
||||||
|
const { backup, payload } = await downloadAdminBackup(env.DB, identity, id);
|
||||||
|
return new Response(payload, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Content-Disposition': `attachment; filename="dune-room-${backup.roomCode}-backup-${backup.id}.json"`,
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'Vary': 'Cookie',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) { return adminFailure(error); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { env } from 'cloudflare:workers';
|
||||||
|
import { AdminError, requireAdmin } from '@/db/admin-access';
|
||||||
|
import { captureAdminBackup, listAdminBackups, validBackupInput } from '@/db/admin-backups';
|
||||||
|
import { adminBody, adminFailure, adminResponse, adminToken } from '@/lib/admin-http';
|
||||||
|
|
||||||
|
function matchIdentity(request: Request, id: string, mutation: boolean) {
|
||||||
|
const header = request.headers.get('X-Dune-Admin-Id');
|
||||||
|
if ((mutation || header !== null) && header !== id)
|
||||||
|
throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const identity = await requireAdmin(env.DB, adminToken(request), ['owner']);
|
||||||
|
matchIdentity(request, identity.id, false);
|
||||||
|
const search = new URL(request.url).searchParams;
|
||||||
|
if ([...search.keys()].some(key => key !== 'room') || search.getAll('room').length !== 1)
|
||||||
|
throw new AdminError('Enter exactly one room code.', 400);
|
||||||
|
return adminResponse({ backups: await listAdminBackups(env.DB, identity, search.get('room') ?? '') });
|
||||||
|
} catch (error) { return adminFailure(error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const input = await adminBody(request, env.DUNE_PUBLIC_ORIGIN);
|
||||||
|
const identity = await requireAdmin(env.DB, adminToken(request), ['owner']);
|
||||||
|
matchIdentity(request, identity.id, true);
|
||||||
|
if (!validBackupInput(input)) throw new AdminError('Provide a room, current version, operation identifier and operational reason.', 400);
|
||||||
|
return adminResponse(await captureAdminBackup(env.DB, identity, input));
|
||||||
|
} catch (error) { return adminFailure(error); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { ClientRequestError, requestJson, requestMayHaveCompleted } from '@/lib/client-request';
|
||||||
|
import { randomId } from '@/lib/random-id';
|
||||||
|
|
||||||
|
type Backup = { id: string; roomCode: string; roomVersion: number; createdAt: number; sizeBytes: number; digest: string };
|
||||||
|
type Pending = { roomCode: string; operationId: string; expectedVersion: number; reason: string };
|
||||||
|
type Room = { code: string; version: number };
|
||||||
|
const codePattern = /^[A-Z2-9]{8}$/;
|
||||||
|
const idPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||||
|
const message = (error: unknown) => error instanceof Error ? error.message : 'The backup request could not be completed.';
|
||||||
|
const storageKey = (accountId: string, code: string) => `dune.admin-backup-request.v1:${accountId}:${code}`;
|
||||||
|
|
||||||
|
function parsePending(raw: string | null, code: string): Pending | null {
|
||||||
|
if (raw === null) return null;
|
||||||
|
let value: unknown;
|
||||||
|
try { value = raw.length <= 1024 ? JSON.parse(raw) : null; } catch { value = null; }
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('This tab’s saved backup request is unreadable. Resolve the local record before creating another backup.');
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (Object.keys(record).length !== 4 || record.roomCode !== code || typeof record.operationId !== 'string' || !idPattern.test(record.operationId) ||
|
||||||
|
!Number.isSafeInteger(record.expectedVersion) || Number(record.expectedVersion) < 0 || typeof record.reason !== 'string' ||
|
||||||
|
record.reason.length > 300 || !record.reason.trim() || /\p{Cc}/u.test(record.reason))
|
||||||
|
throw new Error('This tab’s saved backup request is unreadable. Resolve the local record before creating another backup.');
|
||||||
|
return record as Pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBackups(value: unknown, code: string): Backup[] {
|
||||||
|
if (!value || typeof value !== 'object' || !('backups' in value) || !Array.isArray(value.backups)) throw new Error('The server returned an unreadable backup list.');
|
||||||
|
const backups: unknown[] = value.backups;
|
||||||
|
if (!backups.every(item => !!item && typeof item === 'object' &&
|
||||||
|
'id' in item && typeof item.id === 'string' && idPattern.test(item.id) &&
|
||||||
|
'roomCode' in item && item.roomCode === code &&
|
||||||
|
'roomVersion' in item && Number.isSafeInteger(item.roomVersion) && Number(item.roomVersion) >= 0 &&
|
||||||
|
'createdAt' in item && Number.isSafeInteger(item.createdAt) && Number(item.createdAt) >= 0 && Number(item.createdAt) <= 8_640_000_000_000_000 &&
|
||||||
|
'sizeBytes' in item && Number.isSafeInteger(item.sizeBytes) && Number(item.sizeBytes) >= 0 &&
|
||||||
|
'digest' in item && typeof item.digest === 'string' && /^[a-f0-9]{64}$/.test(item.digest)))
|
||||||
|
throw new Error('The server returned unreadable backup metadata.');
|
||||||
|
return backups as Backup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminBackups({ accountId, onDenied }: { accountId: string; onDenied: () => void }) {
|
||||||
|
const [entry, setEntry] = useState('');
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [room, setRoom] = useState<Room | null>(null);
|
||||||
|
const [backups, setBackups] = useState<Backup[] | null>(null);
|
||||||
|
const [pending, setPending] = useState<Pending | null>(null);
|
||||||
|
const [storageProblem, setStorageProblem] = useState(false);
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
const [confirmed, setConfirmed] = useState(false);
|
||||||
|
const [notice, setNotice] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const inFlight = useRef(false);
|
||||||
|
const generation = useRef(0);
|
||||||
|
const alive = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
const mounted = alive;
|
||||||
|
const currentGeneration = generation;
|
||||||
|
mounted.current = true;
|
||||||
|
return () => { mounted.current = false; currentGeneration.current++; };
|
||||||
|
}, []);
|
||||||
|
const headers = { 'X-Dune-Admin-Id': accountId };
|
||||||
|
|
||||||
|
async function readRoomAndList(selected: string): Promise<{ view: Room | null; list: Backup[] }> {
|
||||||
|
const [view, response] = await Promise.all([
|
||||||
|
requestJson<Room>(`/api/admin/rooms/${selected}/removal`, { cache: 'no-store', headers })
|
||||||
|
.catch(error => {
|
||||||
|
if (error instanceof ClientRequestError && error.status === 404) return null;
|
||||||
|
throw error;
|
||||||
|
}),
|
||||||
|
requestJson<unknown>(`/api/admin/backups?room=${selected}`, { cache: 'no-store', headers }),
|
||||||
|
]);
|
||||||
|
if (view && (view.code !== selected || !Number.isSafeInteger(view.version) || view.version < 0))
|
||||||
|
throw new Error('The server returned an unreadable room version.');
|
||||||
|
return { view, list: readBackups(response, selected) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcile(saved: Pending | null, list: Backup[]) {
|
||||||
|
if (!saved) return false;
|
||||||
|
const found = list.find(item => item.id === saved.operationId);
|
||||||
|
if (!found) return false;
|
||||||
|
if (found.roomVersion !== saved.expectedVersion) throw new Error('The saved operation has conflicting backup metadata. Do not retry it.');
|
||||||
|
window.sessionStorage.removeItem(storageKey(accountId, saved.roomCode));
|
||||||
|
setPending(null);
|
||||||
|
setNotice('The saved operation is present in the backup list. No second snapshot was requested.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(selected: string) {
|
||||||
|
if (inFlight.current) return;
|
||||||
|
inFlight.current = true; setLoading(true); setNotice('');
|
||||||
|
const epoch = ++generation.current;
|
||||||
|
setCode(selected); setRoom(null); setBackups(null); setPending(null); setStorageProblem(false); setConfirmed(false);
|
||||||
|
try {
|
||||||
|
let saved: Pending | null = null;
|
||||||
|
let savedError: unknown = null;
|
||||||
|
try { saved = parsePending(window.sessionStorage.getItem(storageKey(accountId, selected)), selected); }
|
||||||
|
catch (error) { savedError = error; setStorageProblem(true); }
|
||||||
|
const { view, list } = await readRoomAndList(selected);
|
||||||
|
if (epoch !== generation.current || !alive.current) return;
|
||||||
|
setRoom(view); setBackups(list); setPending(saved);
|
||||||
|
if (savedError) setNotice(message(savedError));
|
||||||
|
else if (saved && !reconcile(saved, list)) setNotice('The saved operation is not in the current list. Review the available room and list before manually retrying the exact request.');
|
||||||
|
} catch (error) {
|
||||||
|
if (epoch !== generation.current || !alive.current) return;
|
||||||
|
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||||
|
else {
|
||||||
|
setNotice(message(error));
|
||||||
|
}
|
||||||
|
} finally { inFlight.current = false; if (epoch === generation.current && alive.current) setLoading(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
if (inFlight.current || !code) return;
|
||||||
|
inFlight.current = true; setBusy(true); setNotice('');
|
||||||
|
try {
|
||||||
|
const { view, list } = await readRoomAndList(code);
|
||||||
|
if (alive.current) {
|
||||||
|
setRoom(view); setBackups(list); setConfirmed(false);
|
||||||
|
if (!reconcile(pending, list)) setNotice(pending ? 'The saved operation is not in the current list. Review it before a manual retry.' :
|
||||||
|
view ? 'Current room version and backup list refreshed.' : 'Backup list refreshed. The live room is no longer available.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (alive.current) {
|
||||||
|
setRoom(null); setBackups(null);
|
||||||
|
setNotice(message(error));
|
||||||
|
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied();
|
||||||
|
}
|
||||||
|
} finally { inFlight.current = false; if (alive.current) setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(saved?: Pending) {
|
||||||
|
if (inFlight.current || !backups || storageProblem || (!saved && (!room || pending || !confirmed || !reason.trim())) ||
|
||||||
|
(saved && (!pending || saved.operationId !== pending.operationId || !confirmed))) return;
|
||||||
|
inFlight.current = true; setBusy(true); setConfirmed(false);
|
||||||
|
let input: Pending | null = null;
|
||||||
|
let sent = false;
|
||||||
|
try {
|
||||||
|
// A fresh authoritative read precedes every POST. A changed version needs new explicit review.
|
||||||
|
const latest = await readRoomAndList(code);
|
||||||
|
if (!alive.current) return;
|
||||||
|
setRoom(latest.view); setBackups(latest.list);
|
||||||
|
if (saved && reconcile(saved, latest.list)) return;
|
||||||
|
if (!saved && (!latest.view || latest.view.version !== room?.version)) {
|
||||||
|
setNotice(latest.view ? 'The live room advanced since your review. Review the new version and confirm again; no snapshot was requested.' :
|
||||||
|
'The live room is no longer available. Its existing snapshots can still be downloaded; no snapshot was requested.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input = saved ?? { roomCode: code, operationId: randomId(), expectedVersion: latest.view!.version, reason: reason.trim() };
|
||||||
|
if (input.roomCode !== code || input.reason.length > 300 || /\p{Cc}/u.test(input.reason)) {
|
||||||
|
setNotice('Enter a one-line reason of 1–300 characters.'); return;
|
||||||
|
}
|
||||||
|
const key = storageKey(accountId, code);
|
||||||
|
const existing = parsePending(window.sessionStorage.getItem(key), code);
|
||||||
|
if (existing && JSON.stringify(existing) !== JSON.stringify(input)) throw new Error('Resolve the saved backup request before starting another.');
|
||||||
|
window.sessionStorage.setItem(key, JSON.stringify(input));
|
||||||
|
if (window.sessionStorage.getItem(key) !== JSON.stringify(input)) throw new Error('The backup request could not be preserved in this tab. Nothing was sent.');
|
||||||
|
setPending(input); setNotice('Requesting an immutable snapshot…');
|
||||||
|
sent = true;
|
||||||
|
const result = await requestJson<unknown>('/api/admin/backups', {
|
||||||
|
method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!result || typeof result !== 'object' || !('replayed' in result) || typeof result.replayed !== 'boolean' || !('backup' in result))
|
||||||
|
throw new Error('The server did not confirm the backup operation.');
|
||||||
|
const receipt = readBackups({ backups: [result.backup] }, code)[0];
|
||||||
|
if (receipt.id !== input.operationId || receipt.roomVersion !== input.expectedVersion)
|
||||||
|
throw new Error('The server returned conflicting backup confirmation.');
|
||||||
|
// The authenticated exact-operation receipt is authoritative even when this
|
||||||
|
// backup has fallen outside the newest 100 metadata rows.
|
||||||
|
let storageCleared = true;
|
||||||
|
try { window.sessionStorage.removeItem(storageKey(accountId, code)); }
|
||||||
|
catch {
|
||||||
|
storageCleared = false;
|
||||||
|
if (alive.current) setStorageProblem(true);
|
||||||
|
}
|
||||||
|
if (alive.current) {
|
||||||
|
setPending(null); setReason('');
|
||||||
|
const storageWarning = storageCleared ? '' : ' This tab could not clear its saved retry record; do not retry this confirmed operation.';
|
||||||
|
setNotice((result.replayed ? 'The existing snapshot was confirmed; no second snapshot was created.' : 'Snapshot created. The live room was not changed.') + storageWarning);
|
||||||
|
try {
|
||||||
|
const after = await readRoomAndList(code);
|
||||||
|
if (alive.current) {
|
||||||
|
setRoom(after.view); setBackups(after.list);
|
||||||
|
if (!after.list.some(item => item.id === receipt.id))
|
||||||
|
setNotice('The snapshot was confirmed by its operation receipt, but is not among the currently listed snapshots. No retry is needed.' + storageWarning);
|
||||||
|
}
|
||||||
|
} catch (readError) {
|
||||||
|
if (!alive.current) return;
|
||||||
|
setRoom(null); setBackups(null);
|
||||||
|
setNotice('The snapshot was confirmed, but the current list could not be refreshed. Use Refresh room and backups; do not retry capture.' + storageWarning);
|
||||||
|
if (readError instanceof ClientRequestError && [401, 403].includes(readError.status ?? 0)) onDenied();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!alive.current) return;
|
||||||
|
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) { onDenied(); return; }
|
||||||
|
if (!sent || !input) {
|
||||||
|
setNotice(`${message(error)} No snapshot request was sent.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uncertain = requestMayHaveCompleted(error);
|
||||||
|
if (!uncertain) {
|
||||||
|
try { window.sessionStorage.removeItem(storageKey(accountId, code)); setPending(null); }
|
||||||
|
catch { setStorageProblem(true); }
|
||||||
|
}
|
||||||
|
setNotice(uncertain ? `${message(error)} The request may have completed. Checking the current room and backup list before any retry…` : message(error));
|
||||||
|
try {
|
||||||
|
const after = await readRoomAndList(code);
|
||||||
|
if (alive.current) {
|
||||||
|
setRoom(after.view); setBackups(after.list);
|
||||||
|
if (uncertain && !reconcile(input, after.list)) setNotice(`${message(error)} No matching snapshot appears in the current list. Review the saved request before manually retrying it; no automatic retry was sent.`);
|
||||||
|
}
|
||||||
|
} catch (readError) {
|
||||||
|
if (alive.current) {
|
||||||
|
setRoom(null); setBackups(null);
|
||||||
|
setNotice(`${message(error)} The outcome could not be checked. Refresh the current room and backup list before retrying.`);
|
||||||
|
if (readError instanceof ClientRequestError && [401, 403].includes(readError.status ?? 0)) onDenied();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally { inFlight.current = false; if (alive.current) setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function download(backup: Backup) {
|
||||||
|
if (inFlight.current) return;
|
||||||
|
inFlight.current = true; setBusy(true); setNotice('');
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 60_000);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/backups/${encodeURIComponent(backup.id)}/download`, {
|
||||||
|
method: 'GET', cache: 'no-store', credentials: 'same-origin', headers, signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
if ([401, 403].includes(response.status)) { onDenied(); return; }
|
||||||
|
throw new Error(`The snapshot could not be downloaded (${response.status}).`);
|
||||||
|
}
|
||||||
|
if (!response.headers.get('content-type')?.toLowerCase().includes('application/json')) throw new Error('The server returned an unexpected download type.');
|
||||||
|
const blob = await response.blob();
|
||||||
|
if (!alive.current) return;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
try {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `dune-room-${backup.roomCode}-backup-${backup.id}.json`;
|
||||||
|
link.style.display = 'none';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
setNotice('Snapshot download started. Store the private file securely.');
|
||||||
|
} finally { setTimeout(() => URL.revokeObjectURL(url), 60_000); }
|
||||||
|
} catch (error) { if (alive.current) setNotice(error instanceof DOMException && error.name === 'AbortError' ? 'Download timed out. Try again.' : message(error)); }
|
||||||
|
finally { clearTimeout(timeout); inFlight.current = false; if (alive.current) setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return <section aria-labelledby="admin-backups-title" aria-busy={busy || loading}>
|
||||||
|
<h2 id="admin-backups-title">Find a room</h2>
|
||||||
|
<form className="admin-filters" onSubmit={event => { event.preventDefault(); if (codePattern.test(entry.trim().toUpperCase())) void load(entry.trim().toUpperCase()); else setNotice('Enter the eight-character room code using A–Z and 2–9.'); }}>
|
||||||
|
<label className="admin-search" htmlFor="backup-room-code">Room code<Input id="backup-room-code" value={entry} maxLength={8} autoComplete="off" autoCapitalize="characters" spellCheck={false} disabled={busy || loading} onChange={event => setEntry(event.target.value.toUpperCase())} required /></label>
|
||||||
|
<Button type="submit" disabled={busy || loading || !codePattern.test(entry.trim())}>Show room backups</Button>
|
||||||
|
</form>
|
||||||
|
{notice && <p role="alert" className="admin-notice">{notice}</p>}
|
||||||
|
{loading && <output>Reading current room version and snapshots…</output>}
|
||||||
|
{code && !loading && <section className="admin-room-control" aria-labelledby="backup-current-room">
|
||||||
|
<div className="admin-section-heading"><h2 id="backup-current-room">Room {code}</h2><Button variant="outline" disabled={busy} onClick={() => void refresh()}>Refresh room and backups</Button></div>
|
||||||
|
{backups ? <>
|
||||||
|
{room ? <p>Current live room version: <strong>{room.version}</strong>. Snapshot creation reads the saved room; it does not pause, modify, or restore live play.</p> :
|
||||||
|
<p>The live room no longer exists. Existing snapshots remain available to download, but a new snapshot cannot be created.</p>}
|
||||||
|
<h3>Saved snapshots ({backups.length})</h3>
|
||||||
|
{backups.length === 0 ? <p>No snapshots recorded for this room.</p> : <ol>{backups.map(backup => <li key={backup.id}>
|
||||||
|
<article><p>Version {backup.roomVersion} · <time dateTime={new Date(backup.createdAt).toISOString()}>{new Date(backup.createdAt).toLocaleString()}</time> · {backup.sizeBytes.toLocaleString()} bytes</p>
|
||||||
|
<p>Snapshot ID: <code>{backup.id}</code><br />SHA-256: <code>{backup.digest}</code></p>
|
||||||
|
<Button variant="outline" disabled={busy} onClick={() => void download(backup)}>Download snapshot for room {code}, version {backup.roomVersion}</Button>
|
||||||
|
</article>
|
||||||
|
</li>)}</ol>}
|
||||||
|
{storageProblem ? <div className="admin-confirm"><p>A saved request cannot be read or preserved in this tab. No new snapshot can be requested until the local record is resolved. This does not remove any server snapshot.</p>
|
||||||
|
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy} onChange={event => setConfirmed(event.target.checked)} />I reviewed the current backup list and want to clear this tab’s unreadable retry record.</label>
|
||||||
|
<Button variant="outline" disabled={busy || !confirmed} onClick={() => {
|
||||||
|
try {
|
||||||
|
window.sessionStorage.removeItem(storageKey(accountId, code));
|
||||||
|
if (window.sessionStorage.getItem(storageKey(accountId, code)) !== null) throw new Error('The local record could not be cleared.');
|
||||||
|
setStorageProblem(false); setPending(null); setConfirmed(false); setRoom(null); setBackups(null);
|
||||||
|
setNotice('Local retry record cleared. This did not change any server snapshot. Refresh the room and list before creating a new one.');
|
||||||
|
} catch (error) { setNotice(message(error)); }
|
||||||
|
}}>Clear unreadable local record</Button></div> : pending ? <div className="admin-confirm">
|
||||||
|
<h3>Saved request needs review</h3><p>Room {pending.roomCode}, version {pending.expectedVersion}; reason: {pending.reason}</p>
|
||||||
|
<p>This exact request is preserved in this tab. It is not in the current list; a confirmed older snapshot may also be outside the list window. Retry uses the same operation ID and cannot create a second snapshot for this operation. The live room may have changed or no longer exist.</p>
|
||||||
|
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy} onChange={event => setConfirmed(event.target.checked)} />I reviewed the available room, list and saved request before retrying.</label>
|
||||||
|
<Button disabled={busy || !confirmed} onClick={() => void create(pending)}>Retry saved request</Button>
|
||||||
|
</div> : room ? <form onSubmit={event => { event.preventDefault(); void create(); }}>
|
||||||
|
<label htmlFor="backup-reason">Operational reason<Input id="backup-reason" value={reason} maxLength={300} required disabled={busy} onChange={event => { setReason(event.target.value); setConfirmed(false); }} /></label>
|
||||||
|
<p className="admin-secondary">Use a short one-line reason. Do not include credentials or private game details.</p>
|
||||||
|
<div className="admin-confirm"><p>Create one immutable snapshot of <strong>room {code}</strong> at live version <strong>{room.version}</strong>. This captures private game state and access material, but does not change the game. If the version changes, refresh and review again.</p>
|
||||||
|
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy} onChange={event => setConfirmed(event.target.checked)} />I reviewed the room, version, privacy warning and reason, and want to create this snapshot.</label>
|
||||||
|
<Button type="submit" disabled={busy || !confirmed || !reason.trim() || /\p{Cc}/u.test(reason)}>Create snapshot</Button>
|
||||||
|
</div>
|
||||||
|
</form> : null}
|
||||||
|
</> : <p>The current room or backup list is unavailable. Refresh before creating or retrying any snapshot.</p>}
|
||||||
|
</section>}
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -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)],
|
(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)],
|
||||||
|
);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Private room backups
|
||||||
|
|
||||||
|
The owner-only backup page at `/admin/backups` captures, lists and downloads immutable snapshots of an existing room. This is a **capture-only** capability: importing, validating, restoring or resuming from a backup is not implemented. A downloaded file must not be treated as a safe restore input or as a replacement for protected database/volume backups. Room play, seats, revocations and game versions remain unchanged during capture.
|
||||||
|
|
||||||
|
## Access and operation
|
||||||
|
|
||||||
|
Sign in as a current owner and select an exact eight-character room code. The UI reads the live room version separately, then submits `POST /api/admin/backups` with exactly `{roomCode, operationId, expectedVersion, reason}`. The operation ID is a fresh lowercase UUID; the reason is a single-line operational explanation (1–300 characters). Capture is fenced to the live room version **and** an exact re-read of all room-owned data within the insertion transaction. Concurrent game or seat changes, including tables that do not bump the game version, reject the capture. No partial snapshot is saved. A retry of the same operation ID, owner, room, version and reason returns the original immutable receipt, even after later room changes. A different request/owner using that ID is rejected. An identical room/version/payload snapshot cannot be stored twice under different IDs.
|
||||||
|
|
||||||
|
Only successful captures appear in the dedicated backup ledger. Its receipt retains owner ID, room code, reason, timestamp, room version, digest and size; it survives room/account deletion. Each successful download separately adds an append-only receipt with export ID, backup ID, owner ID, room code and time, committed before private bytes leave the database. Failed attempts are not recorded. Logs and public metadata never include the private payload or credential hashes.
|
||||||
|
|
||||||
|
The append-only download-audit ledger has a global **32,768-record prototype
|
||||||
|
cap**. The limit is checked in the same transaction as each audit insert.
|
||||||
|
At capacity, further downloads fail closed with HTTP 507; existing backups
|
||||||
|
and rooms are unchanged, and no unlogged private export is permitted.
|
||||||
|
There is no automatic pruning. Operators must plan a protected database
|
||||||
|
capacity/retention migration with preserved audit provenance before export
|
||||||
|
availability can resume; deleting audit records merely to bypass this limit
|
||||||
|
would defeat the security boundary.
|
||||||
|
|
||||||
|
The dedicated backup receipts are not yet projected in the separate in-progress
|
||||||
|
unified administrator action-history page. Owner-only metadata lists also omit
|
||||||
|
actor and reason. Operators/viewers therefore cannot inspect backup events in
|
||||||
|
that page yet; the durable receipt tables retain capture and export provenance.
|
||||||
|
|
||||||
|
`GET /api/admin/backups?room=CODE` returns at most 100 newest metadata entries `{id,roomCode,roomVersion,createdAt,sizeBytes,digest}`, never game state, reason or credential hashes. `GET /api/admin/backups/[id]/download` returns the full JSON as an attachment. Both reads repeat live owner authorization in the database query; disabled, demoted, expired or revoked sessions lose access immediately. Neither a participant seat cookie nor an operator/viewer session grants backup access. Routes send `Cache-Control: no-store`, `Vary: Cookie` and `X-Content-Type-Options: nosniff`. Mutating requests additionally require the configured exact origin and matching administrator identity header.
|
||||||
|
|
||||||
|
## Format and privacy
|
||||||
|
|
||||||
|
The downloaded UTF-8 JSON has `format: 1`, `room: {code,state,version,updated_at}`, and `tables` keyed by their SQLite table names. `room.state` is the exact serialized game-state **string** as stored, not a sanitized player view. Each table contains an array of rows with original snake-case column names and values, including revoked seat records. Captured tables: `seats`, `room_controls`, `room_removals`, `room_closures`, `room_archives`, `seat_recovery_keys`, `seat_recovery_receipts`, `seat_handover_offers`, `seat_handover_claim_receipts`, `seat_ai_delegations`, `room_entry_receipts`, `seat_discussion_controls`, `room_messages`. Snapshot data is taken from one room only. Site administrator keys/sessions, unrelated rooms and admin action receipts are excluded. SHA-256 `digest` covers the exact UTF-8 payload bytes and `sizeBytes` is their byte count.
|
||||||
|
|
||||||
|
**Treat downloaded JSON as highly sensitive.** It can contain secret cards and plans, private discussion, participant names, seat token hashes, recovery/handover hashes and revoked credentials. Store/download only to a restricted location; never paste it into tickets, logs, shared browser storage or public test reports. The database backup payload is private; the list response is deliberately metadata only. There is no automatic expiration or deletion policy in this capture slice.
|
||||||
|
|
||||||
|
Capture refuses a serialized payload over **1,800,000 UTF-8 bytes** with HTTP 413; the estimate is conservative and evaluated before constructing a D1 cell against its 2,000,000-byte string/row limit. No backup is silently truncated. This **prototype limit** means a long-lived room with extensive discussion may become uncapturable: larger rooms require a future chunked snapshot format with atomic manifest, bounded chunk reads and validated assembly. Such rooms are **not backed up** by this feature; use a protected database/volume backup instead. The ledger additionally limits each room to 32 snapshots and 16 MiB total, and all rooms to 64 MiB total (HTTP 507 when full). There is no automatic expiry or pruning; quota management/deletion is not implemented. List queries return only the newest 100 receipts per room; older receipts remain downloadable by exact ID. Future restore/import work must independently validate game schema, custody, ownership and credential revocation before any write, preserve a current recovery checkpoint, and must not silently reinstate a revoked seat. None of those restoration behaviors exists here.
|
||||||
|
|
||||||
|
The row limit follows the [Cloudflare D1 storage limits](https://developers.cloudflare.com/d1/platform/limits/).
|
||||||
+6
-1
@@ -14,7 +14,7 @@ recovery and persistence systems where suitable.
|
|||||||
| Create and configure | [Create a lobby](ADMIN_ROOM_CREATION.md) with an explicitly owned new host seat, Basic/Advanced preview and initial AI configuration; invite humans and use ordinary lobby controls/voluntary host-seat handover. [Neutral lobby configuration](ADMIN_LOBBY_CONFIGURATION.md) adds rules/modules, AI seats and host assignment to an existing human without private access. Reserved human seats remain. | Prototyped, partial |
|
| Create and configure | [Create a lobby](ADMIN_ROOM_CREATION.md) with an explicitly owned new host seat, Basic/Advanced preview and initial AI configuration; invite humans and use ordinary lobby controls/voluntary host-seat handover. [Neutral lobby configuration](ADMIN_LOBBY_CONFIGURATION.md) adds rules/modules, AI seats and host assignment to an existing human without private access. Reserved human seats remain. | Prototyped, partial |
|
||||||
| Lifecycle and removal | [Pause/resume and joining locks](ADMIN_ROOM_CONTROLS.md), [administrative close/reopen](ADMIN_ROOM_CLOSURE.md) and [recoverable removal/restoration](ADMIN_ROOM_REMOVAL.md) have controls, durable audit, exact retries and player/AI enforcement. [Archive/unarchive](ADMIN_ROOM_ARCHIVE.md) has separate directory filters and exact retries. Permanent deletion and bulk actions remain missing. | Prototyped, partial |
|
| Lifecycle and removal | [Pause/resume and joining locks](ADMIN_ROOM_CONTROLS.md), [administrative close/reopen](ADMIN_ROOM_CLOSURE.md) and [recoverable removal/restoration](ADMIN_ROOM_REMOVAL.md) have controls, durable audit, exact retries and player/AI enforcement. [Archive/unarchive](ADMIN_ROOM_ARCHIVE.md) has separate directory filters and exact retries. Permanent deletion and bulk actions remain missing. | Prototyped, partial |
|
||||||
| Participant support | [Participant AI](ADMIN_PARTICIPANT_AI.md) enables an existing difficulty on an eligible human seat in a paused game, retaining access/takeback and private custody. [Discussion controls](ADMIN_DISCUSSION.md) mute/unmute new sends while retaining history and gameplay. Participant removal, access revocation, assisted recovery and further replacement remain. Existing lobby controls can reassign the host. | Prototyped, partial |
|
| Participant support | [Participant AI](ADMIN_PARTICIPANT_AI.md) enables an existing difficulty on an eligible human seat in a paused game, retaining access/takeback and private custody. [Discussion controls](ADMIN_DISCUSSION.md) mute/unmute new sends while retaining history and gameplay. Participant removal, access revocation, assisted recovery and further replacement remain. Existing lobby controls can reassign the host. | Prototyped, partial |
|
||||||
| Saved-game operations | Create/list/download backups, validate imports, restore a selected checkpoint safely, and diagnose or resume interrupted automatic work through authoritative game actions. | Missing |
|
| Saved-game operations | [Owner-only room backups](ADMIN_BACKUPS.md) capture/list/download a size-limited, version-fenced room snapshot without changing play or access; import validation, safe checkpoint restore and interrupted-work diagnosis/resume remain missing. | Prototyped, partial |
|
||||||
| Operations and audit | Show server/build/storage health, room/player counts and actionable errors; provide maintenance controls and a searchable record of administrator actions. | Missing |
|
| Operations and audit | Show server/build/storage health, room/player counts and actionable errors; provide maintenance controls and a searchable record of administrator actions. | Missing |
|
||||||
|
|
||||||
Track each area through Missing, Prototyped, Integrated, Verified and Polished.
|
Track each area through Missing, Prototyped, Integrated, Verified and Polished.
|
||||||
@@ -59,6 +59,11 @@ the administrator receives no private seat. Resume remains a separate action.
|
|||||||
[Discussion controls](ADMIN_DISCUSSION.md) restrict new messages from a human seat,
|
[Discussion controls](ADMIN_DISCUSSION.md) restrict new messages from a human seat,
|
||||||
including after recovery/handover, without exposing private history.
|
including after recovery/handover, without exposing private history.
|
||||||
|
|
||||||
|
[Private room backups](ADMIN_BACKUPS.md) let an owner capture the current saved
|
||||||
|
state and related room-owned records without pausing or editing the room. The
|
||||||
|
metadata directory omits private contents; downloaded JSON contains secret
|
||||||
|
game state and hashed seat/recovery credentials. Import/restore are not available.
|
||||||
|
|
||||||
The directory uses an explicit field allowlist, independent of any player view.
|
The directory uses an explicit field allowlist, independent of any player view.
|
||||||
It never returns hands, Traitors, predictions, plans, private messages, recovery
|
It never returns hands, Traitors, predictions, plans, private messages, recovery
|
||||||
records or raw saved state. Searches inspect only codes and public player names.
|
records or raw saved state. Searches inspect only codes and public player names.
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ enter TrueNAS Apps because the host desktop was OS-locked; it made no NAS change
|
|||||||
backup or post-rollout claim. Production remains at the last verified `c9a3625`
|
backup or post-rollout claim. Production remains at the last verified `c9a3625`
|
||||||
image until the Apps session is unlocked and this exact image is deployed.
|
image until the Apps session is unlocked and this exact image is deployed.
|
||||||
|
|
||||||
|
An owner-only [room-backup prototype](ADMIN_BACKUPS.md) now captures, lists and
|
||||||
|
downloads immutable private snapshots with live authorization, unchanged room
|
||||||
|
versions and append-only capture/export receipts. The per-room size/storage
|
||||||
|
limits, missing import/restore and absent unified action-history entries keep
|
||||||
|
the saved-game operations gate open. Local isolated capture/export and a
|
||||||
|
signed-out browser denial passed; this is not deployed evidence.
|
||||||
|
|
||||||
The bounded [Fremen Nexus Cunning](NEXUS_FREMEN_RULES.md#connected-native-cunning-path)
|
The bounded [Fremen Nexus Cunning](NEXUS_FREMEN_RULES.md#connected-native-cunning-path)
|
||||||
prototype connects an initially empty worm's public offer, one spent private
|
prototype connects an initially empty worm's public offer, one spent private
|
||||||
card, a Karama response, a typed remote ride after the Nexus, human controls
|
card, a Karama response, a typed remote ride after the Nexus, human controls
|
||||||
|
|||||||
@@ -1,5 +1,43 @@
|
|||||||
# Dune implementation status
|
# Dune implementation status
|
||||||
|
|
||||||
|
## 28 September 2026 — owner-only room backup capture
|
||||||
|
|
||||||
|
The new `/admin/backups` page lets an authenticated owner capture, list and
|
||||||
|
download an immutable private room snapshot. It includes the saved game and
|
||||||
|
related seat/recovery, lifecycle and discussion records without changing room
|
||||||
|
version or granting access. Exact operation replays, live database authorization,
|
||||||
|
content/version fencing and byte quotas protect competing writes and storage.
|
||||||
|
An append-only owner/room/time receipt records each successful private export
|
||||||
|
before its payload is returned. These are capture-only files: there is no import,
|
||||||
|
validated restore, server health display or unified action-history entry yet.
|
||||||
|
|
||||||
|
Twelve isolated in-memory D1 backup cases pass, including exact replay, revoked
|
||||||
|
access, unversioned concurrent changes, highly escaped state rejection, the
|
||||||
|
32,768-record download-audit capacity boundary and duplicate snapshots. A throwaway genuine game created in an isolated store was
|
||||||
|
captured, listed and downloaded once; its room version remained zero and its
|
||||||
|
export receipt count was one. The signed-out local browser showed the owner
|
||||||
|
gate. A separate intercepted local UI view showed an older snapshot available
|
||||||
|
to download after its live room had disappeared; that view used mocked API
|
||||||
|
responses, not a real room restoration. The HTTP suite passed 53/53, including
|
||||||
|
a new anonymous, forged-admin and real room-host backup denial case; the build
|
||||||
|
included both new backup routes and the page. The stable full offline run
|
||||||
|
passed 5,662/5,662 cases with zero failures.
|
||||||
|
`npm run check` remains blocked by the unrelated concurrent Fremen Nexus draft's
|
||||||
|
missing `Player.fremenNexusMovementBlockedTurn` type field. The 1,800,000-byte
|
||||||
|
per-snapshot cap, 16 MiB per-room and 64 MiB global quotas, bounded export
|
||||||
|
ledger without archival, absent chunked backups, and unavailable live NAS Apps
|
||||||
|
session keep saved-game and deployed acceptance open. No production game or credential was modified.
|
||||||
|
|
||||||
|
## 28 September 2026 — rules-reference duplicate links
|
||||||
|
|
||||||
|
The Ecaz faction topic no longer repeats its Duke Vidal related link, and the
|
||||||
|
Homeworld implementation checklist lists each Tupile intelligence and Terror
|
||||||
|
entry receipt source once. The local `/rules?topic=faction-ecaz#faction-ecaz`
|
||||||
|
page rendered its opened Ecaz guide and one Duke link after the change; its
|
||||||
|
fresh browser console had no React duplicate-key errors. Before the change,
|
||||||
|
the same page reported duplicate keys for all three entries. This is a
|
||||||
|
reference-display correction, not new game-rule acceptance.
|
||||||
|
|
||||||
## 28 September 2026 — source-stable complete-game integration samples
|
## 28 September 2026 — source-stable complete-game integration samples
|
||||||
|
|
||||||
`tools/faction-games.ts` completed five genuine Basic Tleilaxu/Leader Skills
|
`tools/faction-games.ts` completed five genuine Basic Tleilaxu/Leader Skills
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE `admin_room_backups` (
|
||||||
|
`operation_id` text PRIMARY KEY NOT NULL,
|
||||||
|
`actor_admin_id` text NOT NULL,
|
||||||
|
`room_code` text NOT NULL,
|
||||||
|
`request_hash` text NOT NULL,
|
||||||
|
`room_version` integer NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`size_bytes` integer NOT NULL,
|
||||||
|
`digest` text NOT NULL,
|
||||||
|
`payload` text NOT NULL,
|
||||||
|
`reason` text NOT NULL,
|
||||||
|
CONSTRAINT `admin_room_backups_size` CHECK (`size_bytes` BETWEEN 1 AND 1800000 AND `size_bytes` = length(CAST(`payload` AS BLOB)))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `admin_room_backups_room` ON `admin_room_backups` (`room_code`,`created_at`);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE `admin_room_backup_downloads` (
|
||||||
|
`download_id` text PRIMARY KEY NOT NULL,
|
||||||
|
`operation_id` text NOT NULL,
|
||||||
|
`actor_admin_id` text NOT NULL,
|
||||||
|
`room_code` text NOT NULL,
|
||||||
|
`created_at` integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `admin_room_backup_downloads_room` ON `admin_room_backup_downloads` (`room_code`,`created_at`);
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -120,6 +120,20 @@
|
|||||||
"when": 1790395696065,
|
"when": 1790395696065,
|
||||||
"tag": "0016_seat_discussion_controls",
|
"tag": "0016_seat_discussion_controls",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1790640000000,
|
||||||
|
"tag": "0017_admin_room_backups",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1790640001000,
|
||||||
|
"tag": "0018_admin_backup_downloads",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -2744,7 +2744,6 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
'duke-vidal',
|
'duke-vidal',
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(f.id === 'ecaz' ? ['duke-vidal'] : []),
|
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -3722,11 +3721,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
'game/grumman-collection.ts',
|
'game/grumman-collection.ts',
|
||||||
'game/grumman-collection-return.ts',
|
'game/grumman-collection-return.ts',
|
||||||
'game/grumman-collection-options.ts',
|
'game/grumman-collection-options.ts',
|
||||||
'game/terror-entry-receipt.ts',
|
|
||||||
'components/grumman-collection.tsx',
|
'components/grumman-collection.tsx',
|
||||||
'components/terror-board-markers.tsx',
|
'components/terror-board-markers.tsx',
|
||||||
'game/homeworld-arrival.ts',
|
'game/homeworld-arrival.ts',
|
||||||
'game/tupile-intelligence.ts',
|
|
||||||
'game/homeworld-shipment.ts',
|
'game/homeworld-shipment.ts',
|
||||||
'game/guild-homeworld-shipment.ts',
|
'game/guild-homeworld-shipment.ts',
|
||||||
'game/junction-transport.ts',
|
'game/junction-transport.ts',
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// @dune-suite integration
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
|
||||||
|
const roomCode = 'BACKUPAA';
|
||||||
|
const backupId = '00000000-0000-4000-8000-000000000001';
|
||||||
|
|
||||||
|
void test('room-host and forged administrator credentials cannot list, create or export private backups', async () => {
|
||||||
|
const created = await fetch(base + '/api/rooms', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json', origin: base },
|
||||||
|
body: JSON.stringify({ name: 'Backup boundary QA', faction: 'atreides', advanced: false, expansions: [] }),
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
assert.equal(created.status, 201);
|
||||||
|
const seatCookie = created.headers.get('set-cookie')?.split(';')[0];
|
||||||
|
assert.ok(seatCookie);
|
||||||
|
await created.body?.cancel();
|
||||||
|
const credentials: Record<string, string>[] = [
|
||||||
|
{},
|
||||||
|
{ cookie: seatCookie, 'X-Dune-Admin-Id': backupId },
|
||||||
|
{ cookie: 'dune_admin_session=' + 'a'.repeat(64), 'X-Dune-Admin-Id': backupId },
|
||||||
|
];
|
||||||
|
for (const headers of credentials) {
|
||||||
|
for (const path of [`/api/admin/backups?room=${roomCode}`, `/api/admin/backups/${backupId}/download`]) {
|
||||||
|
const response = await fetch(base + path, { headers, signal: AbortSignal.timeout(15_000) });
|
||||||
|
assert.equal(response.status, 401, path);
|
||||||
|
assert.equal(response.headers.get('cache-control'), 'no-store');
|
||||||
|
assert.equal(response.headers.get('content-disposition'), null);
|
||||||
|
assert.deepEqual(Object.keys(await response.json()), ['error']);
|
||||||
|
}
|
||||||
|
const response = await fetch(base + '/api/admin/backups', {
|
||||||
|
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ roomCode, operationId: crypto.randomUUID(), expectedVersion: 0, reason: 'Denied backup' }),
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 401);
|
||||||
|
assert.equal(response.headers.get('cache-control'), 'no-store');
|
||||||
|
assert.deepEqual(Object.keys(await response.json()), ['error']);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { adminStore, pauseAdminBatch, sha256 } from './admin-access-fixture';
|
||||||
|
import { AdminError, adminLogin, requireAdmin, type AdminIdentity, type AdminRole } from '../db/admin-access';
|
||||||
|
import { captureAdminBackup, downloadAdminBackup, listAdminBackups, MAX_BACKUP_BYTES, MAX_BACKUP_DOWNLOADS, MAX_ROOM_BACKUPS, MAX_ROOM_BACKUP_BYTES, MAX_TOTAL_BACKUP_BYTES } from '../db/admin-backups';
|
||||||
|
|
||||||
|
const now = 10_000;
|
||||||
|
const code = 'BACKUPAA';
|
||||||
|
const fails = (status: number) => (error: unknown) => error instanceof AdminError && error.status === status;
|
||||||
|
async function fixture(role: AdminRole = 'owner') {
|
||||||
|
const store = adminStore();
|
||||||
|
const account = store.provision(role);
|
||||||
|
const login = await adminLogin(store.database, account.key, now);
|
||||||
|
const identity = await requireAdmin(store.database, login.token, undefined, now);
|
||||||
|
const privateGame = JSON.stringify({ host: 'Private host', hiddenCards: ['Traitor: Private card'], turn: 3 });
|
||||||
|
store.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)').run(code, privateGame, 7, 9000);
|
||||||
|
const sessionHash = sha256('seat-secret');
|
||||||
|
store.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id,revoked) VALUES(?,?,?,?)').run(sessionHash, code, 'host', 1);
|
||||||
|
store.sqlite.prepare('INSERT INTO room_controls(room_code,paused,join_locked,revision,updated_at) VALUES(?,1,1,2,?)').run(code, now);
|
||||||
|
store.sqlite.prepare('INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES(?,1,1,?,?)').run(code, now, now);
|
||||||
|
store.sqlite.prepare('INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES(?,1,1,?,?)').run(code, now, now);
|
||||||
|
store.sqlite.prepare('INSERT INTO room_archives(room_code,archived,revision,archived_at,updated_at) VALUES(?,1,1,?,?)').run(code, now, now);
|
||||||
|
store.sqlite.prepare('INSERT INTO seat_recovery_keys(room_code,player_id,recovery_hash,current_operation_hash) VALUES(?,?,?,?)').run(code, 'host', sha256('recovery-secret'), sha256('operation-secret'));
|
||||||
|
store.sqlite.prepare('INSERT INTO seat_recovery_receipts(room_code,player_id,operation_hash,recovery_hash,session_hash) VALUES(?,?,?,?,?)').run(code, 'host', sha256('recovery-operation'), sha256('recovery-secret'), sessionHash);
|
||||||
|
store.sqlite.prepare('INSERT INTO seat_handover_offers(room_code,player_id,offer_hash,secret_hash,issuer_session_hash,expires_at) VALUES(?,?,?,?,?,?)').run(code, 'host', sha256('offer'), sha256('handover-secret'), sessionHash, now + 1000);
|
||||||
|
store.sqlite.prepare('INSERT INTO seat_handover_claim_receipts(room_code,player_id,operation_hash,offer_hash,secret_hash,session_hash,claim_fence,claimed_at) VALUES(?,?,?,?,?,?,?,?)').run(code, 'host', sha256('claim'), sha256('offer'), sha256('handover-secret'), sessionHash, sha256('fence'), now);
|
||||||
|
store.sqlite.prepare('INSERT INTO seat_ai_delegations(room_code,owner_id,grant_id,delegate_id,difficulty,owner_session_hash,delegate_session_hash,expires_at) VALUES(?,?,?,?,?,?,?,?)').run(code, 'host', randomUUID(), 'delegate', 'Hard', sessionHash, sha256('delegate-session'), now + 1000);
|
||||||
|
store.sqlite.prepare('INSERT INTO room_entry_receipts(operation_hash,request_hash,session_hash,room_code,player_id) VALUES(?,?,?,?,?)').run(sha256('entry-op'), sha256('entry-request'), sessionHash, code, 'host');
|
||||||
|
store.sqlite.prepare('INSERT INTO seat_discussion_controls(room_code,player_id,muted,revision,updated_at) VALUES(?,?,1,1,?)').run(code, 'host', now);
|
||||||
|
store.sqlite.prepare('INSERT INTO room_messages(room_code,id,sender_id,sender_session_hash,sender_name,sender_faction,recipient_id,recipient_name,body,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)').run(code, randomUUID(), 'host', sessionHash, 'Private host', 'atreides', 'delegate', 'Private recipient', 'private message', now);
|
||||||
|
const input = () => ({ roomCode: code, operationId: randomUUID(), expectedVersion: 7, reason: 'Owner maintenance checkpoint' });
|
||||||
|
return { ...store, account, identity, privateGame, sessionHash, input };
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('capture preserves every room-owned row and private access revocation without mutating the live room', async () => {
|
||||||
|
const store = await fixture();
|
||||||
|
try {
|
||||||
|
const tables = ['rooms', 'seats', 'room_controls', 'room_removals', 'room_closures', 'room_archives', 'seat_recovery_keys', 'seat_recovery_receipts', 'seat_handover_offers', 'seat_handover_claim_receipts', 'seat_ai_delegations', 'room_entry_receipts', 'seat_discussion_controls', 'room_messages'];
|
||||||
|
const before = tables.map(table => store.sqlite.prepare(`SELECT * FROM ${table}`).all());
|
||||||
|
const first = await captureAdminBackup(store.database, store.identity, store.input(), now);
|
||||||
|
const listed = await listAdminBackups(store.database, store.identity, code, now);
|
||||||
|
assert.deepEqual(listed, [first.backup]);
|
||||||
|
assert.equal(JSON.stringify(listed).includes('private message'), false);
|
||||||
|
assert.equal(JSON.stringify(listed).includes(store.sessionHash), false);
|
||||||
|
const downloaded = await downloadAdminBackup(store.database, store.identity, first.backup.id, now);
|
||||||
|
const data = JSON.parse(downloaded.payload);
|
||||||
|
assert.equal(data.format, 1);
|
||||||
|
assert.equal(data.room.state, store.privateGame);
|
||||||
|
assert.equal(data.room.version, 7);
|
||||||
|
for (let index = 1; index < tables.length; index++)
|
||||||
|
assert.deepEqual(data.tables[tables[index]], JSON.parse(JSON.stringify(before[index])));
|
||||||
|
assert.equal(data.tables.seats[0].revoked, 1);
|
||||||
|
assert.equal(first.backup.sizeBytes, Buffer.byteLength(downloaded.payload));
|
||||||
|
assert.equal(first.backup.digest, sha256(downloaded.payload));
|
||||||
|
const audit = store.sqlite.prepare('SELECT operation_id,actor_admin_id,room_code,created_at FROM admin_room_backup_downloads').all();
|
||||||
|
assert.deepEqual(JSON.parse(JSON.stringify(audit)), [{ operation_id: first.backup.id, actor_admin_id: store.identity.id, room_code: code, created_at: now }]);
|
||||||
|
assert.deepEqual(tables.map(table => store.sqlite.prepare(`SELECT * FROM ${table}`).all()), before);
|
||||||
|
assert.equal(store.sqlite.prepare('SELECT actor_admin_id,room_code,reason FROM admin_room_backups').get()!.actor_admin_id, store.identity.id);
|
||||||
|
} finally { store.sqlite.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('operation ID binds exact actor/request and replays the same immutable bytes after room changes', async () => {
|
||||||
|
const store = await fixture();
|
||||||
|
try {
|
||||||
|
const input = store.input();
|
||||||
|
const first = await captureAdminBackup(store.database, store.identity, input, now);
|
||||||
|
store.sqlite.prepare('UPDATE rooms SET version=8,state=? WHERE code=?').run('{"new":"private state"}', code);
|
||||||
|
const replay = await captureAdminBackup(store.database, store.identity, input, now);
|
||||||
|
assert.deepEqual(replay, { ...first, replayed: true });
|
||||||
|
assert.equal((await downloadAdminBackup(store.database, store.identity, input.operationId, now)).payload.includes('new'), false);
|
||||||
|
await assert.rejects(captureAdminBackup(store.database, store.identity, { ...input, reason: 'different request' }, now), fails(409));
|
||||||
|
await assert.rejects(captureAdminBackup(store.database, store.identity, store.input(), now), fails(409));
|
||||||
|
const secondOwner = store.provision();
|
||||||
|
const login = await adminLogin(store.database, secondOwner.key, now);
|
||||||
|
const identity = await requireAdmin(store.database, login.token, ['owner'], now);
|
||||||
|
await assert.rejects(captureAdminBackup(store.database, identity, input, now), fails(409));
|
||||||
|
assert.equal(store.sqlite.prepare('SELECT COUNT(*) AS count FROM admin_room_backups').get()!.count, 1);
|
||||||
|
} finally { store.sqlite.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('live revocation, version advances and unversioned writes during staged capture cannot create or leak a backup', async () => {
|
||||||
|
const store = await fixture();
|
||||||
|
try {
|
||||||
|
const input = store.input();
|
||||||
|
const gate = pauseAdminBatch(store);
|
||||||
|
const pending = captureAdminBackup(store.database, store.identity, input, now);
|
||||||
|
await gate.waiting;
|
||||||
|
store.sqlite.prepare('UPDATE admin_accounts SET session_generation=session_generation+1 WHERE id=?').run(store.identity.id);
|
||||||
|
gate.release();
|
||||||
|
await assert.rejects(pending, fails(403));
|
||||||
|
await assert.rejects(listAdminBackups(store.database, store.identity, code, now), fails(403));
|
||||||
|
await assert.rejects(downloadAdminBackup(store.database, store.identity, input.operationId, now), fails(403));
|
||||||
|
assert.equal(store.sqlite.prepare('SELECT COUNT(*) AS count FROM admin_room_backups').get()!.count, 0);
|
||||||
|
} finally { store.sqlite.close(); }
|
||||||
|
const changed = await fixture();
|
||||||
|
try {
|
||||||
|
const input = changed.input();
|
||||||
|
const gate = pauseAdminBatch(changed);
|
||||||
|
const pending = captureAdminBackup(changed.database, changed.identity, input, now);
|
||||||
|
await gate.waiting;
|
||||||
|
changed.sqlite.prepare('UPDATE room_messages SET body=? WHERE room_code=?').run('new private message', code);
|
||||||
|
gate.release();
|
||||||
|
await assert.rejects(pending, fails(409));
|
||||||
|
assert.equal(changed.sqlite.prepare('SELECT COUNT(*) AS count FROM admin_room_backups').get()!.count, 0);
|
||||||
|
} finally { changed.sqlite.close(); }
|
||||||
|
const advanced = await fixture();
|
||||||
|
try {
|
||||||
|
const input = advanced.input();
|
||||||
|
const gate = pauseAdminBatch(advanced);
|
||||||
|
const pending = captureAdminBackup(advanced.database, advanced.identity, input, now);
|
||||||
|
await gate.waiting;
|
||||||
|
advanced.sqlite.prepare('UPDATE rooms SET version=version+1 WHERE code=?').run(code);
|
||||||
|
gate.release();
|
||||||
|
await assert.rejects(pending, fails(409));
|
||||||
|
assert.equal(advanced.sqlite.prepare('SELECT COUNT(*) AS count FROM admin_room_backups').get()!.count, 0);
|
||||||
|
} finally { advanced.sqlite.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('viewer/operator, revoked owner, and seat credentials cannot download private backups', async () => {
|
||||||
|
const store = await fixture();
|
||||||
|
try {
|
||||||
|
const id = (await captureAdminBackup(store.database, store.identity, store.input(), now)).backup.id;
|
||||||
|
for (const role of ['viewer', 'operator'] as const) {
|
||||||
|
const account = store.provision(role);
|
||||||
|
const login = await adminLogin(store.database, account.key, now);
|
||||||
|
const identity = await requireAdmin(store.database, login.token, undefined, now);
|
||||||
|
await assert.rejects(downloadAdminBackup(store.database, identity, id, now), fails(403));
|
||||||
|
await assert.rejects(listAdminBackups(store.database, identity, code, now), fails(403));
|
||||||
|
}
|
||||||
|
await assert.rejects(downloadAdminBackup(store.database, { ...store.identity, sessionHash: store.sessionHash }, id, now), fails(403));
|
||||||
|
store.sqlite.prepare('UPDATE admin_sessions SET revoked_at=? WHERE token_hash=?').run(now, store.identity.sessionHash);
|
||||||
|
await assert.rejects(downloadAdminBackup(store.database, store.identity, id, now), fails(403));
|
||||||
|
assert.equal(store.sqlite.prepare('SELECT COUNT(*) AS count FROM admin_room_backup_downloads').get()!.count, 0);
|
||||||
|
assert.equal(store.sqlite.prepare('SELECT COUNT(*) AS count FROM admin_room_backups').get()!.count, 1);
|
||||||
|
} finally { store.sqlite.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('oversized room is rejected before persistence without truncation', async () => {
|
||||||
|
const store = await fixture();
|
||||||
|
try {
|
||||||
|
store.sqlite.prepare('UPDATE rooms SET state=? WHERE code=?').run('🔒'.repeat(MAX_BACKUP_BYTES / 4), code);
|
||||||
|
await assert.rejects(captureAdminBackup(store.database, store.identity, store.input(), now), fails(413));
|
||||||
|
assert.equal(store.sqlite.prepare('SELECT COUNT(*) AS count FROM admin_room_backups').get()!.count, 0);
|
||||||
|
} finally { store.sqlite.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('the larger bounded format preserves substantial private discussion without truncation', async () => {
|
||||||
|
const store = await fixture();
|
||||||
|
try {
|
||||||
|
const body = 'private discussion '.repeat(40_000);
|
||||||
|
store.sqlite.prepare('UPDATE room_messages SET body=? WHERE room_code=?').run(body, code);
|
||||||
|
const backup = await captureAdminBackup(store.database, store.identity, store.input(), now);
|
||||||
|
assert.ok(backup.backup.sizeBytes > 512 * 1024 && backup.backup.sizeBytes < MAX_BACKUP_BYTES);
|
||||||
|
const payload = JSON.parse((await downloadAdminBackup(store.database, store.identity, backup.backup.id, now)).payload);
|
||||||
|
assert.equal(payload.tables.room_messages[0].body, body);
|
||||||
|
} finally { store.sqlite.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('oversized request rechecks an exact operation committed while its status was pending', async () => {
|
||||||
|
const store = await fixture();
|
||||||
|
try {
|
||||||
|
const input = store.input();
|
||||||
|
store.sqlite.prepare('UPDATE rooms SET state=? WHERE code=?').run('🔒'.repeat(MAX_BACKUP_BYTES / 4), code);
|
||||||
|
const gate = pauseAdminBatch(store);
|
||||||
|
const pending = captureAdminBackup(store.database, store.identity, input, now);
|
||||||
|
await gate.waiting;
|
||||||
|
store.sqlite.prepare('UPDATE rooms SET state=? WHERE code=?').run(store.privateGame, code);
|
||||||
|
const winner = await captureAdminBackup(store.database, store.identity, input, now);
|
||||||
|
gate.release();
|
||||||
|
assert.deepEqual(await pending, { ...winner, replayed: true });
|
||||||
|
assert.equal(store.sqlite.prepare('SELECT COUNT(*) AS count FROM admin_room_backups').get()!.count, 1);
|
||||||
|
} finally { store.sqlite.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('duplicate snapshot is rejected but unversioned content change permits a new capture', async () => {
|
||||||
|
const store = await fixture();
|
||||||
|
try {
|
||||||
|
await captureAdminBackup(store.database, store.identity, store.input(), now);
|
||||||
|
await assert.rejects(captureAdminBackup(store.database, store.identity, store.input(), now), fails(409));
|
||||||
|
store.sqlite.prepare('UPDATE room_messages SET body=? WHERE room_code=?').run('changed private message', code);
|
||||||
|
const next = await captureAdminBackup(store.database, store.identity, store.input(), now);
|
||||||
|
assert.equal(next.backup.roomVersion, 7);
|
||||||
|
assert.equal(store.sqlite.prepare('SELECT COUNT(*) AS count FROM admin_room_backups').get()!.count, 2);
|
||||||
|
} finally { store.sqlite.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
function seedPriorBackups(store: { sqlite: DatabaseSync; identity: AdminIdentity }, count: number, room: string, bytes: number) {
|
||||||
|
for (let index = 0; index < count; index++)
|
||||||
|
store.sqlite.prepare(`INSERT INTO admin_room_backups
|
||||||
|
(operation_id,actor_admin_id,room_code,request_hash,room_version,created_at,size_bytes,digest,payload,reason)
|
||||||
|
VALUES(?,?,?,?,?,?,?,'prior',CAST(zeroblob(?) AS TEXT),'prior')`).run(
|
||||||
|
randomUUID(), store.identity.id, room, 'prior', index, now - 1, bytes, bytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('per-room count and byte quotas and global byte quota reject new IDs without deleting prior receipts', async () => {
|
||||||
|
const count = await fixture();
|
||||||
|
try {
|
||||||
|
seedPriorBackups(count, MAX_ROOM_BACKUPS, code, 2);
|
||||||
|
await assert.rejects(captureAdminBackup(count.database, count.identity, count.input(), now), fails(507));
|
||||||
|
assert.equal(count.sqlite.prepare('SELECT COUNT(*) AS count FROM admin_room_backups').get()!.count, MAX_ROOM_BACKUPS);
|
||||||
|
} finally { count.sqlite.close(); }
|
||||||
|
const roomBytes = await fixture();
|
||||||
|
try {
|
||||||
|
seedPriorBackups(roomBytes, Math.ceil(MAX_ROOM_BACKUP_BYTES / 1_700_000), code, 1_700_000);
|
||||||
|
await assert.rejects(captureAdminBackup(roomBytes.database, roomBytes.identity, roomBytes.input(), now), fails(507));
|
||||||
|
} finally { roomBytes.sqlite.close(); }
|
||||||
|
const total = await fixture();
|
||||||
|
try {
|
||||||
|
const rows = Math.ceil(MAX_TOTAL_BACKUP_BYTES / 1_700_000);
|
||||||
|
seedPriorBackups(total, rows, 'OTHERAAA', 1_700_000);
|
||||||
|
await assert.rejects(captureAdminBackup(total.database, total.identity, total.input(), now), fails(507));
|
||||||
|
assert.equal(total.sqlite.prepare('SELECT COUNT(*) AS count FROM admin_room_backups').get()!.count, rows);
|
||||||
|
} finally { total.sqlite.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a failed or revoked export cannot return private bytes or append an audit receipt', async () => {
|
||||||
|
const store = await fixture();
|
||||||
|
try {
|
||||||
|
const id = (await captureAdminBackup(store.database, store.identity, store.input(), now)).backup.id;
|
||||||
|
store.sqlite.exec(`CREATE TRIGGER reject_backup_export BEFORE INSERT ON admin_room_backup_downloads
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END`);
|
||||||
|
await assert.rejects(downloadAdminBackup(store.database, store.identity, id, now), /audit unavailable/);
|
||||||
|
store.sqlite.exec('DROP TRIGGER reject_backup_export');
|
||||||
|
const gate = pauseAdminBatch(store);
|
||||||
|
const pending = downloadAdminBackup(store.database, store.identity, id, now);
|
||||||
|
await gate.waiting;
|
||||||
|
store.sqlite.prepare('UPDATE admin_accounts SET session_generation=session_generation+1 WHERE id=?').run(store.identity.id);
|
||||||
|
gate.release();
|
||||||
|
await assert.rejects(pending, fails(403));
|
||||||
|
assert.equal(store.sqlite.prepare('SELECT COUNT(*) AS count FROM admin_room_backup_downloads').get()!.count, 0);
|
||||||
|
} finally { store.sqlite.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('highly escaped valid stored state over D1 JSON expansion limit returns 413 and exact prior operation still replays', async () => {
|
||||||
|
const store = await fixture();
|
||||||
|
try {
|
||||||
|
const firstInput = store.input();
|
||||||
|
const first = await captureAdminBackup(store.database, store.identity, firstInput, now);
|
||||||
|
const escapedState = JSON.stringify({ secret: '\\'.repeat(650_000) });
|
||||||
|
assert.ok(Buffer.byteLength(escapedState) < 2_000_000);
|
||||||
|
store.sqlite.prepare('UPDATE rooms SET state=? WHERE code=?').run(escapedState, code);
|
||||||
|
await assert.rejects(captureAdminBackup(store.database, store.identity, store.input(), now), fails(413));
|
||||||
|
assert.deepEqual(await captureAdminBackup(store.database, store.identity, firstInput, now), { ...first, replayed: true });
|
||||||
|
assert.equal(store.sqlite.prepare('SELECT state FROM rooms WHERE code=?').get(code)!.state, escapedState);
|
||||||
|
assert.equal(store.sqlite.prepare('SELECT COUNT(*) AS count FROM admin_room_backups').get()!.count, 1);
|
||||||
|
} finally { store.sqlite.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('audit capacity rejects an authorized export without releasing bytes or changing the backup', async () => {
|
||||||
|
const store = await fixture();
|
||||||
|
try {
|
||||||
|
const id = (await captureAdminBackup(store.database, store.identity, store.input(), now)).backup.id;
|
||||||
|
const original = store.sqlite.prepare('SELECT payload FROM admin_room_backups WHERE operation_id=?').get(id)!.payload;
|
||||||
|
store.sqlite.prepare(`WITH RECURSIVE seq(n) AS (VALUES(1) UNION ALL SELECT n+1 FROM seq WHERE n < ?)
|
||||||
|
INSERT INTO admin_room_backup_downloads(download_id,operation_id,actor_admin_id,room_code,created_at)
|
||||||
|
SELECT printf('%08x-0000-4000-8000-%012x',n,n),?,?,?,? FROM seq`).run(
|
||||||
|
MAX_BACKUP_DOWNLOADS, id, store.identity.id, code, now,
|
||||||
|
);
|
||||||
|
await assert.rejects(downloadAdminBackup(store.database, store.identity, id, now), fails(507));
|
||||||
|
assert.equal(store.sqlite.prepare('SELECT COUNT(*) AS count FROM admin_room_backup_downloads').get()!.count, MAX_BACKUP_DOWNLOADS);
|
||||||
|
assert.equal(store.sqlite.prepare('SELECT payload FROM admin_room_backups WHERE operation_id=?').get(id)!.payload, original);
|
||||||
|
assert.equal(store.sqlite.prepare('SELECT version FROM rooms WHERE code=?').get(code)!.version, 7);
|
||||||
|
} finally { store.sqlite.close(); }
|
||||||
|
});
|
||||||
Reference in new issue
Block a user