Rotate administrator keys with safe re-enable and exact retry

This commit is contained in:
Quintine committed 2026-09-30 00:01:16 +10:00
1 parent 9cf8249398
commit 55b47ce039
20 files changed
+3072 -104

No files matched your search

+3 -2
View File
@@ -47,8 +47,9 @@ no administrator accounts. Save it privately: log readers can use it, and later
starts do not print it again. See [room controls](docs/ADMIN_ROOM_CONTROLS.md)
and [initial owner setup](docs/ADMIN_PANEL.md#initial-owner-and-access-recovery).
Owners can [manage administrator accounts](docs/ADMIN_ACCOUNTS.md) with
one-time personal keys, role changes and access disabling. Save a new key
privately before clearing its tab-scoped recovery record.
one-time personal keys, role changes, disabling and fresh-key rotation
that can safely re-enable a disabled account. Save a new key privately
before clearing its tab-scoped recovery record; an old key cannot be reused.
Owners/operators can [create a configured lobby](docs/ADMIN_ROOM_CREATION.md) with
an explicitly owned new host seat and initial AI players.
[Configure lobby](docs/ADMIN_LOBBY_CONFIGURATION.md) manages supported rules, AI
+32 -25
View File
@@ -5,12 +5,12 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ClientRequestError, requestJson, requestMayHaveCompleted } from '@/lib/client-request';
import type { AdminAccountInput, AdminAccountRow, AdminAccountsDirectory, AdminAccountResult } from '@/lib/admin-accounts';
import { clearAdminAccountRecord, completeAdminAccountRequest, newAdminAccountRequest, readAdminAccountRecord, saveAdminAccountRequest, validAdminAccountResult, type AdminAccountRecord } from '@/lib/admin-accounts-client';
import { adminAccountKeyStatus, clearAdminAccountRecord, completeAdminAccountRequest, newAdminAccountRequest, readAdminAccountRecord, saveAdminAccountRequest, validAdminAccountResult, type AdminAccountRecord } from '@/lib/admin-accounts-client';
import '../admin.css';
import './accounts.css';
type SessionAccount = { id: string; name: string; role: 'owner' | 'operator' | 'viewer' };
type Mutation = 'provision' | 'role' | 'disable';
type Mutation = 'provision' | 'role' | 'disable' | 'rotate';
const message = (error: unknown) => error instanceof Error ? error.message : 'The account request could not be completed.';
const denied = (error: unknown) => error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0);
@@ -101,12 +101,12 @@ export default function AccountsPage() {
try {
const input = action === 'provision'
? newAdminAccountRequest({ action, name, role, reason })
: target && target.enabled && target.id !== owner.id
: selected && selected.id !== owner.id
? action === 'role'
? newAdminAccountRequest({ action, target: target.id, expectedUpdatedAt: target.updatedAt, role, reason })
: newAdminAccountRequest({ action, target: target.id, expectedUpdatedAt: target.updatedAt, reason })
? newAdminAccountRequest({ action, target: selected.id, expectedUpdatedAt: selected.updatedAt, role, reason })
: newAdminAccountRequest({ action, target: selected.id, expectedUpdatedAt: selected.updatedAt, reason })
: null;
if (!input) throw new Error('Choose a current, enabled account other than your own.');
if (!input) throw new Error('Choose a current account other than your own; role changes and disabling require an enabled account.');
saveAdminAccountRequest(window.sessionStorage, owner.id, input);
setRecord({ kind: 'pending', input }); setConfirmed(false); setStale(false);
setNotice('Review the exact request below. No account change has been sent yet.');
@@ -115,7 +115,7 @@ export default function AccountsPage() {
async function submit(input: AdminAccountInput) {
if (!owner || inFlight.current || !confirmed || storageProblem || stale || record?.kind !== 'pending' ||
(input.action !== 'provision' && !directory?.accounts.some(row => row.id === input.target))) return;
(input.action !== 'provision' && (input.target === owner.id || !directory?.accounts.some(row => row.id === input.target)))) return;
inFlight.current = true; setBusy(true); setConfirmed(false);
try {
// A restored tab must not submit under another or a demoted account.
@@ -128,10 +128,10 @@ export default function AccountsPage() {
if (!validAdminAccountResult(result, input)) throw new Error('The server did not confirm the exact account request. Keep the saved retry.');
completeAdminAccountRequest(window.sessionStorage, owner.id, input, result);
if (!mounted.current) return;
setRecord(input.action === 'provision' ? { kind: 'completed', input, account: result.account } : null);
setRecord(input.action === 'provision' || input.action === 'rotate' ? { kind: 'completed', input, account: result.account } : null);
setTarget(null); setReason(''); setName(''); setStale(false);
setNotice(result.replayed ? 'The original account operation was confirmed; no duplicate change was made.' : 'Account operation confirmed.');
try { await loadDirectory(owner, 1); }
try { await loadDirectory(owner, input.action === 'rotate' ? directory?.page ?? 1 : 1); }
catch (error) { if (denied(error)) loseAccess(); else setNotice(`Account operation confirmed, but refresh failed: ${message(error)}`); }
} catch (error) {
if (!mounted.current) return;
@@ -156,51 +156,57 @@ export default function AccountsPage() {
}
function select(row: AdminAccountRow, next: Mutation) {
if (!owner || row.id === owner.id || !row.enabled || record || busy) return;
if (!owner || row.id === owner.id || (next !== 'rotate' && !row.enabled) || record || busy) return;
setTarget(row); setAction(next); setRole(row.role); setReason(''); setConfirmed(false); setNotice('');
}
const pending = record?.kind === 'pending' ? record.input : null;
const pendingTarget = pending?.action !== 'provision' && pending ? directory?.accounts.find(row => row.id === pending.target) : null;
const completed = record?.kind === 'completed' ? record : null;
const selected = target && directory?.accounts.find(item => item.id === target.id && item.updatedAt === target.updatedAt && item.enabled);
const keyStatus = completed ? adminAccountKeyStatus(completed, directory) : null;
const selected = target && directory?.accounts.find(item => item.id === target.id && item.updatedAt === target.updatedAt && (action === 'rotate' || item.enabled));
const totalPages = directory ? Math.max(1, Math.ceil(directory.total / directory.pageSize)) : 1;
return <main className="admin-shell admin-accounts">
<header className="admin-header"><div><a className="admin-return" href="/admin">Administration</a><h1>Administrator accounts</h1></div>
{owner && <span>{owner.name} <span className="admin-tag">owner</span></span>}</header>
<p>Only an owner can provision accounts, change roles or disable account access. Account changes do not change games or seats.</p>
<p>Only an owner can provision accounts, change roles, disable access or rotate another account’s key. Account changes do not change games or seats.</p>
{notice && <p role="alert" className="admin-notice">{notice}</p>}
{loading ? <output>Checking owner access and account directory…</output> : !owner ?
<p><a className="admin-room-link" href="/admin">Return to administrator sign-in</a></p> : <>
{storageProblem ? <section className="admin-room-control" aria-labelledby="accounts-storage"><h2 id="accounts-storage">Unreadable local request</h2>
<p>Account changes are blocked. Inspect the account directory before discarding the unreadable record. A previously submitted operation may have committed, and a provisioned key may be lost if you discard it.</p>
<p>Account changes are blocked. Inspect the account directory before discarding the unreadable record. A previously submitted operation may have committed, and a newly issued key may be lost if you discard it.</p>
<label className="admin-check"><input type="checkbox" checked={confirmed} onChange={event => setConfirmed(event.target.checked)} />I checked the directory and understand this tab’s saved retry or key may be lost.</label>
<Button disabled={!confirmed || busy || !directory} onClick={discard}>Discard unreadable local record</Button>
</section> : pending ? <section className="admin-room-control" aria-labelledby="accounts-pending"><h2 id="accounts-pending">Saved account request</h2>
<p>{stale ? 'This request conflicted. Inspect the refreshed directory and explicitly discard it to prepare another request.' : 'The exact operation is saved in this tab. A previous attempt may have succeeded. Retry only this request to confirm its outcome.'}</p>
<dl className="accounts-review"><dt>Action</dt><dd>{pending.action}</dd><dt>Account name</dt><dd>{pending.action === 'provision' ? pending.name : pendingTarget?.name ?? 'Find this account in the directory before sending'}</dd>
<dt>Account ID</dt><dd>{pending.action === 'provision' ? pending.id : pending.target}</dd>
{pending.action !== 'provision' && <><dt>Current role</dt><dd>{pendingTarget?.role ?? 'Unavailable'}</dd><dt>Reviewed version</dt><dd>{new Date(pending.expectedUpdatedAt).toLocaleString()}</dd></>}
{pending.action !== 'disable' && <><dt>Desired role</dt><dd>{pending.role}</dd></>}
{pending.action !== 'provision' && <><dt>Current role</dt><dd>{pendingTarget?.role ?? 'Unavailable'}</dd><dt>Current state</dt><dd>{pendingTarget ? pendingTarget.enabled ? 'Enabled' : 'Disabled' : 'Unavailable'}</dd><dt>Reviewed version</dt><dd>{new Date(pending.expectedUpdatedAt).toLocaleString()}</dd></>}
{(pending.action === 'provision' || pending.action === 'role') && <><dt>Desired role</dt><dd>{pending.role}</dd></>}
<dt>Reason</dt><dd>{pending.reason}</dd><dt>Operation ID</dt><dd>{pending.operationId}</dd></dl>
{pending.action === 'disable' && <p role="alert">Disabling {pendingTarget?.name ?? pending.target} blocks the existing access key and permanently revokes every current session. This page cannot re-enable the account. Check its current role and exact ID before proceeding.</p>}
<p className="admin-secondary">Provisioning key stays hidden until the exact operation is confirmed. Do not enter credentials in the reason. Discarding an uncertain provision request can permanently lose the only copy of its key.</p>
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy} onChange={event => setConfirmed(event.target.checked)} />{pending.action === 'disable' ? `I confirm disabling ${pendingTarget?.name ?? pending.target} (${pending.target}) and revoking its sessions for the recorded reason.` : 'I verified this exact account ID, name, action, desired role and reason before changing administrator access.'}</label>
<div className="accounts-actions"><Button disabled={busy || !confirmed || stale || (pending.action !== 'provision' && !pendingTarget)} onClick={() => void submit(pending)}>{busy ? 'Checking request…' : 'Confirm and send exact saved request'}</Button>
{pending.action !== 'provision' && pendingTarget?.updatedAt !== pending.expectedUpdatedAt && <p role="alert">This account’s current version differs from the reviewed version. Only the exact saved operation can be retried; a new request requires discarding it after checking the directory.</p>}
{pending.action === 'disable' && <p role="alert">Disabling {pendingTarget?.name ?? pending.target} blocks the existing access key and permanently revokes every current session. Re-enabling requires a new key rotation; its old key will never work again. Check its current role and exact ID before proceeding.</p>}
{pending.action === 'rotate' && <p role="alert">Rotating {pendingTarget?.name ?? pending.target} ({pending.target}) replaces its key with a fresh one and revokes every existing session. Its role and name stay unchanged. A disabled account is re-enabled with the new key; its old key remains invalid. Save the new key securely after confirmation. Do not rotate your own account.</p>}
<p className="admin-secondary">A new key stays hidden until the exact operation is confirmed. Do not enter credentials in the reason. Discarding an uncertain provision or rotation request can permanently lose the only copy of its key.</p>
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy} onChange={event => setConfirmed(event.target.checked)} />{pending.action === 'disable' ? `I confirm disabling ${pendingTarget?.name ?? pending.target} (${pending.target}) and revoking its sessions for the recorded reason.` : pending.action === 'rotate' ? `I confirm rotating the key for ${pendingTarget?.name ?? pending.target} (${pending.target}), revoking all its sessions${pendingTarget?.enabled === false ? ', and re-enabling access with only the new key' : ''} for the recorded reason.` : 'I verified this exact account ID, name, action, desired role and reason before changing administrator access.'}</label>
<div className="accounts-actions"><Button disabled={busy || !confirmed || stale || (pending.action !== 'provision' && (!pendingTarget || pending.target === owner.id))} onClick={() => void submit(pending)}>{busy ? 'Checking request…' : 'Confirm and send exact saved request'}</Button>
<Button variant="outline" disabled={busy || !confirmed || !directory} onClick={discard}>Discard saved request after checking directory</Button></div>
</section> : completed ? <section className="admin-room-control" aria-labelledby="accounts-key"><h2 id="accounts-key">Save this account key now</h2>
<p>Account <strong>{completed.account.name}</strong> ({completed.account.id}) is provisioned. This key is available only in this tab until you explicitly clear its local record. It is not returned by the server. Give it privately to its owner; it cannot be recovered after clearing.</p>
</section> : completed ? <section className="admin-room-control" aria-labelledby="accounts-key"><h2 id="accounts-key">Save or verify this account key</h2>
<p>Account <strong>{completed.account.name}</strong> ({completed.account.id}) {completed.input.action === 'rotate' ? 'was rotated and enabled by this operation; its previous key and sessions were revoked at that time.' : 'was provisioned by this operation.'} This key is available only in this tab until you explicitly clear its local record. It is not returned by the server. Do not distribute it without checking its current access; it cannot be recovered after clearing.</p>
{keyStatus === 'matching' ? <p>The directory matched this operation at the last read. A later change can still invalidate the key; verify sign-in before distributing it.</p> :
<p role="alert">{keyStatus === 'changed' ? 'The account changed or was disabled after this operation.' : 'The account is not shown on the current directory page, so its current access cannot be confirmed.'} This saved key may no longer work. Do not distribute it; check the account and rotate a fresh key if needed.</p>}
<label htmlFor="accounts-private-key">One-time account access key</label>
<Input id="accounts-private-key" readOnly value={completed.input.key} autoComplete="off" spellCheck={false} onFocus={event => event.currentTarget.select()} />
<label className="admin-check"><input type="checkbox" checked={confirmed} onChange={event => setConfirmed(event.target.checked)} />I saved this key securely and understand clearing this record cannot recover it.</label>
<Button disabled={!confirmed || busy} onClick={discard}>Clear saved key and start another request</Button>
</section> : <section className="admin-room-control" aria-labelledby="accounts-new"><h2 id="accounts-new">{action === 'provision' ? 'Provision an account' : action === 'role' ? 'Change account role' : 'Disable an account'}</h2>
</section> : <section className="admin-room-control" aria-labelledby="accounts-new"><h2 id="accounts-new">{action === 'provision' ? 'Provision an account' : action === 'role' ? 'Change account role' : action === 'rotate' ? 'Rotate account key' : 'Disable an account'}</h2>
<form onSubmit={startReview}>
{action === 'provision' ? <><label htmlFor="accounts-name">Account name<Input id="accounts-name" required maxLength={80} value={name} disabled={busy} onChange={event => setName(event.target.value)} /></label>
<label htmlFor="accounts-role">Initial role<select id="accounts-role" value={role} disabled={busy} onChange={event => setRole(event.target.value as AdminAccountRow['role'])}><option value="viewer">Viewer</option><option value="operator">Operator</option><option value="owner">Owner</option></select></label></> : selected ? <><p>Target: <strong>{selected.name}</strong> ({selected.id}) · current role {selected.role}</p>
{action === 'role' && <label htmlFor="accounts-role">Desired role<select id="accounts-role" value={role} disabled={busy} onChange={event => setRole(event.target.value as AdminAccountRow['role'])}><option value="viewer">Viewer</option><option value="operator">Operator</option><option value="owner">Owner</option></select></label>}
{action === 'disable' && <p>Disabling blocks this account’s existing key and permanently revokes its current sessions. It cannot be re-enabled here.</p>}</> : <p>The selected account changed. Refresh and choose it again.</p>}
{action === 'disable' && <p>Disabling blocks this account’s existing key and permanently revokes its current sessions. Re-enabling requires a fresh key rotation; its old key cannot be used again.</p>}
{action === 'rotate' && <p>Current state: {selected.enabled ? 'enabled' : 'disabled'}. Rotation revokes all existing sessions, replaces the old key, preserves the current role and name, and enables this account with only the newly generated key. Your own key cannot be rotated here.</p>}</> : <p>The selected account changed. Refresh and choose it again.</p>}
<label htmlFor="accounts-reason">Reason for action history<Input id="accounts-reason" required maxLength={300} value={reason} disabled={busy} onChange={event => setReason(event.target.value)} /></label>
<p className="admin-secondary">Use an operational reason without access keys, credentials or private game information. The next screen shows the exact request for confirmation before anything is sent.</p>
<Button type="submit" disabled={busy || !directory || !reason.trim() || (action === 'provision' ? !name.trim() : !selected || action === 'role' && role === selected.role)}>Review exact account request</Button>
@@ -210,8 +216,9 @@ export default function AccountsPage() {
{directory ? <><p>{directory.total.toLocaleString()} accounts · page {directory.page} of {totalPages}</p>
<ul className="accounts-list">{directory.accounts.map(row => <li key={row.id}><div><strong>{row.name}</strong> <span className="admin-tag">{row.role}</span> <span className="admin-tag">{row.enabled ? 'Enabled' : 'Disabled'}</span>
<p className="accounts-id">{row.id}</p><p>Updated {new Date(row.updatedAt).toLocaleString()}</p></div>
{row.enabled && row.id !== owner.id && !record && !storageProblem && <div className="accounts-actions"><Button variant="outline" disabled={busy} onClick={() => select(row, 'role')}>Change role · {row.name}</Button>
<Button variant="outline" disabled={busy} onClick={() => select(row, 'disable')}>Disable · {row.name}</Button></div>}</li>)}</ul>
{row.id !== owner.id && !record && !storageProblem && <div className="accounts-actions">{row.enabled && <><Button variant="outline" disabled={busy} onClick={() => select(row, 'role')}>Change role · {row.name}</Button>
<Button variant="outline" disabled={busy} onClick={() => select(row, 'disable')}>Disable · {row.name}</Button></>}
<Button variant="outline" disabled={busy} onClick={() => select(row, 'rotate')}>Rotate key · {row.name}</Button></div>}</li>)}</ul>
{directory.accounts.length === 0 && <p>No accounts on this page.</p>}
<nav className="admin-pagination" aria-label="Account pages"><Button variant="outline" disabled={busy || directory.page <= 1} onClick={() => void refreshAccounts(directory.page - 1)}>Previous</Button>
<span>Page {directory.page} of {totalPages}</span><Button variant="outline" disabled={busy || directory.page >= totalPages} onClick={() => void refreshAccounts(directory.page + 1)}>Next</Button></nav></> : <p>Account directory unavailable. Changes are blocked until it loads.</p>}
+51 -12
View File
@@ -6,6 +6,7 @@ const authority = `SELECT a.role FROM admin_sessions s JOIN admin_accounts a ON
AND s.expires_at>? AND s.generation=a.session_generation`;
const accountFields = 'id,name,role,enabled,created_at,updated_at';
const receiptFields = 'operation_id,actor_admin_id,request_hash,target_admin_id,name,role,enabled,created_at,updated_at';
const allReceipts = `SELECT ${receiptFields} FROM admin_account_operations UNION ALL SELECT ${receiptFields} FROM admin_account_rotations`;
type Account = { id: string; name: string; role: AdminAccountRow['role']; enabled: number; created_at: number; updated_at: number };
type Receipt = Omit<Account, 'id'> & { operation_id: string; actor_admin_id: string; request_hash: string; target_admin_id: string };
const project = (row: Account): AdminAccountRow => ({ id: row.id, name: row.name, role: row.role, enabled: row.enabled === 1, createdAt: row.created_at, updatedAt: row.updated_at });
@@ -43,11 +44,13 @@ export async function applyAdminAccount(database: D1Database, identity: AdminIde
? { action:input.action,id:input.id,key:input.key,name:input.name,role:input.role,reason:input.reason }
: input.action === 'role'
? { action:input.action,target,expectedUpdatedAt:input.expectedUpdatedAt,role:input.role,reason:input.reason }
: { action:input.action,target,expectedUpdatedAt:input.expectedUpdatedAt,reason:input.reason }));
: input.action === 'rotate'
? { action:input.action,target,expectedUpdatedAt:input.expectedUpdatedAt,key:input.key,reason:input.reason }
: { action:input.action,target,expectedUpdatedAt:input.expectedUpdatedAt,reason:input.reason }));
const previous = await database.batch([
database.prepare(authority).bind(...args),
database.prepare(`SELECT ${receiptFields} FROM admin_account_operations WHERE operation_id=? AND EXISTS (${authority})`).bind(input.operationId,...args),
database.prepare(`SELECT ${accountFields} FROM admin_accounts WHERE id=? AND EXISTS (${authority})`).bind(target,...args),
database.prepare(`SELECT ${receiptFields} FROM (${allReceipts}) WHERE operation_id=? AND EXISTS (${authority})`).bind(input.operationId,...args),
database.prepare(`SELECT ${accountFields},key_hash FROM admin_accounts WHERE id=? AND EXISTS (${authority})`).bind(target,...args),
]);
authorized(previous[0]);
const resolve = (receipt: Receipt | undefined, replayed: boolean): AdminAccountResult | null => {
@@ -58,27 +61,63 @@ export async function applyAdminAccount(database: D1Database, identity: AdminIde
};
const existing = resolve(previous[1].results[0] as Receipt | undefined,true);
if (existing) return existing;
const account = previous[2].results[0] as Account | undefined;
const account = previous[2].results[0] as (Account & { key_hash: string }) | undefined;
if (input.action !== 'provision') {
if (target === identity.id) throw new AdminError('You cannot disable or demote your own account.',409);
if (!account || !account.enabled || account.updated_at !== input.expectedUpdatedAt)
if (target === identity.id) throw new AdminError('You cannot change your own account credentials or authority.',409);
if (!account || input.action !== 'rotate' && !account.enabled || account.updated_at !== input.expectedUpdatedAt)
throw new AdminError('The account changed or was disabled. Refresh before choosing a new operation.',409);
if (input.action === 'role' && account.role === input.role)
throw new AdminError('Choose a different role for this account.',409);
}
const nextTime = input.action === 'provision' ? now : Math.max(now,account!.updated_at+1);
if (input.action === 'rotate') {
const keyHash = await fingerprint(input.key);
const result = await database.batch([
database.prepare(authority).bind(...args),
database.prepare(`UPDATE admin_accounts SET key_hash=?,enabled=1,session_generation=session_generation+1,updated_at=?
WHERE id=? AND updated_at=? AND role=? AND key_hash=? AND key_hash<>? AND id!=?
AND NOT EXISTS (SELECT 1 FROM admin_account_retired_keys WHERE key_hash=?)
AND EXISTS (${authority}) AND NOT EXISTS (SELECT 1 FROM admin_account_operations WHERE operation_id=?)
AND NOT EXISTS (SELECT 1 FROM admin_account_rotations WHERE operation_id=?) RETURNING id`)
.bind(keyHash,nextTime,target,input.expectedUpdatedAt,account!.role,account!.key_hash,keyHash,identity.id,keyHash,...args,input.operationId,input.operationId),
database.prepare(`INSERT INTO admin_account_retired_keys (key_hash,admin_id)
SELECT ?,? WHERE changes()=1 AND EXISTS (${authority})`)
.bind(account!.key_hash,target,...args),
database.prepare(`INSERT INTO admin_account_rotations
(operation_id,actor_admin_id,request_hash,target_admin_id,name,role,previous_enabled,enabled,created_at,updated_at,expected_updated_at,reason,rotated_at)
SELECT ?,?,?,?,name,role,?,enabled,created_at,updated_at,?,?,? FROM admin_accounts
WHERE id=? AND changes()=1 AND EXISTS (${authority})`)
.bind(input.operationId,identity.id,requestHash,target,account!.enabled, input.expectedUpdatedAt,reason,now,target,...args),
database.prepare(`INSERT INTO admin_audit (id,actor_admin_id,target_admin_id,action,created_at,detail,reason)
SELECT ?,?,?,'rotate',?,json_object('previousEnabled',?,'enabled',1),?
WHERE changes()=1 AND EXISTS (${authority})`)
.bind(crypto.randomUUID(),identity.id,target,now,account!.enabled,reason,...args),
database.prepare(`UPDATE admin_sessions SET revoked_at=? WHERE admin_id=? AND revoked_at IS NULL
AND changes()=1 AND EXISTS (SELECT 1 FROM admin_account_rotations WHERE operation_id=? AND actor_admin_id=? AND request_hash=? AND target_admin_id=?)
AND EXISTS (${authority})`)
.bind(now,target,input.operationId,identity.id,requestHash,target,...args),
database.prepare(`SELECT ${receiptFields} FROM (${allReceipts}) WHERE operation_id=? AND EXISTS (${authority})`).bind(input.operationId,...args),
]);
authorized(result[0]);
const committed = resolve(result[6].results[0] as Receipt | undefined,result[1].results.length !== 1);
if (committed) return committed;
throw new AdminError('The account or administrator authority changed. Refresh before choosing a new operation.',409);
}
const mutation = input.action === 'provision'
? database.prepare(`INSERT INTO admin_accounts(id,name,role,key_hash,created_at,updated_at)
SELECT ?,?,?,?,?,? WHERE EXISTS (${authority}) AND NOT EXISTS (SELECT 1 FROM admin_account_operations WHERE operation_id=?)
AND NOT EXISTS (SELECT 1 FROM admin_account_rotations WHERE operation_id=?)
AND NOT EXISTS (SELECT 1 FROM admin_accounts WHERE id=?) RETURNING id`)
.bind(input.id,input.name,input.role,await fingerprint(input.key),now,now,...args,input.operationId,input.id)
.bind(input.id,input.name,input.role,await fingerprint(input.key),now,now,...args,input.operationId,input.operationId,input.id)
: input.action === 'role'
? database.prepare(`UPDATE admin_accounts SET role=?,updated_at=? WHERE id=? AND enabled=1 AND updated_at=? AND role=? AND role<>?
AND id!=? AND EXISTS (${authority}) AND NOT EXISTS (SELECT 1 FROM admin_account_operations WHERE operation_id=?) RETURNING id`)
.bind(input.role,nextTime,target,input.expectedUpdatedAt,account!.role,input.role,identity.id,...args,input.operationId)
AND id!=? AND EXISTS (${authority}) AND NOT EXISTS (SELECT 1 FROM admin_account_operations WHERE operation_id=?)
AND NOT EXISTS (SELECT 1 FROM admin_account_rotations WHERE operation_id=?) RETURNING id`)
.bind(input.role,nextTime,target,input.expectedUpdatedAt,account!.role,input.role,identity.id,...args,input.operationId,input.operationId)
: database.prepare(`UPDATE admin_accounts SET enabled=0,updated_at=? WHERE id=? AND enabled=1 AND updated_at=? AND role=?
AND id!=? AND EXISTS (${authority}) AND NOT EXISTS (SELECT 1 FROM admin_account_operations WHERE operation_id=?) RETURNING id`)
.bind(nextTime,target,input.expectedUpdatedAt,account!.role,identity.id,...args,input.operationId);
AND id!=? AND EXISTS (${authority}) AND NOT EXISTS (SELECT 1 FROM admin_account_operations WHERE operation_id=?)
AND NOT EXISTS (SELECT 1 FROM admin_account_rotations WHERE operation_id=?) RETURNING id`)
.bind(nextTime,target,input.expectedUpdatedAt,account!.role,identity.id,...args,input.operationId,input.operationId);
const result = await database.batch([
database.prepare(authority).bind(...args),
mutation,
@@ -94,7 +133,7 @@ export async function applyAdminAccount(database: D1Database, identity: AdminIde
: database.prepare(`UPDATE admin_audit SET actor_admin_id=?,reason=? WHERE id=(
SELECT id FROM admin_audit WHERE target_admin_id=? AND action=? AND actor_admin_id IS NULL ORDER BY rowid DESC LIMIT 1)
AND changes()=1 AND EXISTS (${authority})`).bind(identity.id,reason,target,input.action === 'provision' ? 'provision' : 'revoke',...args),
database.prepare(`SELECT ${receiptFields} FROM admin_account_operations WHERE operation_id=? AND EXISTS (${authority})`).bind(input.operationId,...args),
database.prepare(`SELECT ${receiptFields} FROM (${allReceipts}) WHERE operation_id=? AND EXISTS (${authority})`).bind(input.operationId,...args),
]);
authorized(result[0]);
const committed = resolve(result[4].results[0] as Receipt | undefined,result[1].results.length !== 1);
+2 -1
View File
@@ -14,7 +14,7 @@ const actionType = `json_extract(${json('action')},'$.type')`;
// bounded groups so SQLite cannot flatten the nine sources back into one union.
const rawEvents = `WITH first_events AS MATERIALIZED (
SELECT 'account' category,rowid row_key,id receipt_id,actor_admin_id actor_id,NULL room_code,target_admin_id target_admin_id,NULL target_seat_id,
CASE WHEN action IN ('provision','revoke','role','login','logout','logout_all') THEN action ELSE 'unknown' END action,
CASE WHEN action IN ('provision','revoke','role','rotate','login','logout','logout_all') THEN action ELSE 'unknown' END action,
reason,NULL before_json,substr(detail,1,8192) after_json,created_at FROM admin_audit
UNION ALL SELECT 'control',rowid,operation_id,actor_admin_id,room_code,NULL,NULL,'room_control',reason,
json_object('paused',before_paused,'joinLocked',before_join_locked,'revision',expected_revision),
@@ -73,6 +73,7 @@ export function projectAdminAudit(row: Row, reasonsVisible: boolean): AdminAudit
const before = parse(row.before_json), after = parse(row.after_json), changes: AuditChange[] = [];
const add = (field: AuditChange['field'], a: string | null, b: string | null) => { if (a !== null || b !== null) changes.push({ field,before:a,after:b }); };
if (row.category === 'account') add('Role',role(after.previousRole),role(after.role));
if (row.category === 'account' && row.action === 'rotate') add('Enabled',flag(after.previousEnabled),flag(after.enabled));
if (row.category === 'lobby' || row.category === 'creation') {
const a = configuration(before), b = configuration(after);
for (const field of ['Rules','Tech Tokens','Stronghold Cards','Host seat','Host faction','Player count','AI seats'] as const) add(field,a[field],b[field]);
+29
View File
@@ -495,6 +495,35 @@ export const adminAccountOperations = sqliteTable(
],
);
// Rotation receipts use a separate table: the original operation CHECK remains intact.
// Only the request fingerprint and safe account metadata persist, never either key.
export const adminAccountRotations = sqliteTable(
'admin_account_rotations',
{
operationId: text('operation_id').primaryKey(),
actorAdminId: text('actor_admin_id').notNull(),
requestHash: text('request_hash').notNull(),
targetAdminId: text('target_admin_id').notNull(),
name: text('name').notNull(),
role: text('role', { enum: ['owner', 'operator', 'viewer'] }).notNull(),
previousEnabled: integer('previous_enabled').notNull(),
enabled: integer('enabled').notNull(),
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
expectedUpdatedAt: integer('expected_updated_at').notNull(),
reason: text('reason').notNull(),
rotatedAt: integer('rotated_at').notNull(),
},
table => [index('admin_account_rotations_target').on(table.targetAdminId, table.rotatedAt)],
);
// Credential history is deliberately separate from public receipts and audit.
// Retained hashes prevent a later A -> B -> A rotation from reactivating A.
export const adminAccountRetiredKeys = sqliteTable('admin_account_retired_keys', {
keyHash: text('key_hash').primaryKey(),
adminId: text('admin_id').notNull(),
});
// Immutable private room snapshots survive room/account deletion; only owner APIs expose them.
export const adminRoomBackups = sqliteTable(
'admin_room_backups',
+65 -17
View File
@@ -6,26 +6,54 @@ Open **Administration → Accounts** as an enabled owner. Operators, viewers and
Choose a name, owner/operator/viewer role and operational reason, then review the exact account ID, name, role and reason before confirming. The browser generates a new UUID and 256-bit personal access key with Web Crypto. It saves the exact request in this tab before sending it. The server stores only its SHA-256 hash and returns account metadata, **not the key**. After confirmation, save the one-time displayed key privately and explicitly clear its tab record. Refresh restores an unconfirmed request or the confirmed key until it is cleared. If the response is uncertain, retry the same saved request; do not generate a replacement request or discard the only key copy. Do not place keys in reasons, screenshots, issue reports or shared logs.
## Change or disable access
## Change roles, disable access or rotate a key
Choose an enabled account other than your own. Review its exact name, ID,
current role, requested new role or disable, and operational reason before
sending. A role change uses the current account timestamp; a stale target
is rejected and must be reviewed again. Disabling blocks the key and
permanently invalidates current sessions; this page cannot re-enable the
account. **Do not manually re-enable its database row:** without a separate
key rotation, its old access key would become usable again. Provision a new
account instead. Another owner must perform a self-demotion or self-disable.
The database refuses to remove the final enabled owner. Existing game rooms,
seats and recovery kits are not modified.
Choose another account and review its exact name, ID, current role and
enabled state, target action, account timestamp and operational reason.
Role changes and disabling require an enabled target. A stale target is
rejected and must be reviewed again. Disabling blocks the existing access
key and permanently invalidates current sessions. Another owner must
perform a self-demotion or self-disable. The database refuses to remove the
final enabled owner. Existing game rooms, seats and recovery kits are not
modified.
Each operation has an exact saved identifier and request fingerprint. Concurrent or repeated identical requests resolve to one account change and one actor-attributed audit record; a different request cannot reuse that identifier. Live owner role, session expiry/generation and target version are rechecked at the database write, not inferred from the rendered page. An uncertain role or disable response can be retried from this tab; a conflicting or revoked request remains blocked for review. Tab records are scoped to the original owner account.
**Rotate key** works for another enabled or disabled account. The browser
generates a fresh target-bound 256-bit key, saves the exact request in this
tab, and asks for confirmation of the target and session impact. Rotation
replaces the old hash, increments the session generation, revokes existing
sessions and enables the account while retaining its name and role. A
disabled account is re-enabled **only with the new key**. Previously
retired key hashes are kept privately so later A→B→A rotations cannot
revive an old credential. Never use manual SQL to re-enable an account
without rotating its key. Rotation of your own account is unavailable.
On success, save the one-time displayed key privately before clearing its
tab record. The server never returns the key. An uncertain response must
be retried with the same saved request; discarding it may lose the only
copy. A retry confirms a historical operation, not that its key still
works: another owner may have rotated or disabled the account afterward.
The page compares the latest visible directory row with the saved
operation and warns when it changed or is not on the current page.
Even matching metadata is only a last-read observation; verify sign-in
before distributing a key.
Action history shows successful account operations with safe role changes and an operational reason for authorized readers. External CLI provisioning or revocation may still have no human actor attribution. Rejected attempts are not yet part of that history. No browser account operation grants access to a playing seat or reveals saved game data.
Each operation has an exact saved identifier and request fingerprint.
Concurrent or repeated identical requests resolve to one account change
and one actor-attributed audit record; a different request cannot reuse
that identifier. Live owner role, session expiry/generation and target
version are rechecked at the database write, not inferred from the
rendered page. An uncertain response can be retried from this tab; a
conflicting or revoked request remains blocked for review. Tab records
are scoped to the original owner account.
Action history shows successful account operations with safe role/enabled
changes and an operational reason for authorized readers. External CLI
provisioning or revocation may still have no human actor attribution.
Rejected attempts are not yet part of that history. No browser account
operation grants access to a playing seat or reveals saved game data.
## Verification and remaining work
Implementation: [account query and transactions](../db/admin-accounts.ts), [request contract](../lib/admin-accounts.ts), [route](../app/api/admin/accounts/route.ts), [page](../app/admin/accounts/page.tsx) and [additive operation receipt](../drizzle/0019_admin_accounts.sql). Keep the original owner-protection and session-revocation triggers in migration `0008`.
Implementation: [account query and transactions](../db/admin-accounts.ts), [request contract](../lib/admin-accounts.ts), [route](../app/api/admin/accounts/route.ts), [page](../app/admin/accounts/page.tsx), [original receipt](../drizzle/0019_admin_accounts.sql) and [additive rotation receipt/key history](../drizzle/0020_admin_key_rotation.sql). Keep the original owner-protection and session-revocation triggers in migration `0008`.
```sh
npm test -- admin-accounts admin-audit
@@ -42,9 +70,29 @@ confirmation. Independent security and persistence reviews resolved
unchanged-role audit no-ops, missing point-of-risk copy and a logout-all
timestamp rollback.
The rotation follow-up passes typecheck, lint and 5,787/5,787 offline
cases, the app build, Drizzle migration check and 55/55 HTTP tests against
the supported local development server. An isolated built-worker D1 store
with its own disposable owner/target completed authenticated **network**
provision, rotation, exact replay, disable, fresh-key re-enable, old
key/session rejection, A→B→A refusal, safe audit and foreign-origin
denial. No production account, room or credential was changed.
An actual phone-width owner page exposed the enabled-target review and
confirmation with no key before submission; a separate local-only
completed-record fixture showed the superseded-key warning without
sending a mutation. Independent security review found the historical
replay presentation risk, now corrected by last-read directory
comparison; persistence review reported no scoped finding.
The full 55-case suite against `wrangler dev` of the production build did
**not** pass: the local workerd process intermittently returned 503
mid-request under that suite. A single oversized request returned 413
and later requests succeeded after draining its body, but that does not
resolve the broader worker-restart observation. Development-server
success and focused built-worker mutations are not deployed acceptance.
Use an isolated owner and disposable target for authenticated end-to-end
account changes; never use a person's normal owner key in a destructive QA
flow. Client/browser fixtures alone do not prove a signed-in **network**
HTTP mutation or production deployment. Key rotation, re-enabling a
disabled account, failed-attempt audit, complete administration and live
acceptance remain unfinished.
HTTP mutation or production deployment. Failed-attempt audit, complete
administration and live acceptance remain unfinished.
+16 -12
View File
@@ -18,12 +18,14 @@ operational reason when permitted, and bounded before/after settings. Expand
**Recorded settings** for the public configuration, lifecycle flags, revisions,
AI difficulty or changed player circle. Names come from current administrator
accounts; deleted accounts retain their recorded identifiers. Web-managed
provisioning, role changes and disabling identify the acting owner and record
the supplied reason. External CLI provisioning/revocation may lack human
actor attribution, and the page says so. A **Recorded** outcome means the
operation was applied; a
room-control operation may successfully save identical settings, and later
operations may supersede it. It does not assert the room's current state.
provisioning, role changes, disabling and fresh-key rotation/re-enable
identify the acting owner and record the supplied reason. Rotation shows
only the before/after enabled state, never the old or new key, hash or
session. External CLI provisioning/revocation may lack human actor
attribution, and the page says so. A **Recorded** outcome means the
operation was applied; a room-control operation may successfully save
identical settings; later operations may supersede it. It does not assert
the room's current state.
## Access and privacy
@@ -55,8 +57,10 @@ Implementation: [query/projection](../db/admin-audit.ts),
[route](../app/api/admin/audit/route.ts), [page](../app/admin/audit/page.tsx).
The original nine-source read needed no migration. Owner account management
adds a safe `reason` field and durable retry receipts in additive
[migration 0019](../drizzle/0019_admin_accounts.sql); it does not add a
new raw game or message source.
[migration 0019](../drizzle/0019_admin_accounts.sql). Additive
[migration 0020](../drizzle/0020_admin_key_rotation.sql) keeps rotation
receipts and retired key hashes out of the history projection. Neither
adds a raw game or message source.
```sh
npm test -- admin-audit
@@ -76,7 +80,7 @@ sources under its five-term compound-query limit, with live role demotion and
filtered reads. Materialized groups keep the unified query within that limit.
Existing records cover successful operations only. Failed/rejected attempts,
key rotation and re-enable, full operational health/diagnostics,
backup/import/restore and the remaining participant support are still required.
Local/browser/source-bound evidence is separate from deployed acceptance, which
must identify the actual running revision and use authorized QA access.
full operational health/diagnostics, backup/import/restore and the
remaining participant support are still required. Local/browser/source-bound
evidence is separate from deployed acceptance, which must identify the
actual running revision and use authorized QA access.
+9 -7
View File
@@ -9,7 +9,7 @@ recovery and persistence systems where suitable.
| Area | Required behavior | Stage |
| --- | --- | --- |
| Administrator access | Personal access keys, server-enforced owner/operator/viewer roles, persistent eight-hour sessions and individual/all-session sign-out. [Owner-only account management](ADMIN_ACCOUNTS.md) adds a paginated directory, browser-created one-time keys, role changes, disabling, live authority and exact retries. A room host is not a site administrator. Key rotation/re-enable, failed-attempt history and further operational permissions remain. | Prototyped, partial |
| Administrator access | Personal access keys, server-enforced owner/operator/viewer roles, persistent eight-hour sessions and individual/all-session sign-out. [Owner-only account management](ADMIN_ACCOUNTS.md) adds a paginated directory, browser-created one-time keys, role changes, disabling and fresh-key rotation/re-enable with session revocation, live authority and exact retries. A room host is not a site administrator. Failed-attempt history and further operational permissions remain. | Prototyped, partial |
| Room directory | Search room codes/public player names, filter status/rules/availability, sort and paginate; show host, roster, modules, game change time, pause/join flags and bounded decision ownership. Detailed setup/shared-window ownership remains. | Prototyped |
| 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 |
@@ -53,12 +53,14 @@ Disabling an account invalidates its sessions; later re-enabling it cannot resto
them. The database refuses to disable, demote or delete the final enabled owner.
No default key, public account creation or host-to-administrator promotion exists.
[Manage accounts](ADMIN_ACCOUNTS.md) lets a current owner provision a personal
key, change another administrator’s role or disable its access. The exact
request and generated key remain tab-scoped through uncertain responses;
the server never returns a key. The owner reviews the target and reason
before submitting, and no game or seat is modified. Key rotation and
re-enabling disabled accounts are not available in this prototype.
[Manage accounts](ADMIN_ACCOUNTS.md) lets a current owner provision a
personal key, change another administrator’s role, disable access or
rotate another account’s key. Rotation also re-enables a disabled account
with a fresh key while invalidating every earlier key and session. The
exact request and generated key remain tab-scoped through uncertain
responses; the server never returns a key. The owner reviews the target,
current state, reason and revocation impact before submitting. No game
or seat is modified.
[Participant AI](ADMIN_PARTICIPANT_AI.md) lets owners/operators support an absent
human in a paused, started game. The player retains access and takeback controls;
+14 -9
View File
@@ -1,6 +1,6 @@
# Current development status
Updated 29 September 2026. **The full twelve-faction goal is unfinished. Basic
Updated 30 September 2026. **The full twelve-faction goal is unfinished. Basic
is playable and the six classic factions have a user-authorized Advanced preview.
Expansion starts and full rules/publication acceptance remain gated.**
@@ -34,14 +34,19 @@ arrival composition, natural assassination reveal coverage, normal
expansion starts or deployed acceptance.
The owner-only [administrator accounts](ADMIN_ACCOUNTS.md) prototype now
lists accounts, provisions a browser-generated one-time personal key, changes
another account’s role and disables access. Server actions repeat live owner
authority and target-version checks at commit, preserve exact tab-scoped
retries and attribute successful changes in action history without exposing
keys or saved games. Key rotation/re-enable, rejected-attempt history,
authenticated positive **network** HTTP on a dedicated isolated QA account, deployed
acceptance and the rest of full administration remain unfinished. This code
is not in the last verified live image.
lists, provisions, changes roles, disables and rotates another account’s
personal key. Rotation can re-enable a disabled target only with a fresh
key; old credentials and sessions remain revoked, including attempted
A→B→A reuse. Live owner/target fences, durable exact retries and safe
actor-attributed history preserve rooms and seats. A disposable owner
and target completed authenticated local **network** rotation and
re-enable on an isolated built-worker D1 store, and mobile browser review
showed exact impact and a superseded-replay warning. The supported dev
HTTP suite passes 55/55; the built-worker full suite hit intermittent
local workerd 503 restarts and is not a green production-bundle suite.
Failed-attempt history, the rest of administration, deployment and
production acceptance remain unfinished. This code is not in the last
verified live image.
The bounded [Advanced Moritani Assassinate Leaders preview](MORITANI_ASSASSINATE_LEADERS.md)
now has repeatable genuine games across two through six supported seats.
+36
View File
@@ -1,5 +1,41 @@
# Dune implementation status
## 30 September 2026 — owner key rotation and safe re-enable
The [account panel](ADMIN_ACCOUNTS.md) now rotates another enabled or
disabled administrator’s personal access key with one exact tab-scoped
request. A disabled target becomes enabled only with the new key; all
old sessions are revoked and historical hashes prevent A→B→A key reuse.
The owner reviews name, ID, role, enabled state, reason and revocation
impact; one-time key disclosure remains confined to the confirmed tab.
Additive migration `0020` preserves previous account receipts and games.
Live SQL owner/version/key checks, one durable rotation receipt and
actor-attributed safe audit prevent stale or duplicate changes.
Independent persistence review found no scoped issue. Security review
identified an exact historical replay that could show a superseded key
as currently usable. The client now compares its receipt with the
last-read account metadata, warns when changed/unlisted, and advises
sign-in verification even when matching. The focused security follow-up
found no remaining issue in those changed lines. A phone-width
authenticated local page showed exact pre-send confirmation; a
completed-record fixture showed the superseded warning without sending
a mutation.
The isolated built-worker network smoke provisioned, rotated, replayed,
disabled and re-enabled a disposable target, rejected old keys and
sessions, refused key reuse and preserved safe audit and origin denial.
No live account or saved game was modified.
`npm run check` passes typecheck, lint and 5,787/5,787 offline cases;
`npm run build`, `drizzle-kit check` and all 55/55 development-server
HTTP cases pass. A second full HTTP run against the local production
bundle did not pass: workerd intermittently returned 503 mid-request
under the suite. Draining oversized admin bodies removed an early
unread-stream return; isolated built requests returned 413 and later
requests remained healthy, but the full bundle load issue remains
unresolved. None of this proves a deployed revision, complete
administration or production recovery.
## 29 September 2026 — natural Advanced Moritani reveal and replacement
A source-unchanged genuine six-seat sample at base seed `20261307`
+22
View File
@@ -0,0 +1,22 @@
CREATE TABLE `admin_account_rotations` (
`operation_id` text PRIMARY KEY NOT NULL,
`actor_admin_id` text NOT NULL,
`request_hash` text NOT NULL,
`target_admin_id` text NOT NULL,
`name` text NOT NULL,
`role` text NOT NULL,
`previous_enabled` integer NOT NULL,
`enabled` integer NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`expected_updated_at` integer NOT NULL,
`reason` text NOT NULL,
`rotated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `admin_account_rotations_target` ON `admin_account_rotations` (`target_admin_id`,`rotated_at`);
--> statement-breakpoint
CREATE TABLE `admin_account_retired_keys` (
`key_hash` text PRIMARY KEY NOT NULL,
`admin_id` text NOT NULL
);
File diff suppressed because it is too large. Load diff
+7
View File
@@ -141,6 +141,13 @@
"when": 1790682568001,
"tag": "0019_admin_accounts",
"breakpoints": true
},
{
"idx": 20,
"version": "6",
"when": 1790682568002,
"tag": "0020_admin_key_rotation",
"breakpoints": true
}
]
}
+29 -11
View File
@@ -1,9 +1,22 @@
import { validAdminAccountInput, type AdminAccountInput, type AdminAccountResult, type AdminAccountRow } from './admin-accounts';
import { validAdminAccountInput, type AdminAccountInput, type AdminAccountResult, type AdminAccountRow, type AdminAccountsDirectory } from './admin-accounts';
type Storage = Pick<globalThis.Storage, 'getItem' | 'setItem' | 'removeItem'>;
export type AdminAccountRecord =
| { kind: 'pending'; input: AdminAccountInput }
| { kind: 'completed'; input: Extract<AdminAccountInput, { action: 'provision' }>; account: AdminAccountRow };
| { kind: 'completed'; input: Extract<AdminAccountInput, { action: 'provision' | 'rotate' }>; account: AdminAccountRow };
/** A receipt describes a past write. The directory can only confirm whether
* its account metadata still matched at the most recent read, not future key validity. */
export function adminAccountKeyStatus(
record: Extract<AdminAccountRecord, { kind: 'completed' }>,
directory: AdminAccountsDirectory | null,
): 'matching' | 'changed' | 'unverified' {
const live = directory?.accounts.find(row => row.id === record.account.id);
if (!live) return 'unverified';
return live.enabled && live.updatedAt === record.account.updatedAt &&
live.createdAt === record.account.createdAt && live.name === record.account.name &&
live.role === record.account.role ? 'matching' : 'changed';
}
const storageKey = (owner: string) => `dune.admin-accounts.v1:${owner}`;
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
@@ -36,20 +49,24 @@ export function validAdminAccountResult(value: unknown, input: AdminAccountInput
if (input.action === 'provision')
return account.enabled && account.name === input.name && account.role === input.role;
return account.updatedAt > input.expectedUpdatedAt &&
(input.action === 'role' ? account.enabled && account.role === input.role : !account.enabled);
(input.action === 'role' ? account.enabled && account.role === input.role :
input.action === 'rotate' ? account.enabled : !account.enabled);
}
const newSecret = () => Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, '0')).join('');
export function newAdminAccountRequest(fields:
| { action: 'provision'; name: string; role: AdminAccountRow['role']; reason: string }
| { action: 'role'; target: string; expectedUpdatedAt: number; role: AdminAccountRow['role']; reason: string }
| { action: 'disable'; target: string; expectedUpdatedAt: number; reason: string },
| { action: 'disable' | 'rotate'; target: string; expectedUpdatedAt: number; reason: string },
): AdminAccountInput {
const operationId = crypto.randomUUID();
const input = fields.action === 'provision' ? (() => {
const id = crypto.randomUUID();
const secret = Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, '0')).join('');
return { action: 'provision' as const, operationId, id, key: `dune-admin.${id}.${secret}`, name: fields.name.trim(), role: fields.role, reason: fields.reason.trim() };
})() : { ...fields, operationId, reason: fields.reason.trim() };
return { action: 'provision' as const, operationId, id, key: `dune-admin.${id}.${newSecret()}`, name: fields.name.trim(), role: fields.role, reason: fields.reason.trim() };
})() : fields.action === 'rotate'
? { ...fields, operationId, key: `dune-admin.${fields.target}.${newSecret()}`, reason: fields.reason.trim() }
: { ...fields, operationId, reason: fields.reason.trim() };
if (!validAdminAccountInput(input)) throw new Error('Check the account, role and operational reason. Do not include credentials in the reason.');
return input;
}
@@ -63,10 +80,10 @@ export function readAdminAccountRecord(storage: Storage, owner: string): AdminAc
const record = value as Record<string, unknown>;
if (Object.keys(record).length === 2 && record.kind === 'pending' && validAdminAccountInput(record.input)) return value as AdminAccountRecord;
if (Object.keys(record).length === 3 && record.kind === 'completed' && validAdminAccountInput(record.input) &&
record.input.action === 'provision' && validAdminAccountRow(record.account)) {
(record.input.action === 'provision' || record.input.action === 'rotate') && validAdminAccountRow(record.account)) {
const input = record.input, account = record.account;
if (account.id === input.id && account.name === input.name &&
account.role === input.role && account.enabled) return value as AdminAccountRecord;
if (validAdminAccountResult({ operationId: input.operationId, replayed: false, account }, input))
return value as AdminAccountRecord;
}
}
throw new Error('This tab’s saved account request is unreadable. Do not send a new request or discard this record until you have checked the account directory.');
@@ -91,7 +108,8 @@ export function completeAdminAccountRequest(storage: Storage, owner: string, inp
const previous = readAdminAccountRecord(storage, owner);
if (!previous || previous.kind !== 'pending' || JSON.stringify(previous.input) !== JSON.stringify(input))
throw new Error('The saved account request changed. Keep it and inspect the directory.');
if (input.action === 'provision') persist(storage, owner, { kind: 'completed', input, account: result.account });
if (input.action === 'provision' || input.action === 'rotate')
persist(storage, owner, { kind: 'completed', input, account: result.account });
else clearAdminAccountRecord(storage, owner);
}
+7 -1
View File
@@ -7,7 +7,8 @@ export type AdminAccountsDirectory = { accounts: AdminAccountRow[]; total: numbe
export type AdminAccountInput =
| { action: 'provision'; operationId: string; id: string; key: string; name: string; role: AdminRole; reason: string }
| { action: 'role'; operationId: string; target: string; expectedUpdatedAt: number; role: AdminRole; reason: string }
| { action: 'disable'; operationId: string; target: string; expectedUpdatedAt: number; reason: string };
| { action: 'disable'; operationId: string; target: string; expectedUpdatedAt: number; reason: string }
| { action: 'rotate'; operationId: string; target: string; expectedUpdatedAt: number; key: string; reason: string };
export type AdminAccountResult = { operationId: string; replayed: boolean; account: AdminAccountRow };
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
@@ -40,5 +41,10 @@ export function validAdminAccountInput(value: unknown): value is AdminAccountInp
if (input.action === 'disable')
return exact(input, ['action','operationId','target','expectedUpdatedAt','reason']) &&
typeof input.target === 'string' && uuid.test(input.target) && timestamp(input.expectedUpdatedAt);
if (input.action === 'rotate')
return exact(input, ['action','operationId','target','expectedUpdatedAt','key','reason']) &&
typeof input.target === 'string' && uuid.test(input.target) && timestamp(input.expectedUpdatedAt) &&
typeof input.key === 'string' && input.key === `dune-admin.${input.target}.${input.key.slice(-64)}` &&
/^dune-admin\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.[0-9a-f]{64}$/.test(input.key);
return false;
}
+2 -2
View File
@@ -4,14 +4,14 @@ export const AUDIT_CATEGORIES = {
} as const;
export type AuditCategory = keyof typeof AUDIT_CATEGORIES;
export const AUDIT_ACTIONS = {
provision: 'Provision administrator', revoke: 'Revoke administrator', role: 'Change administrator role', login: 'Sign in', logout: 'Sign out', logout_all: 'Sign out everywhere',
provision: 'Provision administrator', revoke: 'Revoke administrator', role: 'Change administrator role', rotate: 'Rotate administrator key', login: 'Sign in', logout: 'Sign out', logout_all: 'Sign out everywhere',
room_control: 'Apply room controls', create: 'Create room', rules: 'Change rules', techTokens: 'Change Tech Tokens', strongholdCards: 'Change Stronghold Cards',
addBot: 'Add AI player', removeBot: 'Remove AI player', assignHost: 'Assign host', configureBot: 'Configure AI player',
remove: 'Remove room', restore: 'Restore room', close: 'Close room', reopen: 'Reopen room', archive: 'Archive room', unarchive: 'Unarchive room',
participant_ai: 'Enable participant AI', mute: 'Mute discussion', unmute: 'Unmute discussion', unknown: 'Unrecognized recorded action',
} as const;
export type AuditAction = keyof typeof AUDIT_ACTIONS;
export const AUDIT_FIELDS = ['Role','Rules','Tech Tokens','Stronghold Cards','Host seat','Host faction','AI seats','Player count','Paused','Joining locked','Removed','Closed','Archived','Game version','Setting revision','Difficulty','Discussion muted','Player circle'] as const;
export const AUDIT_FIELDS = ['Role','Enabled','Rules','Tech Tokens','Stronghold Cards','Host seat','Host faction','AI seats','Player count','Paused','Joining locked','Removed','Closed','Archived','Game version','Setting revision','Difficulty','Discussion muted','Player circle'] as const;
export type AuditChange = { field: typeof AUDIT_FIELDS[number]; before: string | null; after: string | null };
export type AdminAuditEvent = {
id: string; category: AuditCategory; action: AuditAction; createdAt: number | null;
+3 -2
View File
@@ -22,9 +22,10 @@ export async function adminBody(request: Request, configuredOrigin?: string): Pr
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > 2048) { void reader.cancel(); throw new AdminError('Request too large.', 413); }
chunks.push(value);
// Drain the request without retaining bytes beyond the limit.
if (size <= 2048) chunks.push(value);
}
if (size > 2048) throw new AdminError('Request too large.', 413);
const bytes = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
+64 -2
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { register } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { clearAdminAccountRecord, completeAdminAccountRequest, newAdminAccountRequest, readAdminAccountRecord, saveAdminAccountRequest, validAdminAccountResult } from '../lib/admin-accounts-client';
import { adminAccountKeyStatus, clearAdminAccountRecord, completeAdminAccountRequest, newAdminAccountRequest, readAdminAccountRecord, saveAdminAccountRequest, validAdminAccountResult } from '../lib/admin-accounts-client';
import type { AdminAccountRow } from '../lib/admin-accounts';
function storage() {
@@ -20,7 +20,7 @@ void test('SSR exposes no account directory or private key before owner session
const { default: AccountsPage } = await import('../app/admin/accounts/page');
const html = renderToStaticMarkup(createElement(AccountsPage));
assert.match(html, /Checking owner access and account directory/);
assert.doesNotMatch(html, /One-time account access key|Change role ·|Disable ·|dune-admin\./);
assert.doesNotMatch(html, /One-time account access key|Change role ·|Disable ·|Rotate key ·|accounts-private-key|dune-admin\./);
});
void test('provisioned key is generated only in the browser, saved exactly before sending, and recoverable from its original tab', () => {
@@ -79,6 +79,68 @@ void test('role and disable requests retain exact target version and reason unti
}
});
void test('disabled-account rotation keeps one fresh target-bound key through exact replay and local recovery', () => {
const owner = crypto.randomUUID(), target = crypto.randomUUID(), tab = storage();
const disabled = { ...account(target, 'owner'), enabled: false };
const input = newAdminAccountRequest({ action: 'rotate', target, expectedUpdatedAt: disabled.updatedAt, reason: ' Replace compromised key ' });
const next = newAdminAccountRequest({ action: 'rotate', target, expectedUpdatedAt: disabled.updatedAt, reason: 'Replace compromised key' });
assert.equal(input.action, 'rotate');
assert.equal(next.action, 'rotate');
if (input.action !== 'rotate' || next.action !== 'rotate') return;
assert.equal(input.reason, 'Replace compromised key');
assert.equal(new RegExp(`^dune-admin\\.${target}\\.[0-9a-f]{64}$`).test(input.key), true);
assert.equal(input.key === next.key, false);
assert.notEqual(input.operationId, next.operationId);
saveAdminAccountRequest(tab, owner, input);
const pending = readAdminAccountRecord(tab, owner);
assert.equal(pending?.kind, 'pending');
assert.equal(pending?.input.operationId, input.operationId);
assert.equal(pending?.input.action === 'rotate' && pending.input.key === input.key, true);
assert.throws(() => saveAdminAccountRequest(tab, owner, next));
const original = { ...disabled, enabled: true, updatedAt: disabled.updatedAt + 1 };
const receipt = { operationId: input.operationId, replayed: true, account: original };
for (const badAccount of [
{ ...original, id: crypto.randomUUID() },
{ ...original, enabled: false },
{ ...original, updatedAt: input.expectedUpdatedAt },
]) {
assert.equal(validAdminAccountResult({ ...receipt, account: badAccount }, input), false);
assert.throws(() => completeAdminAccountRequest(tab, owner, input, { ...receipt, account: badAccount }));
assert.equal(readAdminAccountRecord(tab, owner)?.kind, 'pending');
}
assert.equal(validAdminAccountResult({ ...receipt, account: { ...original, key: 'returned-secret' } }, input), false);
completeAdminAccountRequest(tab, owner, input, receipt);
const completed = readAdminAccountRecord(tab, owner);
assert.equal(completed?.kind === 'completed' && completed.input.action === 'rotate' &&
completed.input.key === input.key && completed.account.updatedAt === original.updatedAt, true);
assert.throws(() => saveAdminAccountRequest(tab, owner, next));
const storageKey = [...tab.values.keys()][0], savedRecord = tab.values.get(storageKey)!;
tab.values.set(storageKey, JSON.stringify({ kind: 'completed', input: { ...input, target: crypto.randomUUID() }, account: original }));
assert.throws(() => readAdminAccountRecord(tab, owner));
tab.values.set(storageKey, JSON.stringify({ kind: 'completed', input, account: { ...original, enabled: false } }));
assert.throws(() => readAdminAccountRecord(tab, owner));
tab.values.set(storageKey, savedRecord);
clearAdminAccountRecord(tab, owner);
assert.equal(readAdminAccountRecord(tab, owner), null);
});
void test('a historic exact replay never presents its superseded key as current access', () => {
const owner = crypto.randomUUID(), target = crypto.randomUUID(), tab = storage();
const input = newAdminAccountRequest({ action: 'rotate', target, expectedUpdatedAt: 1700000000000, reason: 'Staff handover' });
if (input.action !== 'rotate') throw new Error('Expected a rotation request.');
const receipt = { operationId: input.operationId, replayed: true,
account: { ...account(target), updatedAt: input.expectedUpdatedAt + 1 } };
saveAdminAccountRequest(tab, owner, input);
completeAdminAccountRequest(tab, owner, input, receipt);
const completed = readAdminAccountRecord(tab, owner);
if (completed?.kind !== 'completed') throw new Error('Expected a retained one-time key.');
const directory = { accounts: [receipt.account], total: 1, page: 1, pageSize: 25 as const };
assert.equal(adminAccountKeyStatus(completed, directory), 'matching');
assert.equal(adminAccountKeyStatus(completed, { ...directory, accounts: [{ ...receipt.account, updatedAt: receipt.account.updatedAt + 1 }] }), 'changed');
assert.equal(adminAccountKeyStatus(completed, { ...directory, accounts: [{ ...receipt.account, enabled: false }] }), 'changed');
assert.equal(adminAccountKeyStatus(completed, { ...directory, accounts: [] }), 'unverified');
});
void test('unavailable, corrupt or changed storage fails closed and never replaces an uncertain credential', () => {
const tab = storage(), owner = crypto.randomUUID();
const input = newAdminAccountRequest({ action: 'provision', name: 'QA colleague', role: 'viewer', reason: 'QA' });
+136 -1
View File
@@ -21,6 +21,11 @@ function newProvision(role: 'owner'|'operator'|'viewer' = 'viewer'): Extract<Adm
const id = randomUUID();
return { action:'provision',operationId:randomUUID(),id,key:`dune-admin.${id}.${randomBytes(32).toString('hex')}`,name:'New administrator',role,reason:'Staff rotation' };
}
function newRotation(target: string, expectedUpdatedAt = 1000): Extract<AdminAccountInput,{action:'rotate'}> {
return { action:'rotate',operationId:randomUUID(),target,expectedUpdatedAt,
key:`dune-admin.${target}.${randomBytes(32).toString('hex')}`,reason:'Replace compromised credential' };
}
void test('account receipt migration adds metadata without changing existing games or credentials',() => {
const sqlite=new DatabaseSync(':memory:');
@@ -45,9 +50,18 @@ void test('account receipt migration adds metadata without changing existing gam
]);
const before=saved();
sqlite.exec(readFileSync(new URL('../drizzle/0019_admin_accounts.sql',import.meta.url),'utf8'));
const operation=randomUUID();
sqlite.prepare(`INSERT INTO admin_account_operations
(operation_id,actor_admin_id,request_hash,target_admin_id,action,name,role,enabled,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?)`).run(operation,id,'a'.repeat(64),id,'role','Original owner','owner',1,1000,1000);
const previousReceipt=sqlite.prepare('SELECT * FROM admin_account_operations WHERE operation_id=?').get(operation);
sqlite.exec(readFileSync(new URL('../drizzle/0020_admin_key_rotation.sql',import.meta.url),'utf8'));
assert.deepEqual(sqlite.prepare('SELECT * FROM admin_account_operations WHERE operation_id=?').get(operation),previousReceipt);
assert.equal(sqlite.prepare('SELECT COUNT(*) n FROM admin_account_rotations').get()!.n,0);
assert.equal(sqlite.prepare('SELECT COUNT(*) n FROM admin_account_retired_keys').get()!.n,0);
assert.equal(saved(),before);
assert.equal(sqlite.prepare('SELECT reason FROM admin_audit WHERE target_admin_id=?').get(id)!.reason,null);
assert.equal(sqlite.prepare('SELECT COUNT(*) n FROM admin_account_operations').get()!.n,0);
assert.equal(sqlite.prepare('SELECT COUNT(*) n FROM admin_account_operations').get()!.n,1);
assert.throws(()=>sqlite.prepare('UPDATE admin_accounts SET enabled=0 WHERE id=?').run(id));
assert.equal(saved(),before);
} finally {sqlite.close();}
@@ -194,6 +208,113 @@ void test('logout-all cannot roll back an account version and admit a stale disa
assert.equal(f.sqlite.prepare('SELECT enabled FROM admin_accounts WHERE id=?').get(f.target.id)!.enabled,1);
} finally {f.sqlite.close();}
});
void test('rotating an enabled administrator invalidates old keys and sessions while preserving account and game state',async () => {
const f=await fixture();
try {
f.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)').run('ROTATE01','{"private":"saved"}',3,1000);
f.sqlite.prepare('INSERT INTO seats(token_hash,revoked,room_code,player_id) VALUES(?,?,?,?)').run('e'.repeat(64),0,'ROTATE01','player');
const saved=JSON.stringify([f.sqlite.prepare('SELECT * FROM rooms').all(),f.sqlite.prepare('SELECT * FROM seats').all()]);
const oldSession=await adminLogin(f.database,f.target.key,now);
const input=newRotation(f.target.id), result=await applyAdminAccount(f.database,f.identity,input,now);
assert.equal(result.replayed,false);
assert.deepEqual({id:result.account.id,name:result.account.name,role:result.account.role,enabled:result.account.enabled},
{id:f.target.id,name:f.target.name,role:f.target.role,enabled:true});
assert.ok(result.account.updatedAt>1000);
const row=f.sqlite.prepare('SELECT key_hash,session_generation,updated_at FROM admin_accounts WHERE id=?').get(f.target.id)!;
assert.equal(row.key_hash,sha256(input.key));assert.equal(row.session_generation,1);
assert.equal(row.updated_at,result.account.updatedAt);
assert.ok(f.sqlite.prepare('SELECT revoked_at FROM admin_sessions WHERE token_hash=?').get(sha256(oldSession.token))!.revoked_at);
await assert.rejects(adminLogin(f.database,f.target.key,now),denied(401));
await assert.rejects(requireAdmin(f.database,oldSession.token,undefined,now),denied(401));
const fresh=await adminLogin(f.database,input.key,now);
assert.equal((await requireAdmin(f.database,fresh.token,undefined,now)).id,f.target.id);
const audit=f.sqlite.prepare("SELECT action,actor_admin_id,detail,reason FROM admin_audit WHERE target_admin_id=? AND action='rotate'").all(f.target.id);
assert.equal(audit.length,1);
assert.equal(audit[0].action,'rotate');
assert.equal(audit[0].actor_admin_id,f.owner.id);
assert.equal(audit[0].reason,input.reason);
assert.deepEqual(JSON.parse(String(audit[0].detail)),{previousEnabled:1,enabled:1});
assert.deepEqual((await readAdminAudit(f.database,f.identity,new URLSearchParams('category=account'),now))
.events.find(event=>event.action==='rotate' && event.targetAdminId===f.target.id)?.changes,[{field:'Enabled',before:'Yes',after:'Yes'}]);
assert.equal(f.sqlite.prepare('SELECT admin_id FROM admin_account_retired_keys WHERE key_hash=?').get(sha256(f.target.key))!.admin_id,f.target.id);
assert.deepEqual(await applyAdminAccount(f.database,f.identity,input,now),{...result,replayed:true});
const second=newRotation(f.target.id,result.account.updatedAt);
const rotatedAgain=await applyAdminAccount(f.database,f.identity,second,now);
const revive={...newRotation(f.target.id,rotatedAgain.account.updatedAt),key:f.target.key};
await assert.rejects(applyAdminAccount(f.database,f.identity,revive,now),denied(409));
assert.equal(f.sqlite.prepare('SELECT key_hash FROM admin_accounts WHERE id=?').get(f.target.id)!.key_hash,sha256(second.key));
await assert.rejects(adminLogin(f.database,f.target.key,now),denied(401));
await assert.rejects(requireAdmin(f.database,fresh.token,undefined,now),denied(401));
assert.deepEqual(await applyAdminAccount(f.database,f.identity,input,now),{...result,replayed:true});
assert.equal(f.sqlite.prepare('SELECT COUNT(*) n FROM admin_account_retired_keys WHERE admin_id=?').get(f.target.id)!.n,2);
assert.equal(JSON.stringify([f.sqlite.prepare('SELECT * FROM rooms').all(),f.sqlite.prepare('SELECT * FROM seats').all()]),saved);
const persisted=JSON.stringify([f.sqlite.prepare('SELECT * FROM admin_account_rotations').all(),audit,result]);
assert.equal(persisted.includes(input.key),false);assert.equal(persisted.includes(sha256(input.key)),false);
} finally {f.sqlite.close();}
});
void test('disabled administrators return only with a fresh key, and replay retains original metadata after later changes',async () => {
const f=await fixture();
try {
const disable:AdminAccountInput={action:'disable',operationId:randomUUID(),target:f.target.id,expectedUpdatedAt:1000,reason:'Disable access'};
const stopped=await applyAdminAccount(f.database,f.identity,disable,now);
await assert.rejects(adminLogin(f.database,f.target.key,now),denied(401));
const reuse={...newRotation(f.target.id,stopped.account.updatedAt),key:f.target.key};
await assert.rejects(applyAdminAccount(f.database,f.identity,reuse,now),denied(409));
assert.equal(f.sqlite.prepare('SELECT enabled FROM admin_accounts WHERE id=?').get(f.target.id)!.enabled,0);
const rotate=newRotation(f.target.id,stopped.account.updatedAt);
const restored=await applyAdminAccount(f.database,f.identity,rotate,now);
assert.equal(restored.account.enabled,true);assert.ok(restored.account.updatedAt>stopped.account.updatedAt);
await assert.rejects(adminLogin(f.database,f.target.key,now),denied(401));
assert.equal((await adminLogin(f.database,rotate.key,now)).admin.id,f.target.id);
const audit=(await readAdminAudit(f.database,f.identity,new URLSearchParams('category=account'),now)).events.find(event=>event.action==='rotate' && event.targetAdminId===f.target.id);
assert.deepEqual(audit?.changes,[{field:'Enabled',before:'No',after:'Yes'}]);assert.equal(audit?.reason,rotate.reason);
const role:AdminAccountInput={action:'role',operationId:randomUUID(),target:f.target.id,expectedUpdatedAt:restored.account.updatedAt,role:'operator',reason:'New duty'};
await applyAdminAccount(f.database,f.identity,role,now);
assert.deepEqual(await applyAdminAccount(f.database,f.identity,rotate,now),{...restored,replayed:true});
assert.equal(f.sqlite.prepare('SELECT role FROM admin_accounts WHERE id=?').get(f.target.id)!.role,'operator');
assert.equal(f.sqlite.prepare('SELECT COUNT(*) n FROM admin_audit WHERE target_admin_id=? AND action=?').get(f.target.id,'rotate')!.n,1);
} finally {f.sqlite.close();}
});
void test('rotation rejects stale versions, simultaneous target changes, other owners and reused operation IDs',async () => {
const f=await fixture();
try {
const original=newRotation(f.target.id);
for(const bad of [{...original,key:f.owner.key},{...original,key:original.key.toUpperCase()},
{...original,key:original.key+'\\n'},{...original,reason:original.key},{...original,unknown:true}])
assert.equal(validAdminAccountInput(bad),false);
assert.equal(validAdminAccountInput(original),true);
const self=newRotation(f.owner.id);
await assert.rejects(applyAdminAccount(f.database,f.identity,self,now),denied(409));
const winner=await applyAdminAccount(f.database,f.identity,original,now);
const stale=newRotation(f.target.id);
await assert.rejects(applyAdminAccount(f.database,f.identity,stale,now),denied(409));
await assert.rejects(applyAdminAccount(f.database,f.identity,{...original,key:stale.key},now),denied(409));
await assert.rejects(applyAdminAccount(f.database,f.identity,{...original,reason:'Changed reason'},now),denied(409));
const another=f.provision('owner'), login=await adminLogin(f.database,another.key,now);
const other=await requireAdmin(f.database,login.token,['owner'],now);
await assert.rejects(applyAdminAccount(f.database,other,original,now),denied(409));
const cross:AdminAccountInput={action:'role',operationId:original.operationId,target:f.target.id,
expectedUpdatedAt:winner.account.updatedAt,role:'operator',reason:'Cross-table operation ID'};
await assert.rejects(applyAdminAccount(f.database,f.identity,cross,now),denied(409));
const before=Number(f.sqlite.prepare('SELECT COUNT(*) n FROM admin_account_rotations').get()!.n);
const race=newRotation(f.target.id,winner.account.updatedAt);
f.hooks.beforeBatch=async()=>{f.hooks.beforeBatch=async()=>{
delete f.hooks.beforeBatch;f.sqlite.prepare('UPDATE admin_accounts SET updated_at=updated_at+1 WHERE id=?').run(f.target.id);
};};
await assert.rejects(applyAdminAccount(f.database,f.identity,race,now),denied(409));
assert.equal(f.sqlite.prepare('SELECT COUNT(*) n FROM admin_account_rotations').get()!.n,before);
const expired=newRotation(f.target.id,Number(f.sqlite.prepare('SELECT updated_at FROM admin_accounts WHERE id=?').get(f.target.id)!.updated_at));
f.hooks.beforeBatch=async()=>{f.hooks.beforeBatch=async()=>{
delete f.hooks.beforeBatch;f.sqlite.prepare('UPDATE admin_sessions SET revoked_at=1 WHERE admin_id=?').run(f.owner.id);
};};
await assert.rejects(applyAdminAccount(f.database,f.identity,expired,now),denied(401));
assert.equal(f.sqlite.prepare('SELECT COUNT(*) n FROM admin_account_rotations').get()!.n,before);
await assert.rejects(applyAdminAccount(f.database,f.identity,original,now),denied(401));
} finally {f.sqlite.close();}
});
void test('workerd D1 applies provision, exact retry and revocation in transactional batches',async () => {
const { Miniflare }=await import('miniflare');
@@ -226,6 +347,20 @@ void test('workerd D1 applies provision, exact retry and revocation in transacti
const result=await applyAdminAccount(database,f.identity,disable,now);
assert.equal(result.account.enabled,false);
assert.equal((await db.prepare('SELECT COUNT(*) n FROM admin_audit WHERE target_admin_id=?').bind(input.id).first<{n:number}>())?.n,2);
const rotation=newRotation(input.id,result.account.updatedAt);
const restored=await applyAdminAccount(database,f.identity,rotation,now);
assert.equal(restored.account.enabled,true);
assert.equal((await adminLogin(database,rotation.key,now)).admin.id,input.id);
await assert.rejects(adminLogin(database,input.key,now),denied(401));
const competingA=newRotation(input.id,restored.account.updatedAt),competingB=newRotation(input.id,restored.account.updatedAt);
const outcomes=await Promise.allSettled([
applyAdminAccount(database,f.identity,competingA,now),
applyAdminAccount(database,f.identity,competingB,now),
]);
assert.equal(outcomes.filter(outcome=>outcome.status==='fulfilled').length,1);
assert.equal(outcomes.filter(outcome=>outcome.status==='rejected' && denied(409)(outcome.reason)).length,1);
assert.deepEqual(await applyAdminAccount(database,f.identity,rotation,now),{...restored,replayed:true});
assert.equal((await db.prepare("SELECT COUNT(*) n FROM admin_audit WHERE target_admin_id=? AND action='rotate'").bind(input.id).first<{n:number}>())?.n,2);
await assert.rejects(adminLogin(database,input.key,now),denied(401));
} finally {await worker.dispose();f.sqlite.close();}
});
+10
View File
@@ -27,6 +27,16 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden
assert.equal(account.status, 401);
assert.equal(account.headers.get('set-cookie'), null);
assert.deepEqual(Object.keys(await account.json()), ['error']);
const target=crypto.randomUUID();
const rotate=await fetch(base + '/api/admin/accounts', {
method: 'POST',headers:{ ...headers,origin:base,'Content-Type':'application/json' },
body:JSON.stringify({ action:'rotate',operationId:crypto.randomUUID(),target,expectedUpdatedAt:1000,
key:`dune-admin.${target}.${'a'.repeat(64)}`,reason:'Denied rotation request' }),
signal:AbortSignal.timeout(15000),
});
assert.equal(rotate.status,401);
assert.equal(rotate.headers.get('set-cookie'),null);
assert.deepEqual(Object.keys(await rotate.json()),['error']);
const write = await fetch(`${base}/api/admin/rooms/${room}/control`, {
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' },
body: JSON.stringify({ operationId: crypto.randomUUID(), expectedRevision: 0, paused: true, joinLocked: true, reason: 'Denied QA request' }),