Prototype owner-only administrator account management
This commit is contained in:
1 parent
ac8742c829
commit
882d006b3c
24 files changed
+3438
-15
No files matched your search
@@ -46,6 +46,9 @@ prints a newly generated initial owner access key in its log only if there are
|
||||
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.
|
||||
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
|
||||
@@ -59,8 +62,10 @@ retaining their tables and history; unarchiving leaves play closed.
|
||||
[Participant AI](docs/ADMIN_PARTICIPANT_AI.md) supports an absent human in a paused
|
||||
game while preserving their seat and takeback control.
|
||||
[Discussion controls](docs/ADMIN_DISCUSSION.md) mute or unmute new messages from
|
||||
a human seat while preserving history and gameplay. Permanent deletion, further
|
||||
participant support and backup tools are still being built.
|
||||
a human seat while preserving history and gameplay.
|
||||
[Owner-only backups](docs/ADMIN_BACKUPS.md) capture and download private room
|
||||
snapshots without changing play. Import/restore, permanent deletion and further
|
||||
participant support remain unfinished.
|
||||
[Action history](docs/ADMIN_ACTION_HISTORY.md) searches recorded admin operations,
|
||||
with role-limited reasons and public before/after settings.
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
.admin-accounts { max-width: 1060px; }
|
||||
.admin-accounts .admin-room-control { max-width: none; }
|
||||
.admin-accounts .admin-room-control > label.admin-check { display: flex; align-items: center; gap: 12px; margin: 16px 0; }
|
||||
.admin-accounts .admin-check input { width: 22px; height: 22px; min-height: 22px; flex: none; accent-color: var(--primary); }
|
||||
.admin-accounts .admin-room-control > label:not(.admin-check) { margin: 14px 0 8px; }
|
||||
.accounts-review { display: grid; grid-template-columns: minmax(100px, max-content) minmax(0, 1fr); gap: 8px 20px; overflow-wrap: anywhere; }
|
||||
.accounts-review dt { color: #b7bbaf; }
|
||||
.accounts-review dd { margin: 0; }
|
||||
.accounts-actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
||||
.accounts-list { list-style: none; padding: 0; display: grid; gap: 12px; }
|
||||
.accounts-list li { border: 1px solid var(--border); padding: 16px; display: flex; align-items: center; justify-content: space-between; gap: 20px; overflow-wrap: anywhere; }
|
||||
.accounts-list li p { margin: 8px 0 0; }
|
||||
.accounts-id { font-family: monospace; }
|
||||
#accounts-private-key { font-family: monospace; overflow-wrap: anywhere; }
|
||||
@media (max-width: 700px) {
|
||||
.accounts-list li { flex-direction: column; align-items: stretch; }
|
||||
.accounts-actions { flex-direction: column; align-items: stretch; }
|
||||
.accounts-review { grid-template-columns: minmax(0, 1fr); }
|
||||
.accounts-review dd { margin-bottom: 8px; }
|
||||
#accounts-private-key { font-size: 0.85rem; }
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
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 '../admin.css';
|
||||
import './accounts.css';
|
||||
|
||||
type SessionAccount = { id: string; name: string; role: 'owner' | 'operator' | 'viewer' };
|
||||
type Mutation = 'provision' | 'role' | 'disable';
|
||||
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);
|
||||
|
||||
export default function AccountsPage() {
|
||||
const [owner, setOwner] = useState<SessionAccount | null>(null);
|
||||
const [directory, setDirectory] = useState<AdminAccountsDirectory | null>(null);
|
||||
const [record, setRecord] = useState<AdminAccountRecord | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [role, setRole] = useState<AdminAccountRow['role']>('viewer');
|
||||
const [reason, setReason] = useState('');
|
||||
const [action, setAction] = useState<Mutation>('provision');
|
||||
const [target, setTarget] = useState<AdminAccountRow | null>(null);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [storageProblem, setStorageProblem] = useState(false);
|
||||
const [stale, setStale] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const inFlight = useRef(false);
|
||||
const mounted = useRef(false);
|
||||
|
||||
const loseAccess = useCallback(() => {
|
||||
setOwner(null); setDirectory(null); setRecord(null); setTarget(null); setConfirmed(false);
|
||||
setNotice('Owner access changed or expired. Sign in to administration again. Any saved request remains in this tab for its original owner.');
|
||||
}, []);
|
||||
|
||||
const loadDirectory = useCallback(async (account: SessionAccount, page = 1) => {
|
||||
const data = await requestJson<AdminAccountsDirectory>(`/api/admin/accounts?page=${page}`, {
|
||||
cache: 'no-store', headers: { 'X-Dune-Admin-Id': account.id },
|
||||
});
|
||||
if (mounted.current) setDirectory(data);
|
||||
return data;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
let canceled = false;
|
||||
requestJson<{ admin: SessionAccount }>('/api/admin/session', { cache: 'no-store' })
|
||||
.then(async ({ admin }) => {
|
||||
if (canceled) return;
|
||||
if (admin.role !== 'owner') { loseAccess(); setNotice('Only an owner can manage administrator accounts.'); return; }
|
||||
await loadDirectory(admin);
|
||||
if (canceled) return;
|
||||
let saved: AdminAccountRecord | null;
|
||||
try { saved = readAdminAccountRecord(window.sessionStorage, admin.id); }
|
||||
catch (error) { setOwner(admin); setStorageProblem(true); setNotice(message(error)); return; }
|
||||
setOwner(admin); setRecord(saved); setStorageProblem(false); setStale(false); setConfirmed(false);
|
||||
setNotice(saved?.kind === 'pending' ? 'This tab has an unconfirmed account request. Review and retry the exact saved request; do not generate another.' : '');
|
||||
})
|
||||
.catch(error => {
|
||||
if (canceled) return;
|
||||
setRecord(null); setOwner(null); setDirectory(null);
|
||||
setNotice(denied(error) ? 'Owner access changed or expired. Sign in to administration again.' : message(error));
|
||||
})
|
||||
.finally(() => { if (!canceled) setLoading(false); });
|
||||
return () => { canceled = true; mounted.current = false; };
|
||||
}, [refresh, loadDirectory, loseAccess]);
|
||||
useEffect(() => {
|
||||
const revalidate = () => {
|
||||
if (document.visibilityState === 'hidden') return;
|
||||
setLoading(true); setOwner(null); setDirectory(null); setRecord(null); setConfirmed(false);
|
||||
setRefresh(value => value + 1);
|
||||
};
|
||||
window.addEventListener('focus', revalidate);
|
||||
window.addEventListener('pageshow', revalidate);
|
||||
document.addEventListener('visibilitychange', revalidate);
|
||||
return () => {
|
||||
window.removeEventListener('focus', revalidate);
|
||||
window.removeEventListener('pageshow', revalidate);
|
||||
document.removeEventListener('visibilitychange', revalidate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function refreshAccounts(page = directory?.page ?? 1) {
|
||||
if (!owner || inFlight.current) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await loadDirectory(owner, page);
|
||||
setTarget(null); setConfirmed(false); if (!record) setStale(false); setNotice('Account directory refreshed. Review current values before making another change.');
|
||||
} catch (error) { if (denied(error)) loseAccess(); else setNotice(message(error)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
function startReview(event: React.SyntheticEvent<HTMLFormElement, SubmitEvent>) {
|
||||
event.preventDefault();
|
||||
if (!owner || !directory || storageProblem || record || inFlight.current || busy) return;
|
||||
try {
|
||||
const input = action === 'provision'
|
||||
? newAdminAccountRequest({ action, name, role, reason })
|
||||
: target && target.enabled && target.id !== owner.id
|
||||
? action === 'role'
|
||||
? newAdminAccountRequest({ action, target: target.id, expectedUpdatedAt: target.updatedAt, role, reason })
|
||||
: newAdminAccountRequest({ action, target: target.id, expectedUpdatedAt: target.updatedAt, reason })
|
||||
: null;
|
||||
if (!input) throw new Error('Choose a current, enabled account other than your own.');
|
||||
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.');
|
||||
} catch (error) { setNotice(message(error)); }
|
||||
}
|
||||
|
||||
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;
|
||||
inFlight.current = true; setBusy(true); setConfirmed(false);
|
||||
try {
|
||||
// A restored tab must not submit under another or a demoted account.
|
||||
const session = await requestJson<{ admin: SessionAccount }>('/api/admin/session', { cache: 'no-store' });
|
||||
if (session.admin.id !== owner.id || session.admin.role !== 'owner') { loseAccess(); return; }
|
||||
saveAdminAccountRequest(window.sessionStorage, owner.id, input);
|
||||
const result = await requestJson<AdminAccountResult>('/api/admin/accounts', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': owner.id }, body: JSON.stringify(input),
|
||||
});
|
||||
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);
|
||||
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); }
|
||||
catch (error) { if (denied(error)) loseAccess(); else setNotice(`Account operation confirmed, but refresh failed: ${message(error)}`); }
|
||||
} catch (error) {
|
||||
if (!mounted.current) return;
|
||||
if (denied(error)) { loseAccess(); return; }
|
||||
if (error instanceof ClientRequestError && error.status === 409) {
|
||||
setStale(true); setTarget(null);
|
||||
try { await loadDirectory(owner, directory?.page ?? 1); }
|
||||
catch (readError) { if (denied(readError)) { loseAccess(); return; } }
|
||||
setNotice('The account request conflicted with current access or a newer account version. Review the refreshed directory before discarding the saved request; no new request can be sent yet.');
|
||||
} else setNotice(requestMayHaveCompleted(error)
|
||||
? `${message(error)} The change may have completed. Confirm the exact saved request by retrying; do not make a new one.` : message(error));
|
||||
} finally { inFlight.current = false; if (mounted.current) setBusy(false); }
|
||||
}
|
||||
|
||||
function discard() {
|
||||
if (!owner || busy || !directory || !confirmed) return;
|
||||
try {
|
||||
clearAdminAccountRecord(window.sessionStorage, owner.id);
|
||||
setRecord(null); setStorageProblem(false); setStale(false); setConfirmed(false);
|
||||
setNotice('This tab’s local record was cleared. No existing account was changed by clearing it.');
|
||||
} catch (error) { setNotice(message(error)); }
|
||||
}
|
||||
|
||||
function select(row: AdminAccountRow, next: Mutation) {
|
||||
if (!owner || row.id === owner.id || !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 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>
|
||||
{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>
|
||||
<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></>}
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>}
|
||||
<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>
|
||||
</form>
|
||||
</section>}
|
||||
<section aria-labelledby="accounts-directory"><div className="admin-section-heading"><h2 id="accounts-directory">Account directory</h2><div className="accounts-actions"><Button variant="outline" disabled={busy || !!record || storageProblem} onClick={() => { setAction('provision'); setTarget(null); setRole('viewer'); setReason(''); setConfirmed(false); }}>Provision account</Button><Button variant="outline" disabled={busy} onClick={() => void refreshAccounts()}>Refresh accounts</Button></div></div>
|
||||
{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>
|
||||
{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>}
|
||||
</section>
|
||||
</>}
|
||||
</main>;
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export default function ActionHistory() {
|
||||
<span>Page {result.page} of {Math.max(1,Math.ceil(result.total/result.pageSize))}</span>
|
||||
{result.page*result.pageSize < result.total && <a className="admin-room-link" href={link(result.page+1)}>Next page</a>}
|
||||
</nav>
|
||||
<p className="admin-secondary">Times use your device’s time zone; date filters use UTC. Names reflect current administrator accounts. External provisioning and revocation records do not identify the person who ran the database operation. Rejected attempts are not yet recorded here. New actions may move records between pages.</p>
|
||||
<p className="admin-secondary">Times use your device’s time zone; date filters use UTC. Names reflect current administrator accounts. External CLI account operations may lack actor attribution; web-managed account changes record the acting owner. Rejected attempts are not yet recorded here. New actions may move records between pages.</p>
|
||||
</section>}
|
||||
</>}
|
||||
</main>;
|
||||
|
||||
@@ -126,6 +126,7 @@ export default function Administration() {
|
||||
<div className="admin-room-actions"><a className="admin-room-link" href="/admin/audit">Action history</a>{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>}
|
||||
{account.role === 'owner' && <a className="admin-room-link" href="/admin/operations">Operations</a>}
|
||||
{account.role === 'owner' && <a className="admin-room-link" href="/admin/accounts">Accounts</a>}
|
||||
<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}
|
||||
onClose={() => setCreatingRoom(false)} onDenied={accessChanged} onBusyChange={setOperationBusy}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { AdminError, requireAdmin } from '@/db/admin-access';
|
||||
import { applyAdminAccount, readAdminAccounts } from '@/db/admin-accounts';
|
||||
import { adminBody, adminFailure, adminResponse, adminToken } from '@/lib/admin-http';
|
||||
import { validAdminAccountInput } from '@/lib/admin-accounts';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const identity = await requireAdmin(env.DB, adminToken(request), ['owner']);
|
||||
const expected = request.headers.get('X-Dune-Admin-Id');
|
||||
if (expected && expected !== identity.id) throw new AdminError('The signed-in administrator changed. Reload accounts.', 401);
|
||||
return adminResponse(await readAdminAccounts(env.DB, identity, new URL(request.url).searchParams));
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await adminBody(request, env.DUNE_PUBLIC_ORIGIN);
|
||||
const identity = await requireAdmin(env.DB, adminToken(request), ['owner']);
|
||||
if (request.headers.get('X-Dune-Admin-Id') !== identity.id)
|
||||
throw new AdminError('The signed-in administrator changed. Reload accounts.', 401);
|
||||
if (!validAdminAccountInput(body)) throw new AdminError('Check the account details, operation identifier and operational reason.', 400);
|
||||
return adminResponse(await applyAdminAccount(env.DB, identity, body));
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
+1
-1
@@ -189,7 +189,7 @@ export async function adminLogoutAll(
|
||||
const results = await database.batch([
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE admin_accounts SET session_generation = session_generation + 1, updated_at = ?
|
||||
`UPDATE admin_accounts SET session_generation = session_generation + 1, updated_at = MAX(updated_at + 1, ?)
|
||||
WHERE id = ? AND enabled = 1 AND EXISTS (
|
||||
SELECT 1 FROM admin_sessions s WHERE s.token_hash = ? AND s.admin_id = admin_accounts.id
|
||||
AND s.generation = admin_accounts.session_generation AND s.revoked_at IS NULL AND s.expires_at > ?
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { AdminError, type AdminIdentity } from './admin-access';
|
||||
import { validAdminAccountInput, type AdminAccountInput, type AdminAccountRow, type AdminAccountResult, type AdminAccountsDirectory } from '../lib/admin-accounts';
|
||||
|
||||
const authority = `SELECT a.role FROM admin_sessions s JOIN admin_accounts a ON a.id=s.admin_id
|
||||
WHERE s.token_hash=? AND a.id=? AND a.enabled=1 AND a.role='owner' AND s.revoked_at IS NULL
|
||||
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';
|
||||
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 });
|
||||
|
||||
function credentials(identity: AdminIdentity, now: number) {
|
||||
if (!identity || typeof identity.id !== 'string' || !/^[0-9a-f]{64}$/.test(identity.sessionHash))
|
||||
throw new AdminError('Administrator sign-in required.', 401);
|
||||
return [identity.sessionHash, identity.id, now];
|
||||
}
|
||||
function authorized(result: D1Result) {
|
||||
if (!result.results.length) throw new AdminError('Administrator owner sign-in required.', 401);
|
||||
}
|
||||
const fingerprint = 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('');
|
||||
|
||||
export async function readAdminAccounts(database: D1Database, identity: AdminIdentity, search: URLSearchParams, now = Date.now()): Promise<AdminAccountsDirectory> {
|
||||
const args = credentials(identity, now), rawPage = search.get('page') ?? '1';
|
||||
if ([...search.keys()].some(key => key !== 'page' || search.getAll(key).length !== 1) || !/^[1-9][0-9]{0,5}$/.test(rawPage))
|
||||
throw new AdminError('Use a valid account directory page.', 400);
|
||||
const results = await database.batch([
|
||||
database.prepare(authority).bind(...args),
|
||||
database.prepare(`SELECT COUNT(*) total FROM admin_accounts WHERE EXISTS (${authority})`).bind(...args),
|
||||
database.prepare(`SELECT ${accountFields} FROM admin_accounts WHERE EXISTS (${authority}) ORDER BY created_at DESC,id ASC LIMIT 25 OFFSET ?`).bind(...args,(Number(rawPage)-1)*25),
|
||||
]);
|
||||
authorized(results[0]);
|
||||
return { accounts:(results[2].results as Account[]).map(project),total:Number((results[1].results[0] as {total:number}).total),page:Number(rawPage),pageSize:25 };
|
||||
}
|
||||
|
||||
/** No key, key hash or session value enters the durable receipt, audit or response. */
|
||||
export async function applyAdminAccount(database: D1Database, identity: AdminIdentity, input: AdminAccountInput, now = Date.now()): Promise<AdminAccountResult> {
|
||||
const args = credentials(identity, now);
|
||||
if (!validAdminAccountInput(input)) throw new AdminError('Check the account details, operation identifier and operational reason.', 400);
|
||||
const target = input.action === 'provision' ? input.id : input.target;
|
||||
const reason = input.reason.trim();
|
||||
const requestHash = await fingerprint(JSON.stringify(input.action === 'provision'
|
||||
? { 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 }));
|
||||
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),
|
||||
]);
|
||||
authorized(previous[0]);
|
||||
const resolve = (receipt: Receipt | undefined, replayed: boolean): AdminAccountResult | null => {
|
||||
if (!receipt) return null;
|
||||
if (receipt.actor_admin_id !== identity.id || receipt.request_hash !== requestHash || receipt.target_admin_id !== target)
|
||||
throw new AdminError('This operation identifier is bound to a different administrator or request.', 409);
|
||||
return { operationId:receipt.operation_id,replayed,account:project({ ...receipt,id:receipt.target_admin_id }) };
|
||||
};
|
||||
const existing = resolve(previous[1].results[0] as Receipt | undefined,true);
|
||||
if (existing) return existing;
|
||||
const account = previous[2].results[0] as Account | 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)
|
||||
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);
|
||||
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_accounts WHERE id=?) RETURNING id`)
|
||||
.bind(input.id,input.name,input.role,await fingerprint(input.key),now,now,...args,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)
|
||||
: 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);
|
||||
const result = await database.batch([
|
||||
database.prepare(authority).bind(...args),
|
||||
mutation,
|
||||
database.prepare(`INSERT INTO admin_account_operations(operation_id,actor_admin_id,request_hash,target_admin_id,action,name,role,enabled,created_at,updated_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,input.action,target,...args),
|
||||
input.action === 'role'
|
||||
? database.prepare(`INSERT INTO admin_audit(id,actor_admin_id,target_admin_id,action,created_at,detail,reason)
|
||||
SELECT ?,?,?,'role',?,json_object('previousRole',?,'role',?),?
|
||||
WHERE changes()=1 AND EXISTS (${authority})`)
|
||||
.bind(crypto.randomUUID(),identity.id,target,now,account!.role,input.role,reason,...args)
|
||||
: 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),
|
||||
]);
|
||||
authorized(result[0]);
|
||||
const committed = resolve(result[4].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);
|
||||
}
|
||||
+2
-2
@@ -14,8 +14,8 @@ 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','login','logout','logout_all') THEN action ELSE 'unknown' END action,
|
||||
NULL reason,NULL before_json,substr(detail,1,8192) after_json,created_at FROM admin_audit
|
||||
CASE WHEN action IN ('provision','revoke','role','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),
|
||||
json_object('paused',after_paused,'joinLocked',after_join_locked,'revision',expected_revision+1),created_at FROM admin_room_audit
|
||||
|
||||
+22
-1
@@ -460,7 +460,7 @@ export const adminSessions = sqliteTable(
|
||||
);
|
||||
|
||||
// Audit records intentionally have no cascading account/room dependency.
|
||||
// `detail` contains safe operation IDs only, never credentials or game state.
|
||||
// `detail` contains safe role/session metadata only, never credentials or game state.
|
||||
export const adminAudit = sqliteTable(
|
||||
'admin_audit',
|
||||
{
|
||||
@@ -470,10 +470,31 @@ export const adminAudit = sqliteTable(
|
||||
action: text('action').notNull(),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
detail: text('detail').notNull().default('{}'),
|
||||
reason: text('reason'),
|
||||
},
|
||||
(table) => [index('admin_audit_created_at').on(table.createdAt)],
|
||||
);
|
||||
|
||||
// Durable idempotency receipts contain a request fingerprint, never an access key.
|
||||
export const adminAccountOperations = sqliteTable(
|
||||
'admin_account_operations',
|
||||
{
|
||||
operationId: text('operation_id').primaryKey(),
|
||||
actorAdminId: text('actor_admin_id').notNull(),
|
||||
requestHash: text('request_hash').notNull(),
|
||||
targetAdminId: text('target_admin_id').notNull(),
|
||||
action: text('action').notNull(),
|
||||
name: text('name').notNull(),
|
||||
role: text('role', { enum: ['owner', 'operator', 'viewer'] }).notNull(),
|
||||
enabled: integer('enabled').notNull(),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
},
|
||||
(table) => [
|
||||
check('admin_account_operations_action', sql`${table.action} IN ('provision','role','disable')`),
|
||||
],
|
||||
);
|
||||
|
||||
// Immutable private room snapshots survive room/account deletion; only owner APIs expose them.
|
||||
export const adminRoomBackups = sqliteTable(
|
||||
'admin_room_backups',
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Administrator account management
|
||||
|
||||
Open **Administration → Accounts** as an enabled owner. Operators, viewers and room hosts cannot list or change administrator accounts. The directory shows account names, roles, enabled status and timestamps in pages of 25. It never returns access keys, their hashes or administrator sessions.
|
||||
|
||||
## Provision and protect a key
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## 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`.
|
||||
|
||||
```sh
|
||||
npm test -- admin-accounts admin-audit
|
||||
npm run test:integration -- admin-boundary
|
||||
```
|
||||
|
||||
The source-bound local checkpoint passes 5,780 offline tests, 55 HTTP
|
||||
integration tests, the app build and Drizzle migration check. An isolated
|
||||
Request/Response route-handler smoke exercised owner provisioning, exact
|
||||
replay, directory read and foreign-origin denial; it did not use a network
|
||||
server or persistent administrator account. Browser owner fixtures blocked
|
||||
or intercepted POSTs and verified one-time key recovery and mobile disable
|
||||
confirmation. Independent security and persistence reviews resolved
|
||||
unchanged-role audit no-ops, missing point-of-risk copy and a logout-all
|
||||
timestamp rollback.
|
||||
|
||||
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.
|
||||
@@ -17,9 +17,11 @@ Each record identifies the action, time, room/seat/account where recorded, actor
|
||||
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. External account
|
||||
provisioning/revocation records have no human actor attribution, and the page
|
||||
explicitly says so. A **Recorded** outcome means the operation was applied; a
|
||||
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.
|
||||
|
||||
@@ -51,7 +53,10 @@ and uses the existing no-store, cookie-varying administration boundary.
|
||||
Implementation: [query/projection](../db/admin-audit.ts),
|
||||
[response contract](../lib/admin-audit.ts),
|
||||
[route](../app/api/admin/audit/route.ts), [page](../app/admin/audit/page.tsx).
|
||||
No schema migration is needed.
|
||||
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.
|
||||
|
||||
```sh
|
||||
npm test -- admin-audit
|
||||
@@ -71,7 +76,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,
|
||||
additional account-management actions, full operational health/diagnostics,
|
||||
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.
|
||||
+8
-1
@@ -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, individual/all-session sign-out and operator provisioning/revocation. A room host is not a site administrator. Account-management UI and further operational permissions remain. | Prototyped |
|
||||
| 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 |
|
||||
| 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,6 +53,13 @@ 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.
|
||||
|
||||
[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;
|
||||
the administrator receives no private seat. Resume remains a separate action.
|
||||
|
||||
@@ -6,6 +6,16 @@ Expansion starts and full rules/publication acceptance remain gated.**
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
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.
|
||||
|
||||
The bounded [Advanced Moritani Assassinate Leaders preview](MORITANI_ASSASSINATE_LEADERS.md)
|
||||
now has repeatable genuine games across two through six supported seats.
|
||||
Five samples completed 2,511 accepted actions, no rejected candidates
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 29 September 2026 — owner account management prototype
|
||||
|
||||
The [account page](ADMIN_ACCOUNTS.md) lists only safe metadata to a live
|
||||
owner. Provisioning uses a tab-scoped, browser-generated 256-bit key and an
|
||||
exact durable operation receipt; only its SHA-256 hash enters the database.
|
||||
Owners can change another account’s role or disable it after reviewing the
|
||||
name, ID, current/desired role and reason. Database writes repeat live owner
|
||||
authority and target-version checks, retain the final-owner trigger, and
|
||||
attribute the single successful provision/role/disable audit event. Disabled
|
||||
sessions fail immediately; room and seat rows remain untouched. Rotation,
|
||||
re-enable, failed-attempt audit and complete administration remain pending.
|
||||
|
||||
An isolated direct D1 smoke provisioned a disposable account, changed its
|
||||
role, disabled it, confirmed its old key/session fail and verified safe
|
||||
owner-attributed history without a raw key. An isolated Request/Response
|
||||
route-handler smoke confirmed owner provision, exact replay, directory read
|
||||
and foreign-origin denial without contacting a persistent database. Focused
|
||||
account/access/audit regressions include an additive migration preserving
|
||||
existing room, seat, credential and audit bytes. The local browser showed
|
||||
signed-out denial at phone width; intercepted owner fixtures displayed exact
|
||||
owner-role disable confirmation, retained a one-time key after refresh and
|
||||
hid directory/key controls after a focus-triggered demotion. Browser fixture
|
||||
POSTs never reached an account database. Authenticated positive **network**
|
||||
HTTP and live NAS acceptance remain untested.
|
||||
|
||||
Independent security review found and corrected unchanged-role audit no-ops
|
||||
and incomplete final disable confirmation. A separate persistence review
|
||||
reproduced a logout-all timestamp rollback; the account version is now
|
||||
monotonic and a stale disable rejects. Both follow-up read-only reviews
|
||||
reported no remaining scoped finding. The first focused HTTP run found a
|
||||
test assuming JSON for Vite’s foreign-origin 403; the corrected boundary
|
||||
test passes 3/3, and the full local HTTP suite passes 55/55.
|
||||
|
||||
`npm run check` passes typecheck, lint and 5,780/5,780 offline cases;
|
||||
`npm run build` and `drizzle-kit check` pass. These results do not certify
|
||||
complete administration, authenticated production mutations or deployment.
|
||||
|
||||
## 29 September 2026 — Advanced Moritani assassination sample matrix
|
||||
|
||||
The reusable offline runner now supports the explicitly gated
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE `admin_account_operations` (
|
||||
`operation_id` text PRIMARY KEY NOT NULL,
|
||||
`actor_admin_id` text NOT NULL,
|
||||
`request_hash` text NOT NULL,
|
||||
`target_admin_id` text NOT NULL,
|
||||
`action` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`role` text NOT NULL,
|
||||
`enabled` integer NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
CONSTRAINT "admin_account_operations_action" CHECK("admin_account_operations"."action" IN ('provision','role','disable'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `admin_audit` ADD `reason` text;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -134,6 +134,13 @@
|
||||
"when": 1790640001000,
|
||||
"tag": "0018_admin_backup_downloads",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "6",
|
||||
"when": 1790682568001,
|
||||
"tag": "0019_admin_accounts",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { validAdminAccountInput, type AdminAccountInput, type AdminAccountResult, type AdminAccountRow } 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 };
|
||||
|
||||
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}$/;
|
||||
const hasControl = (value: string) => {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const code = value.charCodeAt(i);
|
||||
if (code < 32 || code === 127) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export function validAdminAccountRow(value: unknown): value is AdminAccountRow {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const row = value as Record<string, unknown>;
|
||||
return Object.keys(row).length === 6 && typeof row.id === 'string' && uuid.test(row.id) &&
|
||||
typeof row.name === 'string' && row.name.length >= 1 && row.name.length <= 80 &&
|
||||
!hasControl(row.name) &&
|
||||
(row.role === 'owner' || row.role === 'operator' || row.role === 'viewer') && typeof row.enabled === 'boolean' &&
|
||||
typeof row.createdAt === 'number' && Number.isSafeInteger(row.createdAt) && row.createdAt >= 0 &&
|
||||
typeof row.updatedAt === 'number' && Number.isSafeInteger(row.updatedAt) && row.updatedAt >= 0;
|
||||
}
|
||||
|
||||
export function validAdminAccountResult(value: unknown, input: AdminAccountInput): value is AdminAccountResult {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const result = value as Record<string, unknown>;
|
||||
if (Object.keys(result).length !== 3 || result.operationId !== input.operationId ||
|
||||
typeof result.replayed !== 'boolean' || !validAdminAccountRow(result.account)) return false;
|
||||
const account = result.account;
|
||||
if (account.id !== (input.action === 'provision' ? input.id : input.target)) return false;
|
||||
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);
|
||||
}
|
||||
|
||||
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 },
|
||||
): 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() };
|
||||
if (!validAdminAccountInput(input)) throw new Error('Check the account, role and operational reason. Do not include credentials in the reason.');
|
||||
return input;
|
||||
}
|
||||
|
||||
export function readAdminAccountRecord(storage: Storage, owner: string): AdminAccountRecord | null {
|
||||
const raw = storage.getItem(storageKey(owner));
|
||||
if (raw === null) return null;
|
||||
let value: unknown;
|
||||
try { value = raw.length <= 2048 ? JSON.parse(raw) : null; } catch { value = null; }
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
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)) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
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.');
|
||||
}
|
||||
|
||||
function persist(storage: Storage, owner: string, record: AdminAccountRecord) {
|
||||
const encoded = JSON.stringify(record);
|
||||
storage.setItem(storageKey(owner), encoded);
|
||||
if (storage.getItem(storageKey(owner)) !== encoded) throw new Error('Could not preserve the account request in this tab. Nothing was sent.');
|
||||
}
|
||||
|
||||
export function saveAdminAccountRequest(storage: Storage, owner: string, input: AdminAccountInput) {
|
||||
if (!validAdminAccountInput(input)) throw new Error('Invalid account request. Nothing was sent.');
|
||||
const previous = readAdminAccountRecord(storage, owner);
|
||||
if (previous && (previous.kind !== 'pending' || JSON.stringify(previous.input) !== JSON.stringify(input)))
|
||||
throw new Error('Resolve the saved account request before sending another.');
|
||||
persist(storage, owner, { kind: 'pending', input });
|
||||
}
|
||||
|
||||
export function completeAdminAccountRequest(storage: Storage, owner: string, input: AdminAccountInput, result: AdminAccountResult) {
|
||||
if (!validAdminAccountResult(result, input)) throw new Error('The server did not confirm this exact account request. Keep the saved retry.');
|
||||
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 });
|
||||
else clearAdminAccountRecord(storage, owner);
|
||||
}
|
||||
|
||||
export function clearAdminAccountRecord(storage: Storage, owner: string) {
|
||||
storage.removeItem(storageKey(owner));
|
||||
if (storage.getItem(storageKey(owner)) !== null) throw new Error('Could not clear the saved account request in this tab.');
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { AdminRole } from '../db/admin-access';
|
||||
|
||||
export type AdminAccountRow = {
|
||||
id: string; name: string; role: AdminRole; enabled: boolean; createdAt: number; updatedAt: number;
|
||||
};
|
||||
export type AdminAccountsDirectory = { accounts: AdminAccountRow[]; total: number; page: number; pageSize: 25 };
|
||||
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 };
|
||||
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}$/;
|
||||
const control = (value: string) => {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const code = value.charCodeAt(i);
|
||||
if (code < 32 || code === 127) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const exact = (value: Record<string, unknown>, fields: string[]) =>
|
||||
Object.keys(value).length === fields.length && fields.every(field => Object.hasOwn(value, field));
|
||||
const role = (value: unknown): value is AdminRole => value === 'owner' || value === 'operator' || value === 'viewer';
|
||||
const reason = (value: unknown) => typeof value === 'string' && value.trim().length >= 1 && value.length <= 300 && !control(value) && !/dune-admin\.|[0-9a-f]{64}/i.test(value);
|
||||
const timestamp = (value: unknown) => Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= 8640000000000000;
|
||||
|
||||
export function validAdminAccountInput(value: unknown): value is AdminAccountInput {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const input = value as Record<string, unknown>;
|
||||
if (typeof input.operationId !== 'string' || !uuid.test(input.operationId) || !reason(input.reason)) return false;
|
||||
if (input.action === 'provision')
|
||||
return exact(input, ['action','operationId','id','key','name','role','reason']) &&
|
||||
typeof input.id === 'string' && uuid.test(input.id) &&
|
||||
typeof input.key === 'string' && input.key === `dune-admin.${input.id}.${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) &&
|
||||
typeof input.name === 'string' && input.name.trim().length >= 1 && input.name.length <= 80 && !control(input.name) && role(input.role);
|
||||
if (input.action === 'role')
|
||||
return exact(input, ['action','operationId','target','expectedUpdatedAt','role','reason']) &&
|
||||
typeof input.target === 'string' && uuid.test(input.target) && timestamp(input.expectedUpdatedAt) && role(input.role);
|
||||
if (input.action === 'disable')
|
||||
return exact(input, ['action','operationId','target','expectedUpdatedAt','reason']) &&
|
||||
typeof input.target === 'string' && uuid.test(input.target) && timestamp(input.expectedUpdatedAt);
|
||||
return false;
|
||||
}
|
||||
+1
-1
@@ -4,7 +4,7 @@ export const AUDIT_CATEGORIES = {
|
||||
} as const;
|
||||
export type AuditCategory = keyof typeof AUDIT_CATEGORIES;
|
||||
export const AUDIT_ACTIONS = {
|
||||
provision: 'Provision administrator', revoke: 'Revoke administrator', login: 'Sign in', logout: 'Sign out', logout_all: 'Sign out everywhere',
|
||||
provision: 'Provision administrator', revoke: 'Revoke administrator', role: 'Change administrator role', 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',
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import test from 'node:test';
|
||||
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 type { AdminAccountRow } from '../lib/admin-accounts';
|
||||
|
||||
function storage() {
|
||||
const values = new Map<string, string>();
|
||||
return { values, getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); }, removeItem: (key: string) => { values.delete(key); } };
|
||||
}
|
||||
|
||||
const account = (id: string, role: AdminAccountRow['role'] = 'viewer'): AdminAccountRow =>
|
||||
({ id, name: 'QA only', role, enabled: true, createdAt: 1700000000000, updatedAt: 1700000000000 });
|
||||
|
||||
void test('SSR exposes no account directory or private key before owner session verification', async () => {
|
||||
// Node's test loader does not handle the page's stylesheet imports; the browser build does.
|
||||
register('data:text/javascript,export async function load(url,context,nextLoad){if(url.endsWith(%22.css%22))return {format:%22module%22,source:%22export {}%22,shortCircuit:true};return nextLoad(url,context)}', import.meta.url);
|
||||
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\./);
|
||||
});
|
||||
|
||||
void test('provisioned key is generated only in the browser, saved exactly before sending, and recoverable from its original tab', () => {
|
||||
const owner = crypto.randomUUID();
|
||||
const tab = storage();
|
||||
const input = newAdminAccountRequest({ action: 'provision', name: ' QA colleague ', role: 'operator', reason: ' QA access ' });
|
||||
assert.equal(input.action, 'provision');
|
||||
if (input.action !== 'provision') return;
|
||||
assert.equal(new RegExp(`^dune-admin\\.${input.id}\\.[0-9a-f]{64}$`).test(input.key), true);
|
||||
assert.equal(input.name, 'QA colleague'); assert.equal(input.reason, 'QA access');
|
||||
saveAdminAccountRequest(tab, owner, input);
|
||||
const pending = readAdminAccountRecord(tab, owner);
|
||||
assert.ok(pending?.kind === 'pending' && pending.input.action === 'provision' &&
|
||||
pending.input.operationId === input.operationId && pending.input.key === input.key);
|
||||
assert.equal(readAdminAccountRecord(tab, crypto.randomUUID()), null);
|
||||
assert.throws(() => saveAdminAccountRequest(tab, owner, newAdminAccountRequest({ action: 'provision', name: 'Other', role: 'viewer', reason: 'QA' })));
|
||||
assert.equal(JSON.stringify(readAdminAccountRecord(tab, owner)) === JSON.stringify(pending), true);
|
||||
saveAdminAccountRequest(tab, owner, input);
|
||||
const receipt = { operationId: input.operationId, replayed: true, account: { ...account(input.id, 'operator'), name: input.name } };
|
||||
assert.equal(validAdminAccountResult({ ...receipt, account: { ...receipt.account, role: 'viewer' } }, input), false);
|
||||
completeAdminAccountRequest(tab, owner, input, receipt);
|
||||
const completed = readAdminAccountRecord(tab, owner);
|
||||
assert.ok(completed?.kind === 'completed' && completed.input.key === input.key && completed.account.id === input.id);
|
||||
assert.throws(() => saveAdminAccountRequest(tab, owner, input));
|
||||
assert.equal(JSON.stringify(readAdminAccountRecord(tab, owner)) === JSON.stringify(completed), true);
|
||||
const savedKey = [...tab.values.keys()][0], savedRecord = tab.values.get(savedKey)!;
|
||||
tab.values.set(savedKey, JSON.stringify({ kind: 'completed', input, account: { ...receipt.account, role: 'viewer' } }));
|
||||
assert.throws(() => readAdminAccountRecord(tab, owner));
|
||||
tab.values.set(savedKey, savedRecord);
|
||||
clearAdminAccountRecord(tab, owner);
|
||||
assert.equal(readAdminAccountRecord(tab, owner), null);
|
||||
});
|
||||
|
||||
void test('role and disable requests retain exact target version and reason until a matching receipt is persisted', () => {
|
||||
const owner = crypto.randomUUID(), target = crypto.randomUUID();
|
||||
for (const input of [
|
||||
newAdminAccountRequest({ action: 'role', target, expectedUpdatedAt: 1700000000000, role: 'owner', reason: 'QA rotation' }),
|
||||
newAdminAccountRequest({ action: 'disable', target, expectedUpdatedAt: 1700000000000, reason: 'QA departure' }),
|
||||
]) {
|
||||
if (input.action === 'provision') throw new Error('This fixture must change an existing account.');
|
||||
const tab = storage();
|
||||
saveAdminAccountRequest(tab, owner, input);
|
||||
assert.deepEqual(readAdminAccountRecord(tab, owner), { kind: 'pending', input });
|
||||
assert.throws(() => saveAdminAccountRequest(tab, owner, { ...input, expectedUpdatedAt: 1700000000001 }));
|
||||
assert.deepEqual(readAdminAccountRecord(tab, owner), { kind: 'pending', input });
|
||||
assert.equal(validAdminAccountResult({ operationId: crypto.randomUUID(), replayed: true, account: account(target) }, input), false);
|
||||
assert.equal(validAdminAccountResult({ operationId: input.operationId, replayed: true, account: { ...account(target), key: 'private' } }, input), false);
|
||||
const next = { ...account(target, input.action === 'role' ? 'owner' : 'viewer'),
|
||||
enabled: input.action === 'role', updatedAt: 1700000000001 };
|
||||
assert.equal(validAdminAccountResult({ operationId: input.operationId, replayed: true,
|
||||
account: { ...next, enabled: !next.enabled } }, input), false);
|
||||
const receipt = { operationId: input.operationId, replayed: true, account: next };
|
||||
completeAdminAccountRequest(tab, owner, input, receipt);
|
||||
assert.equal(readAdminAccountRecord(tab, owner), null);
|
||||
assert.equal([...tab.values.values()].join('').includes(input.reason), false);
|
||||
}
|
||||
});
|
||||
|
||||
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' });
|
||||
assert.throws(() => saveAdminAccountRequest({ ...tab, setItem() {} }, owner, input));
|
||||
assert.equal(tab.values.size, 0);
|
||||
saveAdminAccountRequest(tab, owner, input);
|
||||
const key = [...tab.values.keys()][0];
|
||||
tab.values.set(key, '{broken');
|
||||
assert.throws(() => readAdminAccountRecord(tab, owner));
|
||||
assert.throws(() => saveAdminAccountRequest(tab, owner, input));
|
||||
assert.equal(tab.values.get(key), '{broken');
|
||||
tab.values.set(key, JSON.stringify({ kind: 'pending', input: { ...input, operationId: crypto.randomUUID() } }));
|
||||
const unchanged = readAdminAccountRecord(tab, owner);
|
||||
assert.throws(() => completeAdminAccountRequest(tab, owner, input, { operationId: input.operationId, replayed: false, account: account(input.action === 'provision' ? input.id : '') }));
|
||||
assert.equal(JSON.stringify(readAdminAccountRecord(tab, owner)) === JSON.stringify(unchanged), true);
|
||||
assert.throws(() => newAdminAccountRequest({ action: 'provision', name: 'QA', role: 'viewer', reason: 'dune-admin.secret' }));
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { adminStore, sha256 } from './admin-access-fixture';
|
||||
import { adminLogin, adminLogoutAll, requireAdmin, AdminError } from '../db/admin-access';
|
||||
import { applyAdminAccount, readAdminAccounts } from '../db/admin-accounts';
|
||||
import { readAdminAudit } from '../db/admin-audit';
|
||||
import { validAdminAccountInput, type AdminAccountInput } from '../lib/admin-accounts';
|
||||
|
||||
const now = Date.parse('2026-09-29T12:00:00Z');
|
||||
const denied = (status: number) => (error: unknown) => error instanceof AdminError && error.status === status;
|
||||
async function fixture() {
|
||||
const store = adminStore(), owner = store.provision('owner'), target = store.provision('viewer');
|
||||
const session = await adminLogin(store.database,owner.key,now);
|
||||
const identity = await requireAdmin(store.database,session.token,['owner'],now);
|
||||
return { ...store,owner,target,session,identity };
|
||||
}
|
||||
function newProvision(role: 'owner'|'operator'|'viewer' = 'viewer'): Extract<AdminAccountInput,{action:'provision'}> {
|
||||
const id = randomUUID();
|
||||
return { action:'provision',operationId:randomUUID(),id,key:`dune-admin.${id}.${randomBytes(32).toString('hex')}`,name:'New administrator',role,reason:'Staff rotation' };
|
||||
}
|
||||
|
||||
void test('account receipt migration adds metadata without changing existing games or credentials',() => {
|
||||
const sqlite=new DatabaseSync(':memory:');
|
||||
try {
|
||||
for(const file of readdirSync(new URL('../drizzle/',import.meta.url))
|
||||
.filter(file=>file.endsWith('.sql') && file<'0019')
|
||||
.sort((a,b)=>a.localeCompare(b)))
|
||||
sqlite.exec(readFileSync(new URL('../drizzle/'+file,import.meta.url),'utf8'));
|
||||
const id=randomUUID(),keyHash=sha256('private-old-key'),sessionHash=sha256('private-old-session');
|
||||
sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)')
|
||||
.run('SAVE0001','{"sealed":"original"}',7,1000);
|
||||
sqlite.prepare('INSERT INTO seats(token_hash,revoked,room_code,player_id) VALUES(?,?,?,?)')
|
||||
.run('d'.repeat(64),0,'SAVE0001','player');
|
||||
sqlite.prepare('INSERT INTO admin_accounts(id,name,role,key_hash,created_at,updated_at) VALUES(?,?,?,?,?,?)')
|
||||
.run(id,'Original owner','owner',keyHash,1000,1000);
|
||||
sqlite.prepare('INSERT INTO admin_sessions(token_hash,id,admin_id,generation,created_at,expires_at) VALUES(?,?,?,?,?,?)')
|
||||
.run(sessionHash,randomUUID(),id,0,1000,100000);
|
||||
const saved=()=>JSON.stringify([
|
||||
sqlite.prepare('SELECT * FROM rooms').all(),sqlite.prepare('SELECT * FROM seats').all(),
|
||||
sqlite.prepare('SELECT * FROM admin_accounts').all(),sqlite.prepare('SELECT * FROM admin_sessions').all(),
|
||||
sqlite.prepare('SELECT id,actor_admin_id,target_admin_id,action,created_at,detail FROM admin_audit').all(),
|
||||
]);
|
||||
const before=saved();
|
||||
sqlite.exec(readFileSync(new URL('../drizzle/0019_admin_accounts.sql',import.meta.url),'utf8'));
|
||||
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.throws(()=>sqlite.prepare('UPDATE admin_accounts SET enabled=0 WHERE id=?').run(id));
|
||||
assert.equal(saved(),before);
|
||||
} finally {sqlite.close();}
|
||||
});
|
||||
|
||||
void test('owner directory pages 25 safe accounts and rejects stale/forged authority',async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
for(let i=0;i<27;i++)f.provision('viewer','Directory '+i);
|
||||
const first=await readAdminAccounts(f.database,f.identity,new URLSearchParams(),now);
|
||||
const second=await readAdminAccounts(f.database,f.identity,new URLSearchParams('page=2'),now);
|
||||
assert.equal(first.total,29);assert.equal(first.accounts.length,25);assert.equal(second.accounts.length,4);
|
||||
assert.equal(new Set([...first.accounts,...second.accounts].map(a=>a.id)).size,29);
|
||||
assert.doesNotMatch(JSON.stringify(first),/key_hash|session_hash|dune-admin\.|[0-9a-f]{64}/);
|
||||
for(const query of ['page=0','page=1&page=2','role=owner'])await assert.rejects(readAdminAccounts(f.database,f.identity,new URLSearchParams(query),now),denied(400));
|
||||
f.provision('owner');
|
||||
f.sqlite.prepare("UPDATE admin_accounts SET role='operator' WHERE id=?").run(f.owner.id);
|
||||
await assert.rejects(readAdminAccounts(f.database,f.identity,new URLSearchParams(),now),denied(401));
|
||||
} finally {f.sqlite.close();}
|
||||
});
|
||||
|
||||
void test('provision exact retry returns the committed snapshot without doubling audit',async () => {
|
||||
const f=await fixture();
|
||||
try {
|
||||
const input=newProvision('operator');
|
||||
assert.equal(validAdminAccountInput(input),true);
|
||||
const initial=await applyAdminAccount(f.database,f.identity,input,now);
|
||||
assert.equal(initial.replayed,false);assert.equal(initial.account.id,input.id);
|
||||
assert.deepEqual(await applyAdminAccount(f.database,f.identity,input,now),{...initial,replayed:true});
|
||||
assert.equal(f.sqlite.prepare('SELECT COUNT(*) n FROM admin_audit WHERE target_admin_id=?').get(input.id)!.n,1);
|
||||
} finally {f.sqlite.close();}
|
||||
});
|
||||
|
||||
void test('an unchanged role request cannot advance the account version or fabricate an audit change',async () => {
|
||||
const f=await fixture();
|
||||
try {
|
||||
const before=JSON.stringify([
|
||||
f.sqlite.prepare('SELECT * FROM admin_accounts WHERE id=?').get(f.target.id),
|
||||
f.sqlite.prepare('SELECT * FROM admin_audit ORDER BY rowid').all(),
|
||||
]);
|
||||
const input:AdminAccountInput={action:'role',operationId:randomUUID(),target:f.target.id,
|
||||
expectedUpdatedAt:1000,role:'viewer',reason:'No change'};
|
||||
await assert.rejects(applyAdminAccount(f.database,f.identity,input,now),denied(409));
|
||||
assert.equal(JSON.stringify([
|
||||
f.sqlite.prepare('SELECT * FROM admin_accounts WHERE id=?').get(f.target.id),
|
||||
f.sqlite.prepare('SELECT * FROM admin_audit ORDER BY rowid').all(),
|
||||
]),before);
|
||||
} finally {f.sqlite.close();}
|
||||
});
|
||||
|
||||
void test('provision, role and permanent disable retain one audited operation each and leave games/seats untouched',async () => {
|
||||
const f=await fixture();
|
||||
try {
|
||||
f.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)').run('SAVEDQAA','{"secret":"sealed"}',4,1000);
|
||||
f.sqlite.prepare('INSERT INTO seats(token_hash,revoked,room_code,player_id) VALUES(?,?,?,?)').run('f'.repeat(64),0,'SAVEDQAA','player');
|
||||
const before=JSON.stringify([f.sqlite.prepare('SELECT * FROM rooms').all(),f.sqlite.prepare('SELECT * FROM seats').all()]);
|
||||
const provision=newProvision('viewer');
|
||||
const created=await applyAdminAccount(f.database,f.identity,provision,now);
|
||||
assert.equal(created.account.role,'viewer');
|
||||
assert.deepEqual(await applyAdminAccount(f.database,f.identity,provision,now),{...created,replayed:true});
|
||||
const role: AdminAccountInput={action:'role',operationId:randomUUID(),target:provision.id,expectedUpdatedAt:created.account.updatedAt,role:'operator',reason:'Assign duty'};
|
||||
const promoted=await applyAdminAccount(f.database,f.identity,role,now);
|
||||
assert.equal(promoted.account.role,'operator');assert.ok(promoted.account.updatedAt>created.account.updatedAt);
|
||||
assert.deepEqual(await applyAdminAccount(f.database,f.identity,role,now),{...promoted,replayed:true});
|
||||
await assert.rejects(applyAdminAccount(f.database,f.identity,{...role,operationId:randomUUID()},now),denied(409));
|
||||
const login=await adminLogin(f.database,provision.key,now);
|
||||
const disable: AdminAccountInput={action:'disable',operationId:randomUUID(),target:provision.id,expectedUpdatedAt:promoted.account.updatedAt,reason:'Access no longer needed'};
|
||||
const disabled=await applyAdminAccount(f.database,f.identity,disable,now);
|
||||
assert.equal(disabled.account.enabled,false);
|
||||
assert.deepEqual(await applyAdminAccount(f.database,f.identity,disable,now),{...disabled,replayed:true});
|
||||
await assert.rejects(adminLogin(f.database,provision.key,now),denied(401));
|
||||
await assert.rejects(requireAdmin(f.database,login.token,undefined,now),denied(401));
|
||||
assert.ok(f.sqlite.prepare('SELECT revoked_at FROM admin_sessions WHERE admin_id=?').get(provision.id)!.revoked_at);
|
||||
const audits=f.sqlite.prepare('SELECT actor_admin_id,action,reason,detail FROM admin_audit WHERE target_admin_id=?').all(provision.id);
|
||||
assert.equal(audits.length,4); // Login receipt plus three account operations.
|
||||
assert.deepEqual(audits.filter(a=>a.action!=='login').map(a=>a.action).sort((a,b)=>String(a).localeCompare(String(b))),['provision','revoke','role']);
|
||||
assert.ok(audits.filter(a=>a.action!=='login').every(a=>a.actor_admin_id===f.owner.id && typeof a.reason==='string'));
|
||||
const history=await readAdminAudit(f.database,f.identity,new URLSearchParams('category=account'),now);
|
||||
const roleEvent=history.events.find(e=>e.targetAdminId===provision.id && e.action==='role');
|
||||
assert.deepEqual(roleEvent?.changes,[{field:'Role',before:'viewer',after:'operator'}]);
|
||||
assert.equal(roleEvent?.reason,'Assign duty');
|
||||
assert.equal(f.sqlite.prepare('SELECT key_hash FROM admin_accounts WHERE id=?').get(provision.id)!.key_hash,sha256(provision.key));
|
||||
const persisted=JSON.stringify([f.sqlite.prepare('SELECT * FROM admin_account_operations').all(),f.sqlite.prepare('SELECT * FROM admin_audit').all(),history]);
|
||||
assert.equal(persisted.includes(provision.key),false);
|
||||
assert.equal(persisted.includes(sha256(provision.key)),false);
|
||||
assert.equal(JSON.stringify([f.sqlite.prepare('SELECT * FROM rooms').all(),f.sqlite.prepare('SELECT * FROM seats').all()]),before);
|
||||
} finally {f.sqlite.close();}
|
||||
});
|
||||
|
||||
void test('conflicting replay, actor change and invalid payloads cannot write',async () => {
|
||||
const f=await fixture();
|
||||
try {
|
||||
const input=newProvision();await applyAdminAccount(f.database,f.identity,input,now);
|
||||
const before=Number(f.sqlite.prepare('SELECT COUNT(*) n FROM admin_audit').get()!.n);
|
||||
await assert.rejects(applyAdminAccount(f.database,f.identity,{...input,reason:'Different reason'},now),denied(409));
|
||||
await assert.rejects(applyAdminAccount(f.database,f.identity,{...input,reason:' '+input.reason},now),denied(409));
|
||||
const other=f.provision('owner');const session=await adminLogin(f.database,other.key,now);
|
||||
const identity=await requireAdmin(f.database,session.token,['owner'],now);
|
||||
await assert.rejects(applyAdminAccount(f.database,identity,input,now),denied(409));
|
||||
assert.equal(f.sqlite.prepare('SELECT COUNT(*) n FROM admin_audit').get()!.n,before+2); // Other owner provision and login only.
|
||||
for(const bad of [{...input,key:input.key+'\n'},{...input,name:'Bad\nname'},{...input,reason:'contains '+input.key},{...input,unknown:true},{...input,role:'superuser'},
|
||||
{...input,id:randomUUID()},{...input,operationId:'not-a-uuid'}])assert.equal(validAdminAccountInput(bad),false);
|
||||
} finally {f.sqlite.close();}
|
||||
});
|
||||
|
||||
void test('write-time revocation, concurrent target change, final-owner trigger and self mutation fail closed',async () => {
|
||||
const f=await fixture();
|
||||
try {
|
||||
assert.throws(()=>f.sqlite.prepare('UPDATE admin_accounts SET enabled=0 WHERE id=?').run(f.owner.id));
|
||||
assert.equal(f.sqlite.prepare('SELECT enabled FROM admin_accounts WHERE id=?').get(f.owner.id)!.enabled,1);
|
||||
const self: AdminAccountInput={action:'disable',operationId:randomUUID(),target:f.owner.id,expectedUpdatedAt:1000,reason:'Self'};
|
||||
await assert.rejects(applyAdminAccount(f.database,f.identity,self,now),denied(409));
|
||||
await assert.rejects(applyAdminAccount(f.database,f.identity,{
|
||||
action:'role',operationId:randomUUID(),target:f.owner.id,expectedUpdatedAt:1000,role:'viewer',reason:'Self demotion',
|
||||
},now),denied(409));
|
||||
const input=newProvision();
|
||||
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,input,now),denied(401));
|
||||
assert.equal(f.sqlite.prepare('SELECT COUNT(*) n FROM admin_accounts WHERE id=?').get(input.id)!.n,0);
|
||||
f.sqlite.prepare('UPDATE admin_sessions SET revoked_at=NULL WHERE admin_id=?').run(f.owner.id);
|
||||
const target=f.provision('viewer');
|
||||
const change: AdminAccountInput={action:'role',operationId:randomUUID(),target:target.id,expectedUpdatedAt:1000,role:'operator',reason:'Race'};
|
||||
f.hooks.beforeBatch=async()=>{f.hooks.beforeBatch=async()=>{delete f.hooks.beforeBatch;f.sqlite.prepare('UPDATE admin_accounts SET updated_at=2000 WHERE id=?').run(target.id);};};
|
||||
await assert.rejects(applyAdminAccount(f.database,f.identity,change,now),denied(409));
|
||||
assert.equal(f.sqlite.prepare('SELECT role FROM admin_accounts WHERE id=?').get(target.id)!.role,'viewer');
|
||||
assert.equal(f.sqlite.prepare('SELECT COUNT(*) n FROM admin_account_operations WHERE operation_id=?').get(change.operationId)!.n,0);
|
||||
} finally {f.sqlite.close();}
|
||||
});
|
||||
|
||||
void test('logout-all cannot roll back an account version and admit a stale disable',async () => {
|
||||
const f=await fixture();
|
||||
try {
|
||||
const promote:AdminAccountInput={action:'role',operationId:randomUUID(),target:f.target.id,
|
||||
expectedUpdatedAt:1000,role:'operator',reason:'Grant duty'};
|
||||
const changed=await applyAdminAccount(f.database,f.identity,promote,1000);
|
||||
const login=await adminLogin(f.database,f.target.key,1000);
|
||||
const target=await requireAdmin(f.database,login.token,undefined,1000);
|
||||
await adminLogoutAll(f.database,target,1000);
|
||||
const updated=Number(f.sqlite.prepare('SELECT updated_at FROM admin_accounts WHERE id=?').get(f.target.id)!.updated_at);
|
||||
assert.ok(updated>changed.account.updatedAt);
|
||||
const stale:AdminAccountInput={action:'disable',operationId:randomUUID(),target:f.target.id,
|
||||
expectedUpdatedAt:1000,reason:'Stale version'};
|
||||
await assert.rejects(applyAdminAccount(f.database,f.identity,stale,1000),denied(409));
|
||||
assert.equal(f.sqlite.prepare('SELECT enabled FROM admin_accounts WHERE id=?').get(f.target.id)!.enabled,1);
|
||||
} finally {f.sqlite.close();}
|
||||
});
|
||||
|
||||
void test('workerd D1 applies provision, exact retry and revocation in transactional batches',async () => {
|
||||
const { Miniflare }=await import('miniflare');
|
||||
const f=await fixture();
|
||||
const worker=new Miniflare({modules:true,script:'export default {fetch(){return new Response("ok")}}',compatibilityDate:'2026-05-15',d1Databases:['DB']});
|
||||
try {
|
||||
const db=await worker.getD1Database('DB');
|
||||
const schema=f.sqlite.prepare("SELECT type,name,sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 ELSE 2 END").all() as {type:string;name:string;sql:string}[];
|
||||
for(const entry of schema.filter(e=>e.type!=='trigger'))await db.prepare(entry.sql).run();
|
||||
for(const entry of schema.filter(e=>e.type==='table')) {
|
||||
for(const row of f.sqlite.prepare(`SELECT * FROM "${entry.name}"`).all()) {
|
||||
const columns=Object.keys(row);
|
||||
await db.prepare(`INSERT INTO "${entry.name}" (${columns.map(column=>'"'+column+'"').join(',')}) VALUES (${columns.map(()=>'?').join(',')})`).bind(...Object.values(row)).run();
|
||||
}
|
||||
}
|
||||
for(const entry of schema.filter(e=>e.type==='trigger'))await db.prepare(entry.sql).run();
|
||||
const database=db as unknown as D1Database, input=newProvision('owner');
|
||||
const created=await applyAdminAccount(database,f.identity,input,now);
|
||||
const simultaneous=newProvision('viewer');
|
||||
const races=await Promise.all([
|
||||
applyAdminAccount(database,f.identity,simultaneous,now),
|
||||
applyAdminAccount(database,f.identity,simultaneous,now),
|
||||
]);
|
||||
assert.deepEqual(races.map(result=>result.replayed).sort((a,b)=>Number(a)-Number(b)),[false,true],JSON.stringify(races));
|
||||
assert.equal((await db.prepare('SELECT COUNT(*) n FROM admin_audit WHERE target_admin_id=?').bind(simultaneous.id).first<{n:number}>())?.n,1);
|
||||
assert.deepEqual(await applyAdminAccount(database,f.identity,input,now),{...created,replayed:true});
|
||||
const listed=await readAdminAccounts(database,f.identity,new URLSearchParams(),now);
|
||||
assert.equal(listed.total,4);
|
||||
const disable:AdminAccountInput={action:'disable',operationId:randomUUID(),target:input.id,expectedUpdatedAt:created.account.updatedAt,reason:'Remove access'};
|
||||
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);
|
||||
await assert.rejects(adminLogin(database,input.key,now),denied(401));
|
||||
} finally {await worker.dispose();f.sqlite.close();}
|
||||
});
|
||||
@@ -13,12 +13,20 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden
|
||||
assert.ok(cookie);
|
||||
const room = (await created.json() as { code: string }).code;
|
||||
for (const headers of [{}, { cookie }, { cookie: 'dune_admin_session=' + 'a'.repeat(64) }, { 'oai-authenticated-user-id': 'owner', 'oai-authenticated-user-email': 'owner@example.test' }] as Record<string, string>[]) {
|
||||
for (const path of ['/api/admin/session', '/api/admin/audit', '/api/admin/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`, `/api/admin/rooms/${room}/removal`, `/api/admin/rooms/${room}/closure`, `/api/admin/rooms/${room}/archive`, `/api/admin/rooms/${room}/seat-ai`, `/api/admin/rooms/${room}/discussion`]) {
|
||||
for (const path of ['/api/admin/session', '/api/admin/accounts', '/api/admin/audit', '/api/admin/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`, `/api/admin/rooms/${room}/removal`, `/api/admin/rooms/${room}/closure`, `/api/admin/rooms/${room}/archive`, `/api/admin/rooms/${room}/seat-ai`, `/api/admin/rooms/${room}/discussion`]) {
|
||||
const result = await fetch(base + path, { headers, signal: AbortSignal.timeout(15000) });
|
||||
assert.equal(result.status, 401, path);
|
||||
assert.equal(result.headers.get('cache-control'), 'no-store');
|
||||
assert.deepEqual(Object.keys(await result.json()), ['error']);
|
||||
}
|
||||
const account = await fetch(base + '/api/admin/accounts', {
|
||||
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json', 'X-Dune-Admin-Id': 'forged-owner' },
|
||||
body: JSON.stringify({ action: 'provision', operationId: crypto.randomUUID(), id: crypto.randomUUID(), key: 'forged', name: 'Forbidden', role: 'owner', reason: 'Denied boundary request' }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
assert.equal(account.status, 401);
|
||||
assert.equal(account.headers.get('set-cookie'), null);
|
||||
assert.deepEqual(Object.keys(await account.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' }),
|
||||
@@ -96,3 +104,15 @@ void test('admin HTTP rejects login CSRF, oversized bodies and invalid credentia
|
||||
await result.text();
|
||||
}
|
||||
});
|
||||
void test('account writes reject foreign origins before any credential or account lookup', async () => {
|
||||
for (const origin of ['', 'https://foreign.invalid']) {
|
||||
const result = await fetch(base + '/api/admin/accounts', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', ...(origin ? { origin } : {}), 'X-Dune-Admin-Id': 'forged-owner' },
|
||||
body: JSON.stringify({ action:'disable',operationId:crypto.randomUUID(),target:crypto.randomUUID(),expectedUpdatedAt:1,reason:'Forbidden' }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
assert.equal(result.status,403);
|
||||
assert.equal(result.headers.get('set-cookie'),null);
|
||||
await result.text();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user