Add administrator sign-in and private-safe room directory
Connect personal operator-provisioned admin keys, persisted roles and eight-hour sessions, current/all-session revocation and a filtered room directory. Additive schema protects final owner, revokes disabled accounts and records safe audit history. No game state changes; remaining administration scope stays explicit. Verified: typecheck, lint, 5196 offline tests, 52 HTTP tests, production build; authenticated local HTTP and desktop/mobile browser checks. Independent privacy/security review fixed malformed nested roster searches and read-time session revocation. Preserved 1,671 prior rooms and 3,538 seats. Container/deployed acceptance follows publication.
This commit is contained in:
1 parent
36a03bda9c
commit
c2d392e155
27 files changed
+3164
-13
No files matched your search
@@ -16,6 +16,7 @@ COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/drizzle ./drizzle
|
||||
COPY package.json ./
|
||||
COPY tools/wrangler.local.json ./tools/wrangler.local.json
|
||||
COPY tools/admin-access.mjs ./tools/admin-access.mjs
|
||||
COPY deploy/container-entrypoint.sh ./deploy/container-entrypoint.sh
|
||||
COPY deploy/serve.mjs ./deploy/serve.mjs
|
||||
RUN mkdir -p /data /app/.wrangler && chown node:node /data /app/.wrangler
|
||||
|
||||
@@ -39,6 +39,11 @@ For private NAS hosting and manually applied checkpoint image updates, see the
|
||||
[TrueNAS container guide](docs/TRUENAS.md). Its separate persistent storage starts
|
||||
with new games; the requested host port is 33046.
|
||||
|
||||
The **Administration** link opens `/admin`, with separate administrator sign-in,
|
||||
session controls and a searchable room directory. See [initial owner setup and
|
||||
scope](docs/ADMIN_PANEL.md#initial-owner-and-access-recovery). Room creation,
|
||||
lifecycle/removal, participant support and backup tools are still being built.
|
||||
|
||||
## Saved seats and reconnecting
|
||||
|
||||
The room cookie identifies your seat. Refreshing or reopening the invitation with that cookie restores your private hand and pending decisions. A failed restoration offers an explicit retry. Requests time out after 15 seconds; uncertain game actions are reconciled with a read rather than automatically repeated.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
.admin-shell { max-width: 1420px; margin: auto; padding: 32px; line-height: 1.5; }
|
||||
.admin-header, .admin-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
|
||||
.admin-header { padding-bottom: 24px; margin-bottom: 28px; border-bottom: 1px solid var(--border); }
|
||||
.admin-return { color: var(--primary); font-size: 1rem; }
|
||||
.admin-header h1 { display: flex; align-items: center; gap: 12px; margin: 12px 0 0; }
|
||||
.admin-session { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; }
|
||||
.admin-shell h2 { margin-top: 0; }
|
||||
.admin-secondary { color: #b7bbaf; }
|
||||
.admin-login { max-width: 580px; margin: 48px auto; padding: 30px; border: 1px solid var(--border); background: #171c16; }
|
||||
.admin-login form { display: grid; gap: 12px; margin: 24px 0; }
|
||||
.admin-shell input, .admin-shell select, .admin-shell button { min-height: 42px; font-size: 1rem; }
|
||||
.admin-shell input, .admin-shell select { width: 100%; border: 1px solid var(--border); border-radius: 4px; background: #151a14; color: var(--foreground); padding: 8px 10px; }
|
||||
.admin-shell button { white-space: normal; }
|
||||
.admin-notice { display: block; padding: 14px 18px; border: 1px solid var(--primary); background: #25291e; }
|
||||
.admin-filters { display: flex; flex-wrap: wrap; align-items: end; gap: 16px; padding: 22px 0; border-bottom: 1px solid var(--border); }
|
||||
.admin-filters label { display: grid; gap: 6px; font-size: 0.9375rem; flex: 1 1 145px; }
|
||||
.admin-filters .admin-search { flex: 2 1 240px; }
|
||||
.admin-results { margin: 20px 0 12px; }
|
||||
.admin-room-table { table-layout: fixed; font-size: 0.9375rem; }
|
||||
.admin-room-table th, .admin-room-table td { padding: 16px 12px; white-space: normal; overflow-wrap: anywhere; vertical-align: top; }
|
||||
.admin-room-table th:first-child { width: 30%; }
|
||||
.admin-room-table td p { margin: 8px 0; }
|
||||
.admin-room-table details { margin-top: 8px; }
|
||||
.admin-room-table summary { cursor: pointer; color: var(--primary); }
|
||||
.admin-room-table ul { padding-left: 20px; }
|
||||
.admin-code { font-family: monospace; font-size: 1.125rem; letter-spacing: 1px; }
|
||||
.admin-tag { display: inline-block; font-size: 0.875rem; border: 1px solid #626951; border-radius: 4px; padding: 2px 7px; margin-left: 5px; }
|
||||
.admin-pagination { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin: 24px 0; }
|
||||
.admin-session-control { border-top: 1px solid var(--border); padding-top: 24px; margin-top: 24px; max-width: 820px; }
|
||||
.admin-confirm { border: 1px solid var(--border); padding: 16px; }
|
||||
.admin-confirm button { margin-right: 10px; }
|
||||
.admin-development { border-top: 1px solid var(--border); margin-top: 28px; padding-top: 20px; }
|
||||
@media (max-width: 700px) {
|
||||
.admin-shell { padding: 20px 16px; }
|
||||
.admin-header, .admin-section-heading { align-items: start; flex-direction: column; gap: 16px; }
|
||||
.admin-header h1 { font-size: 1.8rem; }
|
||||
.admin-login { padding: 20px; margin: 24px 0; }
|
||||
.admin-room-table { display: block; }
|
||||
.admin-room-table thead { display: none; }
|
||||
.admin-room-table tbody { display: block; }
|
||||
.admin-room-table tr { display: grid; grid-template-columns: 1fr; padding: 14px 0; }
|
||||
.admin-room-table td { display: block; padding: 6px 0; }
|
||||
.admin-room-table td:nth-child(2)::before { content: 'Rules: '; color: var(--primary); }
|
||||
.admin-room-table td:nth-child(3)::before { content: 'Progress: '; color: var(--primary); }
|
||||
.admin-room-table td:nth-child(4)::before { content: 'Last game change: '; color: var(--primary); }
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Shield, RefreshCw, LogOut } from 'lucide-react';
|
||||
import { ClientRequestError, requestJson } from '@/lib/client-request';
|
||||
import type { AdminDirectory } from '@/lib/admin-directory';
|
||||
import { FACTIONS } from '@/game/catalog';
|
||||
import './admin.css';
|
||||
|
||||
type Account = { id: string; name: string; role: 'owner' | 'operator' | 'viewer' };
|
||||
type Filters = { q: string; status: string; rules: string; sort: string };
|
||||
const initialFilters: Filters = { q: '', status: 'all', rules: 'all', sort: 'recent' };
|
||||
const errorMessage = (error: unknown) => error instanceof Error ? error.message : 'The request could not be completed.';
|
||||
|
||||
export default function Administration() {
|
||||
const [account, setAccount] = useState<Account | null>(null);
|
||||
const [restoring, setRestoring] = useState(true);
|
||||
const [key, setKey] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [directory, setDirectory] = useState<AdminDirectory | null>(null);
|
||||
const [filters, setFilters] = useState(initialFilters);
|
||||
const [applied, setApplied] = useState(initialFilters);
|
||||
const [confirmAll, setConfirmAll] = useState(false);
|
||||
const epoch = useRef(0);
|
||||
const clearSession = useCallback(() => {
|
||||
epoch.current++;
|
||||
setAccount(null); setDirectory(null); setConfirmAll(false);
|
||||
setBusy(false);
|
||||
}, []);
|
||||
const load = useCallback(async (next: Filters, page = 1) => {
|
||||
const current = ++epoch.current;
|
||||
setBusy(true); setNotice('');
|
||||
try {
|
||||
const query = new URLSearchParams({ ...next, page: String(page) });
|
||||
const result = await requestJson<AdminDirectory>('/api/admin/rooms?' + query, { cache: 'no-store' });
|
||||
if (current === epoch.current) { setDirectory(result); setApplied(next); }
|
||||
} catch (error) {
|
||||
if (current === epoch.current) {
|
||||
if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) clearSession();
|
||||
setNotice(errorMessage(error));
|
||||
}
|
||||
} finally { if (current === epoch.current) setBusy(false); }
|
||||
}, [clearSession]);
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
requestJson<{ admin: Account }>('/api/admin/session', { cache: 'no-store' })
|
||||
.then(async result => { if (!canceled) { setAccount(result.admin); await load(initialFilters); } })
|
||||
.catch(error => { if (!canceled && !(error instanceof ClientRequestError && error.status === 401)) setNotice(errorMessage(error)); })
|
||||
.finally(() => { if (!canceled) setRestoring(false); });
|
||||
return () => { canceled = true; };
|
||||
}, [load]);
|
||||
|
||||
async function sessionAction(action: 'login' | 'logout' | 'logoutAll') {
|
||||
if (busy) return;
|
||||
setBusy(true); setNotice('');
|
||||
const submittedKey = key.trim();
|
||||
setKey('');
|
||||
try {
|
||||
const result = await requestJson<{ admin?: Account }>('/api/admin/session', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, ...(action === 'login' ? { key: submittedKey } : {}) }),
|
||||
});
|
||||
if (result.admin) { setAccount(result.admin); await load(initialFilters); }
|
||||
else { clearSession(); setNotice(action === 'logoutAll' ? 'All your administrator sessions are signed out.' : 'Signed out.'); }
|
||||
} catch (error) {
|
||||
// Reconcile an uncertain response with a read; never repeat a mutation automatically.
|
||||
try {
|
||||
const result = await requestJson<{ admin: Account }>('/api/admin/session', { cache: 'no-store' });
|
||||
setAccount(result.admin);
|
||||
await load(initialFilters);
|
||||
setNotice(action === 'login' ? '' : 'You are still signed in. Retry the sign-out action.');
|
||||
} catch (readError) {
|
||||
if (readError instanceof ClientRequestError && readError.status === 401) clearSession();
|
||||
setNotice(errorMessage(error));
|
||||
}
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return <main className="admin-shell">
|
||||
<header className="admin-header">
|
||||
<div><a className="admin-return" href="/">Arrakis Table</a><h1><Shield aria-hidden="true" /> Administration</h1></div>
|
||||
{account && <div className="admin-session"><span>{account.name} <span className="admin-tag">{account.role}</span></span>
|
||||
<Button variant="outline" disabled={busy} onClick={() => void sessionAction('logout')}><LogOut aria-hidden="true" /> Sign out</Button></div>}
|
||||
</header>
|
||||
{notice && <output className="admin-notice">{notice}</output>}
|
||||
{restoring ? <output>Checking administrator access…</output> : !account ?
|
||||
<section className="admin-login" aria-labelledby="admin-sign-in">
|
||||
<h2 id="admin-sign-in">Administrator sign-in</h2>
|
||||
<p>Use your personal administrator access key. Room invitations and saved-seat recovery kits cannot sign in here.</p>
|
||||
<form onSubmit={event => { event.preventDefault(); void sessionAction('login'); }}>
|
||||
<label htmlFor="admin-key">Access key</label>
|
||||
<Input id="admin-key" type="password" autoComplete="current-password" autoCapitalize="none" spellCheck={false}
|
||||
maxLength={130} value={key} onChange={event => setKey(event.target.value)} required disabled={busy} />
|
||||
<Button type="submit" disabled={busy || !key.trim()}>{busy ? 'Signing in…' : 'Sign in'}</Button>
|
||||
</form>
|
||||
<p className="admin-secondary">First setup: the server owner creates a personal key using the installation instructions. Access is closed until an administrator is provisioned.</p>
|
||||
</section> : <>
|
||||
<section aria-labelledby="admin-rooms-title">
|
||||
<div className="admin-section-heading"><div><h2 id="admin-rooms-title">Rooms</h2><p className="admin-secondary">Public room details. Cards, sealed choices and private discussion stay hidden.</p></div>
|
||||
<Button variant="outline" disabled={busy} onClick={() => void load(applied, directory?.page ?? 1)}><RefreshCw aria-hidden="true" /> Refresh</Button></div>
|
||||
<form className="admin-filters" onSubmit={event => { event.preventDefault(); void load(filters); }}>
|
||||
<label className="admin-search" htmlFor="admin-search">Room code or player name<Input id="admin-search" value={filters.q} maxLength={80} disabled={busy} onChange={e => setFilters({ ...filters, q: e.target.value })} /></label>
|
||||
<label>Status<select disabled={busy} value={filters.status} onChange={e => setFilters({ ...filters, status: e.target.value })}>
|
||||
<option value="all">All statuses</option><option value="lobby">Lobby</option><option value="setup">Setup</option><option value="playing">Playing</option><option value="finished">Finished</option>
|
||||
</select></label>
|
||||
<label>Rules<select disabled={busy} value={filters.rules} onChange={e => setFilters({ ...filters, rules: e.target.value })}>
|
||||
<option value="all">All rules</option><option value="basic">Basic</option><option value="advanced">Advanced</option>
|
||||
</select></label>
|
||||
<label>Sort<select disabled={busy} value={filters.sort} onChange={e => setFilters({ ...filters, sort: e.target.value })}>
|
||||
<option value="recent">Recently changed</option><option value="oldest">Oldest change</option><option value="code">Room code</option>
|
||||
</select></label>
|
||||
<Button type="submit" disabled={busy}>Apply filters</Button>
|
||||
</form>
|
||||
<div aria-busy={busy}>
|
||||
{!directory ? <output>{busy ? 'Loading rooms…' : 'Use Refresh to load rooms.'}</output> : <>
|
||||
<p className="admin-results">{directory.total.toLocaleString()} {directory.total === 1 ? 'room' : 'rooms'} match</p>
|
||||
{directory.rooms.length === 0 ? <p>No rooms on this page. Change the filters or return to the first page.</p> :
|
||||
<Table className="admin-room-table"><TableHeader><TableRow>
|
||||
<TableHead>Room & players</TableHead><TableHead>Rules</TableHead><TableHead>Progress</TableHead><TableHead>Last game change</TableHead>
|
||||
</TableRow></TableHeader><TableBody>{directory.rooms.map(room => <TableRow key={room.code}>
|
||||
<TableCell><strong className="admin-code">{room.code}</strong> <span className="admin-tag">{room.status}</span>
|
||||
<p>Host: {room.players.find(p => p.id === room.host)?.name ?? 'Unavailable'}</p>
|
||||
<details><summary>{room.players.length} {room.players.length === 1 ? 'player' : 'players'}</summary><ul>{room.players.map(player => <li key={player.id}>{player.name} · {FACTIONS.find(f => f.id === player.faction)?.name ?? player.faction} · {player.control === 'ai' ? 'AI' : player.control === 'autopilot' ? 'Human seat on AI' : 'Human'}</li>)}</ul></details>
|
||||
</TableCell>
|
||||
<TableCell>{room.advanced ? room.preview ? 'Advanced preview' : 'Advanced development' : 'Basic'}
|
||||
{room.expansions.length > 0 && <p>Expansions: {room.expansions.map(id => id === 'ix' ? 'Ixians & Tleilaxu' : id === 'choam' ? 'CHOAM & Richese' : 'Ecaz & Moritani').join(', ')}</p>}
|
||||
<p className="admin-secondary">{room.modules.join(', ') || 'No optional modules'}</p>
|
||||
</TableCell>
|
||||
<TableCell>{room.turn !== null && room.status !== 'lobby' ? `Turn ${room.turn} · ` : ''}{room.phase}
|
||||
<p>{room.pending.label}{room.pending.owners.length > 0 ? ': ' + room.pending.owners.map(id => room.players.find(p => p.id === id)?.name).filter(Boolean).join(', ') : ''}</p>
|
||||
</TableCell>
|
||||
<TableCell><time dateTime={new Date(room.updatedAt).toISOString()}>{new Date(room.updatedAt).toLocaleString()}</time><p className="admin-secondary">Version {room.version}</p></TableCell>
|
||||
</TableRow>)}</TableBody></Table>}
|
||||
<nav className="admin-pagination" aria-label="Room pages">
|
||||
<Button variant="outline" disabled={busy || directory.page <= 1} onClick={() => void load(applied, 1)}>First</Button>
|
||||
<Button variant="outline" disabled={busy || directory.page <= 1} onClick={() => void load(applied, directory.page - 1)}>Previous</Button>
|
||||
<span>Page {directory.page} of {Math.max(1, Math.ceil(directory.total / directory.pageSize))}</span>
|
||||
<Button variant="outline" disabled={busy || directory.page * directory.pageSize >= directory.total} onClick={() => void load(applied, directory.page + 1)}>Next</Button>
|
||||
</nav>
|
||||
</>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="admin-session-control" aria-labelledby="admin-access-title"><h2 id="admin-access-title">Your access</h2>
|
||||
<p>Sessions expire after eight hours. Sign out everywhere to end all your current administrator sessions, including this browser. Your personal access key remains valid.</p>
|
||||
{!confirmAll ? <Button variant="outline" disabled={busy} onClick={() => setConfirmAll(true)}>Sign out everywhere…</Button> :
|
||||
<div className="admin-confirm"><p>End all your administrator sessions now?</p><Button disabled={busy} onClick={() => void sessionAction('logoutAll')}>Confirm sign out everywhere</Button><Button variant="outline" disabled={busy} onClick={() => setConfirmAll(false)}>Cancel</Button></div>}
|
||||
</section>
|
||||
<p className="admin-secondary admin-development">Administration preview: room creation, lifecycle controls, participant support and backup tools are still being implemented.</p>
|
||||
</>}
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { requireAdmin } from '@/db/admin-access';
|
||||
import { readAdminDirectory } from '@/db/admin-directory';
|
||||
import { adminFailure, adminResponse, adminToken } from '@/lib/admin-http';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const identity = await requireAdmin(env.DB, adminToken(request), ['owner', 'operator', 'viewer']);
|
||||
return adminResponse(await readAdminDirectory(env.DB, identity, new URL(request.url).searchParams));
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { AdminError, adminLogin, adminLogout, adminLogoutAll, requireAdmin } from '@/db/admin-access';
|
||||
import { adminBody, adminCookie, adminFailure, adminResponse, adminToken } from '@/lib/admin-http';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { id, name, role } = await requireAdmin(env.DB, adminToken(request));
|
||||
return adminResponse({ admin: { id, name, role } });
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await adminBody(request, env.DUNE_PUBLIC_ORIGIN);
|
||||
if (body.action === 'login' && Object.keys(body).every(k => ['action', 'key'].includes(k))) {
|
||||
const result = await adminLogin(env.DB, body.key);
|
||||
return adminResponse({ admin: result.admin }, { 'Set-Cookie': adminCookie(request, result.token, env.DUNE_PUBLIC_ORIGIN) });
|
||||
}
|
||||
if (Object.keys(body).length !== 1) throw new AdminError('Invalid session action.', 400);
|
||||
if (body.action === 'logout') await adminLogout(env.DB, adminToken(request));
|
||||
else if (body.action === 'logoutAll') await adminLogoutAll(env.DB, await requireAdmin(env.DB, adminToken(request)));
|
||||
else throw new AdminError('Choose a valid session action.', 400);
|
||||
return adminResponse({ signedOut: true }, { 'Set-Cookie': adminCookie(request, '', env.DUNE_PUBLIC_ORIGIN) });
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
@@ -1125,6 +1125,7 @@ export default function Home() {
|
||||
</div>
|
||||
<footer>
|
||||
<span>Unofficial fan implementation</span>
|
||||
<a href="/admin">Administration</a>
|
||||
<span>Dune · A game of conquest, diplomacy & betrayal</span>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
export type AdminRole = 'owner' | 'operator' | 'viewer';
|
||||
export type AdminAccount = { id: string; name: string; role: AdminRole };
|
||||
export type AdminIdentity = AdminAccount & { sessionHash: string };
|
||||
|
||||
export const ADMIN_SESSION_DURATION_MS = 8 * 60 * 60 * 1000;
|
||||
|
||||
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 KEY_PATTERN = new RegExp(`^dune-admin\\.(${UUID})\\.[0-9a-f]{64}$`);
|
||||
const TOKEN_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
export class AdminError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AdminError';
|
||||
}
|
||||
}
|
||||
|
||||
function isRole(value: string): value is AdminRole {
|
||||
return value === 'owner' || value === 'operator' || value === 'viewer';
|
||||
}
|
||||
|
||||
async function hash(value: string) {
|
||||
const digest = await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
new TextEncoder().encode(value),
|
||||
);
|
||||
return Array.from(new Uint8Array(digest), (byte) =>
|
||||
byte.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
}
|
||||
|
||||
function sessionToken() {
|
||||
return Array.from(crypto.getRandomValues(new Uint8Array(32)), (byte) =>
|
||||
byte.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
}
|
||||
|
||||
const signInRequired = () =>
|
||||
new AdminError('Administrator sign-in required.', 401);
|
||||
|
||||
/** Creates an independent admin session. Access keys and session tokens never enter audit records. */
|
||||
export async function adminLogin(
|
||||
database: D1Database,
|
||||
key: unknown,
|
||||
now = Date.now(),
|
||||
): Promise<{
|
||||
token: string;
|
||||
admin: AdminAccount;
|
||||
expiresAt: number;
|
||||
}> {
|
||||
const invalid = () =>
|
||||
new AdminError('Administrator access key is invalid or revoked.', 401);
|
||||
if (typeof key !== 'string') throw invalid();
|
||||
const match = KEY_PATTERN.exec(key);
|
||||
if (!match || match[0] !== key) throw invalid();
|
||||
const keyHash = await hash(key);
|
||||
const account = await database
|
||||
.prepare(
|
||||
'SELECT id,name,role,session_generation FROM admin_accounts WHERE id = ? AND key_hash = ? AND enabled = 1',
|
||||
)
|
||||
.bind(match[1], keyHash)
|
||||
.first<AdminAccount & { session_generation: number }>();
|
||||
if (!account || !isRole(account.role)) throw invalid();
|
||||
|
||||
const token = sessionToken();
|
||||
const tokenHash = await hash(token);
|
||||
const sessionId = crypto.randomUUID();
|
||||
const expiresAt = now + ADMIN_SESSION_DURATION_MS;
|
||||
// Repeat the credential and generation fence at the write. A disable or
|
||||
// logout-all between the read and this batch cannot resurrect an old session.
|
||||
const results = await database.batch([
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO admin_sessions (token_hash,id,admin_id,generation,created_at,expires_at)
|
||||
SELECT ?,?,id,session_generation,?,? FROM admin_accounts
|
||||
WHERE id = ? AND key_hash = ? AND enabled = 1 AND session_generation = ?`,
|
||||
)
|
||||
.bind(
|
||||
tokenHash,
|
||||
sessionId,
|
||||
now,
|
||||
expiresAt,
|
||||
account.id,
|
||||
keyHash,
|
||||
account.session_generation,
|
||||
),
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO admin_audit (id,actor_admin_id,target_admin_id,action,created_at,detail)
|
||||
SELECT ?,admin_id,admin_id,'login',?,? FROM admin_sessions WHERE token_hash = ?`,
|
||||
)
|
||||
.bind(crypto.randomUUID(), now, JSON.stringify({ sessionId }), tokenHash),
|
||||
]);
|
||||
if (results[0].meta.changes !== 1) throw invalid();
|
||||
// Return current role/name, and fail if revocation committed after creation.
|
||||
const { id, name, role } = await requireAdmin(
|
||||
database,
|
||||
token,
|
||||
undefined,
|
||||
now,
|
||||
);
|
||||
return { token, admin: { id, name, role }, expiresAt };
|
||||
}
|
||||
|
||||
/** Every privileged request checks live account status, role, generation and absolute expiry. */
|
||||
export async function requireAdmin(
|
||||
database: D1Database,
|
||||
token: string | undefined,
|
||||
roles?: AdminRole[],
|
||||
now = Date.now(),
|
||||
): Promise<AdminIdentity> {
|
||||
if (
|
||||
typeof token !== 'string' ||
|
||||
token.length !== 64 ||
|
||||
!TOKEN_PATTERN.test(token)
|
||||
)
|
||||
throw signInRequired();
|
||||
const sessionHash = await hash(token);
|
||||
const account = await database
|
||||
.prepare(
|
||||
`SELECT a.id,a.name,a.role FROM admin_accounts a
|
||||
JOIN admin_sessions s ON s.admin_id = a.id
|
||||
WHERE s.token_hash = ? AND s.revoked_at IS NULL AND s.expires_at > ?
|
||||
AND s.generation = a.session_generation AND a.enabled = 1`,
|
||||
)
|
||||
.bind(sessionHash, now)
|
||||
.first<AdminAccount>();
|
||||
if (!account || !isRole(account.role)) throw signInRequired();
|
||||
if (roles && !roles.includes(account.role))
|
||||
throw new AdminError(
|
||||
'Your administrator role does not permit this action.',
|
||||
403,
|
||||
);
|
||||
return {
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
role: account.role,
|
||||
sessionHash,
|
||||
};
|
||||
}
|
||||
|
||||
/** Idempotent browser logout also accepts an expired/disabled session, without granting any privilege. */
|
||||
export async function adminLogout(
|
||||
database: D1Database,
|
||||
token: string | undefined,
|
||||
now = Date.now(),
|
||||
): Promise<void> {
|
||||
if (
|
||||
typeof token !== 'string' ||
|
||||
token.length !== 64 ||
|
||||
!TOKEN_PATTERN.test(token)
|
||||
)
|
||||
return;
|
||||
const tokenHash = await hash(token);
|
||||
await database.batch([
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO admin_audit (id,actor_admin_id,target_admin_id,action,created_at,detail)
|
||||
SELECT ?,admin_id,admin_id,'logout',?,json_object('sessionId',id)
|
||||
FROM admin_sessions WHERE token_hash = ? AND revoked_at IS NULL`,
|
||||
)
|
||||
.bind(crypto.randomUUID(), now, tokenHash),
|
||||
database
|
||||
.prepare(
|
||||
'UPDATE admin_sessions SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL',
|
||||
)
|
||||
.bind(now, tokenHash),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Revoke every current browser session, including this one, while retaining the access key. */
|
||||
export async function adminLogoutAll(
|
||||
database: D1Database,
|
||||
identity: AdminIdentity,
|
||||
now = Date.now(),
|
||||
): Promise<void> {
|
||||
if (
|
||||
!identity ||
|
||||
typeof identity.sessionHash !== 'string' ||
|
||||
identity.sessionHash.length !== 64 ||
|
||||
!TOKEN_PATTERN.test(identity.sessionHash)
|
||||
)
|
||||
throw signInRequired();
|
||||
const auditId = crypto.randomUUID();
|
||||
const results = await database.batch([
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE admin_accounts SET session_generation = session_generation + 1, updated_at = ?
|
||||
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 > ?
|
||||
)`,
|
||||
)
|
||||
.bind(now, identity.id, identity.sessionHash, now),
|
||||
// changes() refers to the account update immediately before this statement.
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO admin_audit (id,actor_admin_id,target_admin_id,action,created_at)
|
||||
SELECT ?,id,id,'logout_all',? FROM admin_accounts WHERE id = ? AND changes() = 1`,
|
||||
)
|
||||
.bind(auditId, now, identity.id),
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE admin_sessions SET revoked_at = ? WHERE admin_id = ? AND revoked_at IS NULL
|
||||
AND EXISTS (SELECT 1 FROM admin_audit WHERE id = ? AND action = 'logout_all')`,
|
||||
)
|
||||
.bind(now, identity.id, auditId),
|
||||
]);
|
||||
if (results[0].meta.changes !== 1) throw signInRequired();
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Game } from '../game/engine';
|
||||
import type { AdminDirectory, AdminRoom } from '../lib/admin-directory';
|
||||
import { AdminError, type AdminIdentity } from './admin-access';
|
||||
|
||||
const phases = ['Storm', 'Spice Blow', 'CHOAM Charity', 'Bidding', 'Revival', 'Shipment & Movement', 'Battle', 'Spice Collection', 'Mentat Pause'];
|
||||
const statuses = ['lobby', 'setup', 'playing', 'finished'] as const;
|
||||
const expansionIds = ['ix', 'choam', 'ecaz'];
|
||||
const text = (value: unknown, max = 80) => typeof value === 'string' ? value.slice(0, max) : '';
|
||||
const integer = (value: unknown) => Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : null;
|
||||
|
||||
/** Only emit allowlisted fields. Never call viewGame with an administrator's or host's seat. */
|
||||
export function projectAdminRoom(row: { code: string; version: number; updated_at: number; state: string }): AdminRoom {
|
||||
const base: AdminRoom = {
|
||||
code: row.code, version: row.version, updatedAt: row.updated_at,
|
||||
status: 'unreadable', host: null, players: [], advanced: false, preview: false,
|
||||
expansions: [], modules: [], turn: null, phase: 'Unavailable',
|
||||
pending: { label: 'Saved room needs inspection', owners: [] },
|
||||
};
|
||||
try {
|
||||
const g = JSON.parse(row.state) as Game;
|
||||
if (!g || !statuses.includes(g.status) || !Array.isArray(g.players) ||
|
||||
g.players.length > 6 || g.players.some(p => !p || typeof p.id !== 'string' || p.id.length > 80 || typeof p.name !== 'string')) return base;
|
||||
const players: AdminRoom['players'] = g.players.map(p => ({
|
||||
id: text(p.id), name: text(p.name), faction: text(p.faction, 30),
|
||||
control: p.bot ? 'ai' : p.autopilot ? 'autopilot' : 'human',
|
||||
}));
|
||||
const seated = (id: unknown): id is string => typeof id === 'string' && players.some(p => p.id === id);
|
||||
let pending: AdminRoom['pending'] = { label: 'Table decision', owners: [] };
|
||||
if (g.status === 'finished') pending = { label: 'Game finished', owners: [] };
|
||||
else if (g.status === 'lobby') pending = { label: 'Lobby readiness', owners: g.players.filter(p => !p.ready).map(p => p.id) };
|
||||
else if (g.status === 'setup') pending = { label: 'Starting choices', owners: [] };
|
||||
else if (g.pendingTreacheryDiscard) pending = { label: 'Automatic continuation', owners: [] };
|
||||
else if (g.response || g.phaseOpening) pending = { label: 'Shared response window', owners: [] };
|
||||
else if (g.truthtrance) pending = { label: 'Truthtrance window', owners: [] };
|
||||
else if (g.decision) pending = { label: 'Special decision', owners: seated(g.decision.player) ? [g.decision.player] : [] };
|
||||
else if (g.battle) pending = { label: 'Battle decisions', owners: [g.battle.attacker, g.battle.defender].filter(seated) };
|
||||
else if (g.nexus) pending = { label: 'Nexus negotiations', owners: [] };
|
||||
else if (g.phase === 0 && g.stormDialers?.length) pending = { label: 'Storm dials', owners: g.stormDialers.filter(id => g.stormDials?.[id] === undefined).filter(seated) };
|
||||
else if (g.auction) pending = { label: 'Auction decision', owners: seated(g.auction.active) ? [g.auction.active] : [] };
|
||||
else if (seated(g.active)) pending = { label: 'Turn decision', owners: [g.active] };
|
||||
return { ...base, status: g.status, host: seated(g.host) ? g.host : null, players,
|
||||
advanced: g.advanced === true, preview: g.advancedPreview === true,
|
||||
expansions: expansionIds.filter(id => Array.isArray(g.expansions) && g.expansions.includes(id)),
|
||||
modules: [g.techTokens && 'Tech Tokens', g.strongholdCards && 'Stronghold Cards', g.leaderSkills && 'Leader Skills', g.homeworlds && 'Homeworlds', g.nexusCards && 'Nexus Cards', g.discoveryEnabled && 'Discoveries', g.ecazTreachery && 'Ecaz Treachery'].filter((s): s is string => typeof s === 'string'),
|
||||
turn: integer(g.turn), phase: g.status === 'lobby' ? 'Lobby' : g.status === 'setup' ? 'Setup' : phases[g.phase] ?? 'Unknown phase', pending };
|
||||
} catch { return base; }
|
||||
}
|
||||
|
||||
export async function readAdminDirectory(database: D1Database, identity: AdminIdentity, search: URLSearchParams, now = Date.now()): Promise<AdminDirectory> {
|
||||
const allowed = ['q', 'status', 'rules', 'sort', 'page'];
|
||||
if ([...search.keys()].some(k => !allowed.includes(k) || search.getAll(k).length !== 1)) throw new AdminError('Invalid room filters.', 400);
|
||||
const q = (search.get('q') ?? '').trim();
|
||||
const status = search.get('status') ?? 'all';
|
||||
const rules = search.get('rules') ?? 'all';
|
||||
const sort = search.get('sort') ?? 'recent';
|
||||
const rawPage = search.get('page') ?? '1';
|
||||
if (q.length > 80 || !['all', ...statuses].includes(status) || !['all', 'basic', 'advanced'].includes(rules) ||
|
||||
!['recent', 'oldest', 'code'].includes(sort) || !/^[1-9][0-9]{0,5}$/.test(rawPage)) throw new AdminError('Invalid room filters.', 400);
|
||||
const page = Number(rawPage), pageSize = 25;
|
||||
// json_valid protects the rest of the directory if a legacy save is malformed.
|
||||
const state = "CASE WHEN json_valid(state) THEN state ELSE '{}' END";
|
||||
// Each read is fenced again inside the transaction: prior authentication is
|
||||
// not authority to read after another browser revokes this session.
|
||||
const authority = `SELECT 1 FROM admin_sessions s JOIN admin_accounts a ON a.id = s.admin_id
|
||||
WHERE s.token_hash = ? AND a.id = ? AND a.enabled = 1 AND s.revoked_at IS NULL
|
||||
AND s.expires_at > ? AND s.generation = a.session_generation
|
||||
AND a.role IN ('owner','operator','viewer')`;
|
||||
const credentials = [identity.sessionHash, identity.id, now];
|
||||
const where: string[] = [`EXISTS (${authority})`];
|
||||
const args: (string | number)[] = [...credentials];
|
||||
if (q) {
|
||||
const players = `CASE WHEN json_type(${state}, '$.players') = 'array' THEN json_extract(${state}, '$.players') ELSE '[]' END`;
|
||||
where.push(`(code LIKE ? ESCAPE '\\' OR EXISTS (SELECT 1 FROM json_each(${players}) p WHERE json_extract(CASE WHEN p.type = 'object' THEN p.value ELSE '{}' END, '$.name') LIKE ? ESCAPE '\\'))`);
|
||||
const pattern = `%${q.replace(/[\\%_]/g, '\\$&')}%`;
|
||||
args.push(pattern, pattern);
|
||||
}
|
||||
if (status !== 'all') { where.push(`json_extract(${state}, '$.status') = ?`); args.push(status); }
|
||||
if (rules !== 'all') { where.push(`json_extract(${state}, '$.advanced') = ?`); args.push(rules === 'advanced' ? 1 : 0); }
|
||||
const predicate = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
||||
const order = sort === 'code' ? 'code ASC' : `updated_at ${sort === 'oldest' ? 'ASC' : 'DESC'}, code ASC`;
|
||||
const results = await database.batch([
|
||||
database.prepare(authority).bind(...credentials),
|
||||
database.prepare(`SELECT COUNT(*) AS total FROM rooms ${predicate}`).bind(...args),
|
||||
database.prepare(`SELECT code,version,updated_at,state FROM rooms ${predicate} ORDER BY ${order} LIMIT ? OFFSET ?`).bind(...args, pageSize, (page - 1) * pageSize),
|
||||
]);
|
||||
if (!results[0].results.length) throw new AdminError('Administrator sign-in required.', 401);
|
||||
const total = Number((results[1].results[0] as { total: number }).total);
|
||||
const rows = results[2].results as { code: string; version: number; updated_at: number; state: string }[];
|
||||
return { rooms: rows.map(projectAdminRoom), total, page, pageSize };
|
||||
}
|
||||
+66
-6
@@ -5,13 +5,20 @@ import {
|
||||
primaryKey,
|
||||
index,
|
||||
uniqueIndex,
|
||||
check,
|
||||
} from 'drizzle-orm/sqlite-core';
|
||||
export const rooms = sqliteTable('rooms', {
|
||||
code: text('code').primaryKey(),
|
||||
state: text('state').notNull(),
|
||||
version: integer('version').notNull().default(0),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
});
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const rooms = sqliteTable(
|
||||
'rooms',
|
||||
{
|
||||
code: text('code').primaryKey(),
|
||||
state: text('state').notNull(),
|
||||
version: integer('version').notNull().default(0),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
},
|
||||
(table) => [index('rooms_updated_at').on(table.updatedAt)],
|
||||
);
|
||||
export const seats = sqliteTable('seats', {
|
||||
tokenHash: text('token_hash').primaryKey(),
|
||||
revoked: integer('revoked').notNull().default(0),
|
||||
@@ -152,3 +159,56 @@ export const roomMessages = sqliteTable(
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const adminAccounts = sqliteTable(
|
||||
'admin_accounts',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
role: text('role', { enum: ['owner', 'operator', 'viewer'] }).notNull(),
|
||||
keyHash: text('key_hash').notNull().unique(),
|
||||
enabled: integer('enabled').notNull().default(1),
|
||||
sessionGeneration: integer('session_generation').notNull().default(0),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
},
|
||||
(table) => [
|
||||
check(
|
||||
'admin_accounts_role',
|
||||
sql`${table.role} IN ('owner', 'operator', 'viewer')`,
|
||||
),
|
||||
check('admin_accounts_enabled', sql`${table.enabled} IN (0, 1)`),
|
||||
check('admin_accounts_generation', sql`${table.sessionGeneration} >= 0`),
|
||||
],
|
||||
);
|
||||
|
||||
export const adminSessions = sqliteTable(
|
||||
'admin_sessions',
|
||||
{
|
||||
tokenHash: text('token_hash').primaryKey(),
|
||||
id: text('id').notNull().unique(),
|
||||
adminId: text('admin_id')
|
||||
.notNull()
|
||||
.references(() => adminAccounts.id, { onDelete: 'cascade' }),
|
||||
generation: integer('generation').notNull(),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
expiresAt: integer('expires_at').notNull(),
|
||||
revokedAt: integer('revoked_at'),
|
||||
},
|
||||
(table) => [index('admin_sessions_account').on(table.adminId)],
|
||||
);
|
||||
|
||||
// Audit records intentionally have no cascading account/room dependency.
|
||||
// `detail` contains safe operation IDs only, never credentials or game state.
|
||||
export const adminAudit = sqliteTable(
|
||||
'admin_audit',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
actorAdminId: text('actor_admin_id'),
|
||||
targetAdminId: text('target_admin_id').notNull(),
|
||||
action: text('action').notNull(),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
detail: text('detail').notNull().default('{}'),
|
||||
},
|
||||
(table) => [index('admin_audit_created_at').on(table.createdAt)],
|
||||
);
|
||||
+94
-4
@@ -1,15 +1,16 @@
|
||||
# Administration panel requirements
|
||||
|
||||
Added to the goal on 22 September 2026 at the user's request. This is required
|
||||
product scope, not a claim that the panel is implemented. Reuse the existing
|
||||
room, session, recovery and persistence systems where suitable.
|
||||
product scope. The first access and directory prototype is available at `/admin`;
|
||||
the remaining room operations are unfinished. Reuse the existing room, session,
|
||||
recovery and persistence systems where suitable.
|
||||
|
||||
## Required capabilities
|
||||
|
||||
| Area | Required behavior | Stage |
|
||||
| --- | --- | --- |
|
||||
| Administrator access | Secure administrator sign-in, server-enforced permissions, session revocation and a documented initial-owner setup. A room host is not automatically a site administrator. | Missing |
|
||||
| Room directory | Search, filter, sort and paginate rooms; show lifecycle status, host, roster, rules/modules, last activity and pending decision ownership without exposing private game information. | Missing |
|
||||
| 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 |
|
||||
| Room directory | Search room codes/public player names, filter status/rules, sort and paginate; show host, roster, modules, game change time and bounded decision ownership. Detailed setup/shared-window ownership and later lifecycle metadata remain. | Prototyped |
|
||||
| Create and configure | Create rooms with supported rules and human/AI seats, manage lobby settings and invitations, and assign a host through an explicit workflow. Preserve normal prerequisites and readiness rules. | Missing |
|
||||
| Lifecycle and removal | Pause/resume, lock/unlock new joins, close/end, archive, restore and delete selected rooms. Offer recoverable removal and explicit permanent deletion with clear scope, consequences and safeguards for related records. | Missing |
|
||||
| Participant support | Remove disruptive participants or revoke access, manage appropriate restrictions, assist saved-seat recovery and host reassignment, and use supported AI takeover/replacement without losing forces, cards or decisions. | Missing |
|
||||
@@ -20,6 +21,95 @@ Track each area through Missing, Prototyped, Integrated, Verified and Polished.
|
||||
As implementation begins, link its controls, server actions, persistence,
|
||||
documentation and evidence. A working directory alone is not a full admin panel.
|
||||
|
||||
## Access and directory prototype
|
||||
|
||||
Use **Administration** in the home-page footer or visit `/admin`. Every directory
|
||||
request checks a separate administrator session; seat cookies and forwarded
|
||||
identity headers cannot grant access. The standalone NAS runtime has no trusted
|
||||
Sites identity dispatcher, so this panel uses separately provisioned personal
|
||||
keys. Each key contains a random 256-bit secret. Only its SHA-256 hash is stored;
|
||||
sign-in issues a separate random, hashed session with an eight-hour absolute
|
||||
expiry. HTTPS uses Secure, HttpOnly, SameSite=Strict cookies scoped to the admin
|
||||
API. Mutations require the exact configured origin and bounded JSON requests.
|
||||
|
||||
All three roles may read the current directory and sign out their own sessions.
|
||||
Owner/operator mutation capabilities will be added with the room-management
|
||||
slices. The role is checked from the live account, never accepted from a client.
|
||||
Disabling an account invalidates its sessions; later re-enabling it cannot restore
|
||||
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.
|
||||
|
||||
The directory uses an explicit field allowlist, independent of any player view.
|
||||
It never returns hands, Traitors, predictions, plans, private messages, recovery
|
||||
records or raw saved state. Searches inspect only codes and public player names.
|
||||
Each page is limited to 25 rooms and repeats authorization in its read transaction
|
||||
so concurrent revocation cannot expose a later directory snapshot. Malformed
|
||||
saves are marked unreadable without leaking their contents or breaking searches.
|
||||
Last game change refers to the room's saved-state timestamp, not private messages.
|
||||
Shared windows and setup currently use broad pending labels; these labels do not
|
||||
assert that a human is stalled or identify hidden-card-dependent eligibility.
|
||||
|
||||
Implementation: [panel](../app/admin/page.tsx), [access](../db/admin-access.ts),
|
||||
[directory](../db/admin-directory.ts), [HTTP boundary](../lib/admin-http.ts),
|
||||
[additive migration](../drizzle/0008_admin_access.sql), and
|
||||
[operator CLI](../tools/admin-access.mjs). The migration's explicit SQLite triggers
|
||||
enforce final-owner protection and account revocation/provisioning audit; retain
|
||||
them when generating future schema changes. Audit browsing is still pending.
|
||||
|
||||
### Initial owner and access recovery
|
||||
|
||||
From the development checkout, apply additive migrations, then create a personal
|
||||
owner key in a new private directory outside the checkout:
|
||||
|
||||
```sh
|
||||
npm run db:local
|
||||
node tools/admin-access.mjs --name 'Owner' --role owner --out /private/dune-owner
|
||||
npx wrangler d1 execute DB --local --config tools/wrangler.local.json --persist-to .wrangler/state --file /private/dune-owner/provision.sql
|
||||
```
|
||||
|
||||
The parent directory must already exist. Output files are mode 0600 in a new
|
||||
mode-0700 directory. `access-key.txt` contains the sign-in key; `provision.sql`
|
||||
contains only its hash. Keep the key in a private password manager or protected
|
||||
file, outside Git, logs and chat. The command never connects to a database or
|
||||
prints the key. Apply the SQL only to the intended instance. Do not upload a
|
||||
development key to production; provision separate accounts there.
|
||||
|
||||
For the container, run the bundled CLI inside the existing `dune` container
|
||||
(using its actual container ID/name), with output such as `/data/admin-owner`.
|
||||
Apply the generated SQL using `node node_modules/wrangler/bin/wrangler.js d1
|
||||
execute DB --local --config tools/wrangler.local.json --persist-to /data --file
|
||||
/data/admin-owner/provision.sql`. A protected volume snapshot should precede
|
||||
operational changes. Retrieve the key through an authorized private channel and
|
||||
retain the account ID shown by the CLI. No application restart is needed.
|
||||
|
||||
To revoke an account, generate a new private output directory with
|
||||
`node tools/admin-access.mjs --revoke ACCOUNT_UUID --out /private/dune-revoke`
|
||||
and apply its `revoke.sql` using the same instance-specific command. To replace
|
||||
a lost/compromised owner key, provision a replacement owner first, verify access,
|
||||
then revoke the previous account. Existing games and seats are unchanged.
|
||||
**Sign out everywhere** revokes browser sessions but keeps the personal key valid.
|
||||
|
||||
### Focused verification
|
||||
|
||||
```sh
|
||||
npm test -- admin-access admin-directory admin-http
|
||||
npm run test:integration -- admin-boundary
|
||||
node tools/verify-admin.mjs --url http://localhost:3000 --key-file /private/dune-qa/access-key.txt --qa-account ACCOUNT_UUID --out /private/dune-admin-report
|
||||
```
|
||||
|
||||
The HTTP verifier requires a **dedicated QA administrator account** and explicitly
|
||||
checks its ID before proceeding: it creates one named QA room and signs out all
|
||||
sessions of that QA account. Never supply a human operator's normal key. It checks
|
||||
authentication, cookie properties, ordinary-host denial, directory privacy,
|
||||
session revocation and unchanged game/seat continuation. Private reports retain
|
||||
room IDs and safe outcomes, never keys/cookies. The container verifier provisions
|
||||
its own disposable QA account and checks admin-session continuity across restart
|
||||
and replacement, followed by the HTTP flow and HTTPS-proxy cookie behavior.
|
||||
|
||||
Local browser checks cover sign-in, directory filters, refresh restoration and
|
||||
mobile layout. Final source-bound checks and separately recorded deployed
|
||||
acceptance accompany each checkpoint; a local pass is not production evidence.
|
||||
|
||||
## Behavior and safeguards
|
||||
|
||||
Every privileged endpoint must authenticate and authorize its caller. Support
|
||||
|
||||
@@ -9,9 +9,13 @@ Expansion starts and full rules/publication acceptance remain gated.**
|
||||
The user added a complete [administration panel](ADMIN_PANEL.md) to the goal on
|
||||
22 September: secure admin access, room creation/removal and lifecycle controls,
|
||||
participant support, backup/restore, operational views and audited actions.
|
||||
This is required scope, not implemented functionality, and belongs before full
|
||||
AI refinement/calibration. Existing rules, Git, subagent and preservation
|
||||
requirements remain in force.
|
||||
The first prototype now connects separate administrator sign-in, persistent
|
||||
sessions/revocation and a searchable, filtered, paginated room directory at
|
||||
`/admin`. Private game contents remain excluded. Operator provisioning and
|
||||
recovery instructions are in the admin guide. Room creation, lifecycle/removal,
|
||||
participant support, backups and operational controls remain unfinished; these
|
||||
belong before full AI refinement/calibration. Existing rules, Git, subagent and
|
||||
preservation requirements remain in force.
|
||||
|
||||
The goal also requires testing the deployed application at
|
||||
**https://dune.procrastination.games**. Match the running revision, exercise
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 22 September 2026 — Administrator access and room directory prototype
|
||||
|
||||
The first [admin panel](ADMIN_PANEL.md#access-and-directory-prototype) slice adds
|
||||
personal operator-provisioned keys, live owner/operator/viewer permissions,
|
||||
separate persistent eight-hour sessions, current/all-session sign-out and a
|
||||
privacy-preserving room directory. Additive schema/trigger changes protect the
|
||||
last enabled owner, invalidate disabled-account sessions and retain safe audit
|
||||
events. Filters, paging, public roster inspection, refresh and mobile controls
|
||||
are connected; no existing game state is rewritten.
|
||||
|
||||
Independent review found and resolved malformed-roster search failure and a
|
||||
revocation race at directory reads. Focused auth/SQL/privacy tests, authenticated
|
||||
HTTP and local browser checks precede source-bound check/build/integration and
|
||||
deployed acceptance. Evidence and actual final results belong to the checkpoint
|
||||
report. Room lifecycle, creation, participant support, backups, audit browsing
|
||||
and complete administration acceptance remain open; AI refinement remains gated.
|
||||
|
||||
## 22 September 2026 — Administration and deployed verification added to the goal
|
||||
|
||||
The user requested a complete [admin panel](ADMIN_PANEL.md), including adding
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
CREATE TABLE `admin_accounts` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`role` text NOT NULL,
|
||||
`key_hash` text NOT NULL,
|
||||
`enabled` integer DEFAULT 1 NOT NULL,
|
||||
`session_generation` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
CONSTRAINT "admin_accounts_role" CHECK("admin_accounts"."role" IN ('owner', 'operator', 'viewer')),
|
||||
CONSTRAINT "admin_accounts_enabled" CHECK("admin_accounts"."enabled" IN (0, 1)),
|
||||
CONSTRAINT "admin_accounts_generation" CHECK("admin_accounts"."session_generation" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `admin_accounts_key_hash_unique` ON `admin_accounts` (`key_hash`);--> statement-breakpoint
|
||||
CREATE TABLE `admin_audit` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`actor_admin_id` text,
|
||||
`target_admin_id` text NOT NULL,
|
||||
`action` text NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`detail` text DEFAULT '{}' NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `admin_audit_created_at` ON `admin_audit` (`created_at`);--> statement-breakpoint
|
||||
CREATE TABLE `admin_sessions` (
|
||||
`token_hash` text PRIMARY KEY NOT NULL,
|
||||
`id` text NOT NULL,
|
||||
`admin_id` text NOT NULL,
|
||||
`generation` integer NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
`revoked_at` integer,
|
||||
FOREIGN KEY (`admin_id`) REFERENCES `admin_accounts`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `admin_sessions_id_unique` ON `admin_sessions` (`id`);--> statement-breakpoint
|
||||
CREATE INDEX `admin_sessions_account` ON `admin_sessions` (`admin_id`);--> statement-breakpoint
|
||||
CREATE INDEX `rooms_updated_at` ON `rooms` (`updated_at`);
|
||||
--> statement-breakpoint
|
||||
-- D1 operator SQL must be atomic without client-managed BEGIN/COMMIT.
|
||||
-- Drizzle records the table schema; these SQLite triggers retain the account
|
||||
-- audit and revocation invariants for every writer, including the setup CLI.
|
||||
CREATE TRIGGER `admin_accounts_provision_audit`
|
||||
AFTER INSERT ON `admin_accounts`
|
||||
BEGIN
|
||||
INSERT INTO admin_audit (id, actor_admin_id, target_admin_id, action, created_at, detail)
|
||||
VALUES (lower(hex(randomblob(16))), NULL, NEW.id, 'provision', unixepoch() * 1000,
|
||||
json_object('role', NEW.role));
|
||||
END;
|
||||
--> statement-breakpoint
|
||||
CREATE TRIGGER `admin_accounts_last_owner_update`
|
||||
BEFORE UPDATE OF enabled, role ON `admin_accounts`
|
||||
WHEN OLD.enabled = 1 AND OLD.role = 'owner'
|
||||
AND (NEW.enabled != 1 OR NEW.role != 'owner')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM admin_accounts WHERE id != OLD.id AND enabled = 1 AND role = 'owner'
|
||||
)
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'Cannot remove the final enabled administrator owner.');
|
||||
END;
|
||||
--> statement-breakpoint
|
||||
CREATE TRIGGER `admin_accounts_last_owner_delete`
|
||||
BEFORE DELETE ON `admin_accounts`
|
||||
WHEN OLD.enabled = 1 AND OLD.role = 'owner'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM admin_accounts WHERE id != OLD.id AND enabled = 1 AND role = 'owner'
|
||||
)
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'Cannot remove the final enabled administrator owner.');
|
||||
END;
|
||||
--> statement-breakpoint
|
||||
CREATE TRIGGER `admin_accounts_revoke`
|
||||
AFTER UPDATE OF enabled ON `admin_accounts`
|
||||
WHEN OLD.enabled = 1 AND NEW.enabled = 0
|
||||
BEGIN
|
||||
UPDATE admin_accounts SET session_generation = session_generation + 1 WHERE id = NEW.id;
|
||||
UPDATE admin_sessions SET revoked_at = unixepoch() * 1000
|
||||
WHERE admin_id = NEW.id AND revoked_at IS NULL;
|
||||
INSERT INTO admin_audit (id, actor_admin_id, target_admin_id, action, created_at, detail)
|
||||
VALUES (lower(hex(randomblob(16))), NULL, NEW.id, 'revoke', unixepoch() * 1000,
|
||||
json_object('previousRole', OLD.role));
|
||||
END;
|
||||
@@ -0,0 +1,982 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "cc440330-962f-474a-8bf1-25a2609c6e9a",
|
||||
"prevId": "9d40c7aa-918d-4da7-adc8-d6d8dc49d1c5",
|
||||
"tables": {
|
||||
"admin_accounts": {
|
||||
"name": "admin_accounts",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"key_hash": {
|
||||
"name": "key_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
},
|
||||
"session_generation": {
|
||||
"name": "session_generation",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"admin_accounts_key_hash_unique": {
|
||||
"name": "admin_accounts_key_hash_unique",
|
||||
"columns": [
|
||||
"key_hash"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {
|
||||
"admin_accounts_role": {
|
||||
"name": "admin_accounts_role",
|
||||
"value": "\"admin_accounts\".\"role\" IN ('owner', 'operator', 'viewer')"
|
||||
},
|
||||
"admin_accounts_enabled": {
|
||||
"name": "admin_accounts_enabled",
|
||||
"value": "\"admin_accounts\".\"enabled\" IN (0, 1)"
|
||||
},
|
||||
"admin_accounts_generation": {
|
||||
"name": "admin_accounts_generation",
|
||||
"value": "\"admin_accounts\".\"session_generation\" >= 0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin_audit": {
|
||||
"name": "admin_audit",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"actor_admin_id": {
|
||||
"name": "actor_admin_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"target_admin_id": {
|
||||
"name": "target_admin_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"detail": {
|
||||
"name": "detail",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'{}'"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"admin_audit_created_at": {
|
||||
"name": "admin_audit_created_at",
|
||||
"columns": [
|
||||
"created_at"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"admin_sessions": {
|
||||
"name": "admin_sessions",
|
||||
"columns": {
|
||||
"token_hash": {
|
||||
"name": "token_hash",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"admin_id": {
|
||||
"name": "admin_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"generation": {
|
||||
"name": "generation",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"admin_sessions_id_unique": {
|
||||
"name": "admin_sessions_id_unique",
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"admin_sessions_account": {
|
||||
"name": "admin_sessions_account",
|
||||
"columns": [
|
||||
"admin_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"admin_sessions_admin_id_admin_accounts_id_fk": {
|
||||
"name": "admin_sessions_admin_id_admin_accounts_id_fk",
|
||||
"tableFrom": "admin_sessions",
|
||||
"tableTo": "admin_accounts",
|
||||
"columnsFrom": [
|
||||
"admin_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"room_entry_receipts": {
|
||||
"name": "room_entry_receipts",
|
||||
"columns": {
|
||||
"operation_hash": {
|
||||
"name": "operation_hash",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"request_hash": {
|
||||
"name": "request_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_hash": {
|
||||
"name": "session_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"room_code": {
|
||||
"name": "room_code",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"player_id": {
|
||||
"name": "player_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"room_entry_receipts_room_code_rooms_code_fk": {
|
||||
"name": "room_entry_receipts_room_code_rooms_code_fk",
|
||||
"tableFrom": "room_entry_receipts",
|
||||
"tableTo": "rooms",
|
||||
"columnsFrom": [
|
||||
"room_code"
|
||||
],
|
||||
"columnsTo": [
|
||||
"code"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"room_messages": {
|
||||
"name": "room_messages",
|
||||
"columns": {
|
||||
"sequence": {
|
||||
"name": "sequence",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"room_code": {
|
||||
"name": "room_code",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sender_id": {
|
||||
"name": "sender_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sender_session_hash": {
|
||||
"name": "sender_session_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sender_name": {
|
||||
"name": "sender_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sender_faction": {
|
||||
"name": "sender_faction",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"recipient_id": {
|
||||
"name": "recipient_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"recipient_name": {
|
||||
"name": "recipient_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"body": {
|
||||
"name": "body",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"room_messages_room_id": {
|
||||
"name": "room_messages_room_id",
|
||||
"columns": [
|
||||
"room_code",
|
||||
"id"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"room_messages_channel": {
|
||||
"name": "room_messages_channel",
|
||||
"columns": [
|
||||
"room_code",
|
||||
"recipient_id",
|
||||
"sequence"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"room_messages_sender": {
|
||||
"name": "room_messages_sender",
|
||||
"columns": [
|
||||
"room_code",
|
||||
"sender_id",
|
||||
"created_at"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"room_messages_room_code_rooms_code_fk": {
|
||||
"name": "room_messages_room_code_rooms_code_fk",
|
||||
"tableFrom": "room_messages",
|
||||
"tableTo": "rooms",
|
||||
"columnsFrom": [
|
||||
"room_code"
|
||||
],
|
||||
"columnsTo": [
|
||||
"code"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"rooms": {
|
||||
"name": "rooms",
|
||||
"columns": {
|
||||
"code": {
|
||||
"name": "code",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"rooms_updated_at": {
|
||||
"name": "rooms_updated_at",
|
||||
"columns": [
|
||||
"updated_at"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"seat_ai_delegations": {
|
||||
"name": "seat_ai_delegations",
|
||||
"columns": {
|
||||
"room_code": {
|
||||
"name": "room_code",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"owner_id": {
|
||||
"name": "owner_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"grant_id": {
|
||||
"name": "grant_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"delegate_id": {
|
||||
"name": "delegate_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"difficulty": {
|
||||
"name": "difficulty",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"owner_session_hash": {
|
||||
"name": "owner_session_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"delegate_session_hash": {
|
||||
"name": "delegate_session_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"used_at": {
|
||||
"name": "used_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"seat_ai_delegations_grant_id_unique": {
|
||||
"name": "seat_ai_delegations_grant_id_unique",
|
||||
"columns": [
|
||||
"grant_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"seat_ai_delegations_room_code_rooms_code_fk": {
|
||||
"name": "seat_ai_delegations_room_code_rooms_code_fk",
|
||||
"tableFrom": "seat_ai_delegations",
|
||||
"tableTo": "rooms",
|
||||
"columnsFrom": [
|
||||
"room_code"
|
||||
],
|
||||
"columnsTo": [
|
||||
"code"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"seat_ai_delegations_room_code_owner_id_grant_id_pk": {
|
||||
"columns": [
|
||||
"room_code",
|
||||
"owner_id",
|
||||
"grant_id"
|
||||
],
|
||||
"name": "seat_ai_delegations_room_code_owner_id_grant_id_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"seat_handover_claim_receipts": {
|
||||
"name": "seat_handover_claim_receipts",
|
||||
"columns": {
|
||||
"room_code": {
|
||||
"name": "room_code",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"player_id": {
|
||||
"name": "player_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"operation_hash": {
|
||||
"name": "operation_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"offer_hash": {
|
||||
"name": "offer_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"secret_hash": {
|
||||
"name": "secret_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_hash": {
|
||||
"name": "session_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"claim_fence": {
|
||||
"name": "claim_fence",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"claimed_at": {
|
||||
"name": "claimed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"seat_handover_claim_receipts_claim_fence_unique": {
|
||||
"name": "seat_handover_claim_receipts_claim_fence_unique",
|
||||
"columns": [
|
||||
"claim_fence"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"seat_handover_claim_receipts_room_code_rooms_code_fk": {
|
||||
"name": "seat_handover_claim_receipts_room_code_rooms_code_fk",
|
||||
"tableFrom": "seat_handover_claim_receipts",
|
||||
"tableTo": "rooms",
|
||||
"columnsFrom": [
|
||||
"room_code"
|
||||
],
|
||||
"columnsTo": [
|
||||
"code"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"seat_handover_claim_receipts_room_code_player_id_operation_hash_pk": {
|
||||
"columns": [
|
||||
"room_code",
|
||||
"player_id",
|
||||
"operation_hash"
|
||||
],
|
||||
"name": "seat_handover_claim_receipts_room_code_player_id_operation_hash_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"seat_handover_offers": {
|
||||
"name": "seat_handover_offers",
|
||||
"columns": {
|
||||
"room_code": {
|
||||
"name": "room_code",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"player_id": {
|
||||
"name": "player_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"offer_hash": {
|
||||
"name": "offer_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"secret_hash": {
|
||||
"name": "secret_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"issuer_session_hash": {
|
||||
"name": "issuer_session_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"claim_operation_hash": {
|
||||
"name": "claim_operation_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_hash": {
|
||||
"name": "session_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"claimed_at": {
|
||||
"name": "claimed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"seat_handover_offers_offer_hash_unique": {
|
||||
"name": "seat_handover_offers_offer_hash_unique",
|
||||
"columns": [
|
||||
"offer_hash"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"seat_handover_offers_room_code_rooms_code_fk": {
|
||||
"name": "seat_handover_offers_room_code_rooms_code_fk",
|
||||
"tableFrom": "seat_handover_offers",
|
||||
"tableTo": "rooms",
|
||||
"columnsFrom": [
|
||||
"room_code"
|
||||
],
|
||||
"columnsTo": [
|
||||
"code"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"seat_handover_offers_room_code_player_id_pk": {
|
||||
"columns": [
|
||||
"room_code",
|
||||
"player_id"
|
||||
],
|
||||
"name": "seat_handover_offers_room_code_player_id_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"seat_recovery_keys": {
|
||||
"name": "seat_recovery_keys",
|
||||
"columns": {
|
||||
"room_code": {
|
||||
"name": "room_code",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"player_id": {
|
||||
"name": "player_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"recovery_hash": {
|
||||
"name": "recovery_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"current_operation_hash": {
|
||||
"name": "current_operation_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"seat_recovery_keys_room_code_rooms_code_fk": {
|
||||
"name": "seat_recovery_keys_room_code_rooms_code_fk",
|
||||
"tableFrom": "seat_recovery_keys",
|
||||
"tableTo": "rooms",
|
||||
"columnsFrom": [
|
||||
"room_code"
|
||||
],
|
||||
"columnsTo": [
|
||||
"code"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"seat_recovery_keys_room_code_player_id_pk": {
|
||||
"columns": [
|
||||
"room_code",
|
||||
"player_id"
|
||||
],
|
||||
"name": "seat_recovery_keys_room_code_player_id_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"seat_recovery_receipts": {
|
||||
"name": "seat_recovery_receipts",
|
||||
"columns": {
|
||||
"room_code": {
|
||||
"name": "room_code",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"player_id": {
|
||||
"name": "player_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"operation_hash": {
|
||||
"name": "operation_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"recovery_hash": {
|
||||
"name": "recovery_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_hash": {
|
||||
"name": "session_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"seat_recovery_receipts_room_code_rooms_code_fk": {
|
||||
"name": "seat_recovery_receipts_room_code_rooms_code_fk",
|
||||
"tableFrom": "seat_recovery_receipts",
|
||||
"tableTo": "rooms",
|
||||
"columnsFrom": [
|
||||
"room_code"
|
||||
],
|
||||
"columnsTo": [
|
||||
"code"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"seat_recovery_receipts_room_code_player_id_operation_hash_pk": {
|
||||
"columns": [
|
||||
"room_code",
|
||||
"player_id",
|
||||
"operation_hash"
|
||||
],
|
||||
"name": "seat_recovery_receipts_room_code_player_id_operation_hash_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"seats": {
|
||||
"name": "seats",
|
||||
"columns": {
|
||||
"token_hash": {
|
||||
"name": "token_hash",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"revoked": {
|
||||
"name": "revoked",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"room_code": {
|
||||
"name": "room_code",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"player_id": {
|
||||
"name": "player_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"seats_room_code_rooms_code_fk": {
|
||||
"name": "seats_room_code_rooms_code_fk",
|
||||
"tableFrom": "seats",
|
||||
"tableTo": "rooms",
|
||||
"columnsFrom": [
|
||||
"room_code"
|
||||
],
|
||||
"columnsTo": [
|
||||
"code"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,13 @@
|
||||
"when": 1789947127493,
|
||||
"tag": "0007_clammy_sprite",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1790032690330,
|
||||
"tag": "0008_admin_access",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Public operational metadata only; never a player view or a saved game. */
|
||||
export type AdminRoom = {
|
||||
code: string;
|
||||
version: number;
|
||||
updatedAt: number;
|
||||
status: 'lobby' | 'setup' | 'playing' | 'finished' | 'unreadable';
|
||||
host: string | null;
|
||||
players: { id: string; name: string; faction: string; control: 'human' | 'ai' | 'autopilot' }[];
|
||||
advanced: boolean;
|
||||
preview: boolean;
|
||||
expansions: string[];
|
||||
modules: string[];
|
||||
turn: number | null;
|
||||
phase: string;
|
||||
pending: { label: string; owners: string[] };
|
||||
};
|
||||
export type AdminDirectory = {
|
||||
rooms: AdminRoom[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { requestOrigin } from './request-origin';
|
||||
import { AdminError } from '../db/admin-access';
|
||||
|
||||
const cookieName = 'dune_admin_session';
|
||||
export function adminToken(request: Request): string | undefined {
|
||||
return request.headers.get('cookie')?.split(';').map(x => x.trim()).find(x => x.startsWith(cookieName + '='))?.slice(cookieName.length + 1);
|
||||
}
|
||||
export function adminCookie(request: Request, token: string, configuredOrigin?: string): string {
|
||||
const secure = new URL(requestOrigin(request, configuredOrigin)).protocol === 'https:';
|
||||
return `${cookieName}=${token}; Path=/api/admin; HttpOnly; SameSite=Strict; Max-Age=${token ? 28800 : 0}${secure ? '; Secure' : ''}`;
|
||||
}
|
||||
export async function adminBody(request: Request, configuredOrigin?: string): Promise<Record<string, unknown>> {
|
||||
// Require a browser's exact configured origin even for sign-in, preventing login CSRF.
|
||||
if (request.headers.get('origin') !== requestOrigin(request, configuredOrigin) ||
|
||||
request.headers.get('sec-fetch-site') === 'cross-site') throw new AdminError('Invalid request origin.', 403);
|
||||
if (request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') throw new AdminError('Send a JSON request.', 415);
|
||||
const reader = request.body?.getReader();
|
||||
if (!reader) throw new AdminError('Enter the request details.', 400);
|
||||
let size = 0;
|
||||
const chunks: Uint8Array[] = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > 2048) { void reader.cancel(); throw new AdminError('Request too large.', 413); }
|
||||
chunks.push(value);
|
||||
}
|
||||
const bytes = new Uint8Array(size);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
|
||||
const data: unknown = JSON.parse(new TextDecoder().decode(bytes));
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new AdminError('Invalid request.', 400);
|
||||
return data as Record<string, unknown>;
|
||||
}
|
||||
export function adminResponse(value: unknown, extra: HeadersInit = {}) {
|
||||
const headers = new Headers(extra);
|
||||
headers.set('Cache-Control', 'no-store');
|
||||
headers.set('Vary', 'Cookie');
|
||||
headers.set('X-Content-Type-Options', 'nosniff');
|
||||
return Response.json(value, { headers });
|
||||
}
|
||||
export function adminFailure(error: unknown) {
|
||||
const status = error instanceof AdminError ? error.status : error instanceof SyntaxError ? 400 : 503;
|
||||
return Response.json({ error: error instanceof AdminError ? error.message : error instanceof SyntaxError ? 'Invalid JSON.' : 'Administration is temporarily unavailable. Try again.' },
|
||||
{ status, headers: { 'Cache-Control': 'no-store', 'Vary': 'Cookie', 'X-Content-Type-Options': 'nosniff' } });
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { adminLogin, requireAdmin } from '../db/admin-access';
|
||||
import { adminStore, sha256 } from './admin-access-fixture';
|
||||
|
||||
const checkout = fileURLToPath(new URL('../', import.meta.url));
|
||||
const cli = fileURLToPath(
|
||||
new URL('../tools/admin-access.mjs', import.meta.url),
|
||||
);
|
||||
const run = (...args: string[]) =>
|
||||
spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8' });
|
||||
|
||||
void test('operator CLI writes a unique private key outside the checkout and SQL provisions with safely escaped names', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'dune-admin-cli-'));
|
||||
const output = join(directory, 'owner');
|
||||
const store = adminStore();
|
||||
try {
|
||||
assert.ok(store.sqlite instanceof DatabaseSync);
|
||||
const name = "O'Brien'); DROP TABLE rooms; --";
|
||||
const result = run('--name', name, '--role', 'owner', '--out', output);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.deepEqual(readdirSync(output).sort(), [
|
||||
'access-key.txt',
|
||||
'provision.sql',
|
||||
]);
|
||||
assert.equal(statSync(output).mode & 0o777, 0o700);
|
||||
for (const file of ['access-key.txt', 'provision.sql'])
|
||||
assert.equal(statSync(join(output, file)).mode & 0o777, 0o600);
|
||||
const key = readFileSync(join(output, 'access-key.txt'), 'utf8').trim();
|
||||
const sql = readFileSync(join(output, 'provision.sql'), 'utf8');
|
||||
assert.match(key, /^dune-admin\.[0-9a-f-]{36}\.[0-9a-f]{64}$/);
|
||||
assert.equal(sql.includes(key), false);
|
||||
assert.equal(sql.includes(key.split('.').at(-1)!), false);
|
||||
assert.equal(sql.includes(sha256(key)), true);
|
||||
assert.equal((result.stdout + result.stderr).includes(key), false);
|
||||
assert.equal((result.stdout + result.stderr).includes(sha256(key)), false);
|
||||
store.sqlite.exec(sql);
|
||||
assert.equal(
|
||||
store.sqlite.prepare('SELECT name FROM admin_accounts').get()!.name,
|
||||
name,
|
||||
);
|
||||
assert.equal(
|
||||
store.sqlite.prepare('SELECT count(*) AS n FROM rooms').get()!.n,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
store.sqlite
|
||||
.prepare(
|
||||
"SELECT count(*) AS n FROM admin_audit WHERE action = 'provision'",
|
||||
)
|
||||
.get()!.n,
|
||||
1,
|
||||
);
|
||||
const login = await adminLogin(store.database, key, 1000);
|
||||
await requireAdmin(store.database, login.token, ['owner'], 2000);
|
||||
assert.throws(() => store.sqlite.exec(sql), /UNIQUE constraint/);
|
||||
assert.equal(
|
||||
store.sqlite
|
||||
.prepare(
|
||||
"SELECT count(*) AS n FROM admin_audit WHERE action = 'provision'",
|
||||
)
|
||||
.get()!.n,
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
store.sqlite.close();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
void test('operator CLI revocation needs only a safe account ID, revokes sessions and refuses the final enabled owner', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'dune-admin-revoke-cli-'));
|
||||
const store = adminStore();
|
||||
try {
|
||||
const first = store.provision();
|
||||
const second = store.provision();
|
||||
const login = await adminLogin(store.database, first.key, 1000);
|
||||
const output = join(directory, 'revoke');
|
||||
const result = run('--revoke', first.id, '--out', output);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.deepEqual(readdirSync(output), ['revoke.sql']);
|
||||
const sql = readFileSync(join(output, 'revoke.sql'), 'utf8');
|
||||
assert.equal(
|
||||
(sql + result.stdout + result.stderr).includes(first.key),
|
||||
false,
|
||||
);
|
||||
assert.equal(sql.includes('key_hash'), false);
|
||||
store.sqlite.exec(sql);
|
||||
await assert.rejects(
|
||||
requireAdmin(store.database, login.token, undefined, 2000),
|
||||
);
|
||||
await assert.rejects(adminLogin(store.database, first.key, 2000));
|
||||
assert.equal(
|
||||
store.sqlite
|
||||
.prepare(
|
||||
"SELECT count(*) AS n FROM admin_audit WHERE action = 'revoke'",
|
||||
)
|
||||
.get()!.n,
|
||||
1,
|
||||
);
|
||||
store.sqlite.exec(sql);
|
||||
assert.equal(
|
||||
store.sqlite
|
||||
.prepare(
|
||||
"SELECT count(*) AS n FROM admin_audit WHERE action = 'revoke'",
|
||||
)
|
||||
.get()!.n,
|
||||
1,
|
||||
);
|
||||
const lastOutput = join(directory, 'last');
|
||||
assert.equal(run('--revoke', second.id, '--out', lastOutput).status, 0);
|
||||
assert.throws(
|
||||
() =>
|
||||
store.sqlite.exec(readFileSync(join(lastOutput, 'revoke.sql'), 'utf8')),
|
||||
/final enabled administrator owner/,
|
||||
);
|
||||
assert.equal(
|
||||
store.sqlite
|
||||
.prepare('SELECT enabled FROM admin_accounts WHERE id = ?')
|
||||
.get(second.id)!.enabled,
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
store.sqlite.close();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
void test('operator CLI refuses existing paths, checkout paths and symlink aliases without overwriting files', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'dune-admin-paths-'));
|
||||
const inside = join(checkout, '.admin-test-output');
|
||||
try {
|
||||
assert.equal(existsSync(inside), false);
|
||||
const direct = run('--name', 'Owner', '--role', 'owner', '--out', inside);
|
||||
assert.equal(direct.status, 1);
|
||||
assert.equal(existsSync(inside), false);
|
||||
const alias = join(directory, 'checkout-alias');
|
||||
symlinkSync(checkout, alias, 'dir');
|
||||
const viaAlias = run(
|
||||
'--name',
|
||||
'Owner',
|
||||
'--role',
|
||||
'owner',
|
||||
'--out',
|
||||
join(alias, '.admin-test-output'),
|
||||
);
|
||||
assert.equal(viaAlias.status, 1);
|
||||
assert.equal(existsSync(inside), false);
|
||||
const existing = join(directory, 'existing');
|
||||
mkdirSync(existing, { mode: 0o700 });
|
||||
writeFileSync(join(existing, 'access-key.txt'), 'existing private value', {
|
||||
mode: 0o600,
|
||||
});
|
||||
const reuse = run('--name', 'Owner', '--role', 'owner', '--out', existing);
|
||||
assert.equal(reuse.status, 1);
|
||||
assert.equal(
|
||||
readFileSync(join(existing, 'access-key.txt'), 'utf8'),
|
||||
'existing private value',
|
||||
);
|
||||
assert.equal(
|
||||
(reuse.stdout + reuse.stderr).includes('existing private value'),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(readdirSync(existing), ['access-key.txt']);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
void test('operator CLI rejects malformed names, roles, IDs and incomplete commands before generating any credential', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'dune-admin-input-'));
|
||||
try {
|
||||
const output = join(directory, 'new');
|
||||
for (const args of [
|
||||
[],
|
||||
['--name', 'Owner', '--out', output],
|
||||
['--name', 'Owner', '--role', 'superuser', '--out', output],
|
||||
['--name', ' ', '--role', 'owner', '--out', output],
|
||||
['--name', 'Owner\nInjected', '--role', 'owner', '--out', output],
|
||||
['--name', 'x'.repeat(81), '--role', 'owner', '--out', output],
|
||||
[
|
||||
'--name',
|
||||
'Owner',
|
||||
'--role',
|
||||
'owner',
|
||||
'--role',
|
||||
'viewer',
|
||||
'--out',
|
||||
output,
|
||||
],
|
||||
['--name', 'Owner', '--role', 'owner', '--out', 'relative-output'],
|
||||
['--revoke', "x' OR 1=1;--", '--out', output],
|
||||
[
|
||||
'--revoke',
|
||||
'10000000-0000-4000-8000-000000000001',
|
||||
'--name',
|
||||
'Owner',
|
||||
'--out',
|
||||
output,
|
||||
],
|
||||
]) {
|
||||
assert.equal(run(...args).status, 1);
|
||||
assert.equal(existsSync(output), false);
|
||||
}
|
||||
const help = run('--help');
|
||||
assert.equal(help.status, 0);
|
||||
assert.match(help.stdout, /No database is contacted/);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import type { AdminRole } from '../db/admin-access';
|
||||
|
||||
export function adminStore(path = ':memory:', migrate = true) {
|
||||
const sqlite = new DatabaseSync(path);
|
||||
sqlite.exec('PRAGMA foreign_keys = ON');
|
||||
if (migrate) {
|
||||
for (const file of readdirSync(new URL('../drizzle/', import.meta.url))
|
||||
.filter((file) => file.endsWith('.sql'))
|
||||
.sort())
|
||||
sqlite.exec(
|
||||
readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'),
|
||||
);
|
||||
}
|
||||
const hooks: { beforeBatch?: () => Promise<void> } = {};
|
||||
class Statement {
|
||||
values: (string | number | null)[] = [];
|
||||
constructor(readonly sql: string) {}
|
||||
bind(...values: (string | number | null)[]) {
|
||||
this.values = values;
|
||||
return this;
|
||||
}
|
||||
async first() {
|
||||
return sqlite.prepare(this.sql).get(...this.values) ?? null;
|
||||
}
|
||||
async all() {
|
||||
return {
|
||||
results: sqlite.prepare(this.sql).all(...this.values),
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
async run() {
|
||||
const statement = sqlite.prepare(this.sql);
|
||||
if (statement.columns().length) {
|
||||
return {
|
||||
results: statement.all(...this.values),
|
||||
meta: { changes: 0 },
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
const result = statement.run(...this.values);
|
||||
return {
|
||||
results: [],
|
||||
meta: { changes: Number(result.changes) },
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
const database = {
|
||||
prepare: (sql: string) => new Statement(sql),
|
||||
batch: async (statements: Statement[]) => {
|
||||
await hooks.beforeBatch?.();
|
||||
sqlite.exec('BEGIN');
|
||||
try {
|
||||
const results = [];
|
||||
for (const statement of statements) results.push(await statement.run());
|
||||
sqlite.exec('COMMIT');
|
||||
return results;
|
||||
} catch (error) {
|
||||
sqlite.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
} as unknown as D1Database;
|
||||
function provision(role: AdminRole = 'owner', name = 'Admin') {
|
||||
const id = randomUUID();
|
||||
const key = `dune-admin.${id}.${randomBytes(32).toString('hex')}`;
|
||||
sqlite
|
||||
.prepare(`INSERT INTO admin_accounts
|
||||
(id,name,role,key_hash,created_at,updated_at) VALUES (?,?,?,?,?,?)`)
|
||||
.run(id, name, role, sha256(key), 1000, 1000);
|
||||
return { id, key, name, role };
|
||||
}
|
||||
return { sqlite, database, hooks, provision };
|
||||
}
|
||||
|
||||
export const sha256 = (value: string) =>
|
||||
createHash('sha256').update(value).digest('hex');
|
||||
|
||||
export function pauseAdminBatch(store: ReturnType<typeof adminStore>) {
|
||||
let entered!: () => void;
|
||||
let release!: () => void;
|
||||
const waiting = new Promise<void>((resolve) => (entered = resolve));
|
||||
const gate = new Promise<void>((resolve) => (release = resolve));
|
||||
store.hooks.beforeBatch = async () => {
|
||||
delete store.hooks.beforeBatch;
|
||||
entered();
|
||||
await gate;
|
||||
};
|
||||
return { waiting, release };
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import {
|
||||
ADMIN_SESSION_DURATION_MS,
|
||||
AdminError,
|
||||
adminLogin,
|
||||
adminLogout,
|
||||
adminLogoutAll,
|
||||
requireAdmin,
|
||||
} from '../db/admin-access';
|
||||
import { adminStore, pauseAdminBatch, sha256 } from './admin-access-fixture';
|
||||
|
||||
const denied =
|
||||
(status = 401) =>
|
||||
(error: unknown) =>
|
||||
error instanceof AdminError && error.status === status;
|
||||
function snapshot(store: ReturnType<typeof adminStore>) {
|
||||
return JSON.stringify(
|
||||
['admin_accounts', 'admin_sessions', 'admin_audit'].map((table) =>
|
||||
store.sqlite.prepare(`SELECT * FROM ${table} ORDER BY rowid`).all(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void test('admin migration is additive and preserves preexisting room and seat bytes', () => {
|
||||
const sqlite = new DatabaseSync(':memory:');
|
||||
try {
|
||||
for (const file of readdirSync(new URL('../drizzle/', import.meta.url))
|
||||
.filter((file) => file.endsWith('.sql') && file < '0008')
|
||||
.sort())
|
||||
sqlite.exec(
|
||||
readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'),
|
||||
);
|
||||
sqlite
|
||||
.prepare(
|
||||
'INSERT INTO rooms (code,state,version,updated_at) VALUES (?,?,?,?)',
|
||||
)
|
||||
.run('PRIOR001', '{"private":"sealed","version":17}', 17, 9000);
|
||||
sqlite
|
||||
.prepare(
|
||||
'INSERT INTO seats (token_hash,revoked,room_code,player_id) VALUES (?,?,?,?)',
|
||||
)
|
||||
.run('c1'.repeat(32), 0, 'PRIOR001', 'saved-player');
|
||||
const before = JSON.stringify([
|
||||
sqlite.prepare('SELECT * FROM rooms').all(),
|
||||
sqlite.prepare('SELECT * FROM seats').all(),
|
||||
]);
|
||||
sqlite.exec(
|
||||
readFileSync(
|
||||
new URL('../drizzle/0008_admin_access.sql', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
JSON.stringify([
|
||||
sqlite.prepare('SELECT * FROM rooms').all(),
|
||||
sqlite.prepare('SELECT * FROM seats').all(),
|
||||
]),
|
||||
before,
|
||||
);
|
||||
assert.equal(
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT count(*) AS n FROM sqlite_master WHERE name = 'rooms_updated_at' AND type = 'index'",
|
||||
)
|
||||
.get()!.n,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
sqlite.prepare('SELECT count(*) AS n FROM admin_accounts').get()!.n,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('malformed credentials fail before querying D1, including a trailing line break', async () => {
|
||||
const database = {
|
||||
prepare: () => {
|
||||
throw new Error('Unexpected database query');
|
||||
},
|
||||
} as unknown as D1Database;
|
||||
const key = `dune-admin.10000000-0000-4000-8000-000000000001.${'a'.repeat(64)}`;
|
||||
for (const malformed of [`${key}\n`, `${key}\r\n`, ` ${key}`, {}, null])
|
||||
await assert.rejects(adminLogin(database, malformed, 1000), denied());
|
||||
for (const malformed of ['a'.repeat(64) + '\n', 'A'.repeat(64), undefined])
|
||||
await assert.rejects(
|
||||
requireAdmin(database, malformed, undefined, 1000),
|
||||
denied(),
|
||||
);
|
||||
});
|
||||
|
||||
void test('admin login persists only hashes, preserves audit privacy and survives a database restart', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'dune-admin-restart-'));
|
||||
const path = join(directory, 'admin.sqlite');
|
||||
let store = adminStore(path);
|
||||
try {
|
||||
assert.ok(store.sqlite instanceof DatabaseSync);
|
||||
const owner = store.provision();
|
||||
const login = await adminLogin(store.database, owner.key, 1000);
|
||||
assert.deepEqual(login.admin, {
|
||||
id: owner.id,
|
||||
name: owner.name,
|
||||
role: owner.role,
|
||||
});
|
||||
assert.equal(login.expiresAt, 1000 + ADMIN_SESSION_DURATION_MS);
|
||||
assert.match(login.token, /^[0-9a-f]{64}$/);
|
||||
const second = await adminLogin(store.database, owner.key, 2000);
|
||||
assert.notEqual(second.token, login.token);
|
||||
const saved = snapshot(store);
|
||||
assert.equal(saved.includes(owner.key), false);
|
||||
assert.equal(saved.includes(login.token), false);
|
||||
assert.equal(saved.includes(second.token), false);
|
||||
assert.ok(saved.includes(sha256(owner.key)));
|
||||
assert.ok(saved.includes(sha256(login.token)));
|
||||
const audit = JSON.stringify(
|
||||
store.sqlite.prepare('SELECT * FROM admin_audit').all(),
|
||||
);
|
||||
assert.equal(audit.includes(sha256(owner.key)), false);
|
||||
assert.equal(audit.includes(sha256(login.token)), false);
|
||||
assert.equal(audit.includes('sessionId'), true);
|
||||
store.sqlite.close();
|
||||
store = adminStore(path, false);
|
||||
assert.deepEqual(
|
||||
await requireAdmin(store.database, login.token, ['owner'], 3000),
|
||||
{
|
||||
...login.admin,
|
||||
sessionHash: sha256(login.token),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
store.sqlite.close();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
void test('absolute expiry cannot be extended by authenticated reads', async () => {
|
||||
const store = adminStore();
|
||||
try {
|
||||
const owner = store.provision();
|
||||
const login = await adminLogin(store.database, owner.key, 5000);
|
||||
await requireAdmin(
|
||||
store.database,
|
||||
login.token,
|
||||
undefined,
|
||||
login.expiresAt - 1,
|
||||
);
|
||||
const before = snapshot(store);
|
||||
await assert.rejects(
|
||||
requireAdmin(store.database, login.token, undefined, login.expiresAt),
|
||||
denied(),
|
||||
);
|
||||
await assert.rejects(
|
||||
requireAdmin(store.database, login.token, undefined, login.expiresAt + 1),
|
||||
denied(),
|
||||
);
|
||||
assert.equal(snapshot(store), before);
|
||||
} finally {
|
||||
store.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('roles are checked against the current account and ordinary saved-seat cookies never grant admin access', async () => {
|
||||
const store = adminStore();
|
||||
try {
|
||||
const owner = store.provision();
|
||||
store.provision('owner', 'Other owner');
|
||||
const login = await adminLogin(store.database, owner.key, 1000);
|
||||
store.sqlite
|
||||
.prepare("UPDATE admin_accounts SET role = 'viewer' WHERE id = ?")
|
||||
.run(owner.id);
|
||||
assert.equal(
|
||||
(await requireAdmin(store.database, login.token, ['viewer'], 2000)).role,
|
||||
'viewer',
|
||||
);
|
||||
await assert.rejects(
|
||||
requireAdmin(store.database, login.token, ['owner', 'operator'], 2000),
|
||||
denied(403),
|
||||
);
|
||||
await assert.rejects(
|
||||
requireAdmin(store.database, login.token, [], 2000),
|
||||
denied(403),
|
||||
);
|
||||
const seatToken = 'ea'.repeat(32);
|
||||
store.sqlite
|
||||
.prepare('INSERT INTO rooms (code,state,updated_at) VALUES (?,?,?)')
|
||||
.run('ADMINQA1', '{"saved":"private"}', 1000);
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO seats (token_hash,room_code,player_id) VALUES (?,?,?)',
|
||||
)
|
||||
.run(sha256(seatToken), 'ADMINQA1', owner.id);
|
||||
const before = snapshot(store);
|
||||
await assert.rejects(
|
||||
requireAdmin(store.database, seatToken, undefined, 2000),
|
||||
denied(),
|
||||
);
|
||||
await assert.rejects(adminLogin(store.database, seatToken, 2000), denied());
|
||||
assert.equal(snapshot(store), before);
|
||||
assert.equal(
|
||||
store.sqlite.prepare('SELECT state FROM rooms').get()!.state,
|
||||
'{"saved":"private"}',
|
||||
);
|
||||
} finally {
|
||||
store.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('malformed and incorrect credentials are rejected without stored mutation or secret-bearing errors', async () => {
|
||||
const store = adminStore();
|
||||
try {
|
||||
const owner = store.provision();
|
||||
const before = snapshot(store);
|
||||
for (const key of [
|
||||
null,
|
||||
undefined,
|
||||
123,
|
||||
{},
|
||||
[],
|
||||
'',
|
||||
'shared-password',
|
||||
owner.key.toUpperCase(),
|
||||
` ${owner.key}`,
|
||||
`${owner.key}\n`,
|
||||
owner.key.slice(0, -1),
|
||||
`dune-admin.${owner.id}.${'0'.repeat(64)}`,
|
||||
]) {
|
||||
await assert.rejects(
|
||||
adminLogin(store.database, key, 1000),
|
||||
(error: unknown) => {
|
||||
assert.ok(denied()(error));
|
||||
assert.equal(String(error).includes(owner.key), false);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
for (const token of [
|
||||
undefined,
|
||||
'',
|
||||
owner.key,
|
||||
'a'.repeat(63),
|
||||
'A'.repeat(64),
|
||||
'a'.repeat(65),
|
||||
'a'.repeat(64) + '\n',
|
||||
])
|
||||
await assert.rejects(
|
||||
requireAdmin(store.database, token, undefined, 1000),
|
||||
denied(),
|
||||
);
|
||||
assert.equal(snapshot(store), before);
|
||||
} finally {
|
||||
store.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('single-session logout is persisted and idempotently audited without revoking the access key', async () => {
|
||||
const store = adminStore();
|
||||
try {
|
||||
const owner = store.provision();
|
||||
const first = await adminLogin(store.database, owner.key, 1000);
|
||||
const second = await adminLogin(store.database, owner.key, 1000);
|
||||
await adminLogout(store.database, first.token, 2000);
|
||||
await assert.rejects(
|
||||
requireAdmin(store.database, first.token, undefined, 2000),
|
||||
denied(),
|
||||
);
|
||||
await requireAdmin(store.database, second.token, undefined, 2000);
|
||||
const beforeRetry = snapshot(store);
|
||||
await adminLogout(store.database, first.token, 3000);
|
||||
await adminLogout(store.database, 'malformed', 3000);
|
||||
assert.equal(snapshot(store), beforeRetry);
|
||||
await adminLogin(store.database, owner.key, 3000);
|
||||
assert.equal(
|
||||
store.sqlite
|
||||
.prepare(
|
||||
"SELECT count(*) AS n FROM admin_audit WHERE action = 'logout'",
|
||||
)
|
||||
.get()!.n,
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
store.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('logout-all revokes only the authenticated account and its access key can open a fresh session', async () => {
|
||||
const store = adminStore();
|
||||
try {
|
||||
const owner = store.provision();
|
||||
const other = store.provision('viewer');
|
||||
const first = await adminLogin(store.database, owner.key, 1000);
|
||||
const second = await adminLogin(store.database, owner.key, 1000);
|
||||
const observer = await adminLogin(store.database, other.key, 1000);
|
||||
const identity = await requireAdmin(
|
||||
store.database,
|
||||
first.token,
|
||||
undefined,
|
||||
1000,
|
||||
);
|
||||
const beforeForged = snapshot(store);
|
||||
await assert.rejects(
|
||||
adminLogoutAll(store.database, { ...identity, id: other.id }, 2000),
|
||||
denied(),
|
||||
);
|
||||
assert.equal(snapshot(store), beforeForged);
|
||||
await adminLogoutAll(store.database, identity, 2000);
|
||||
for (const token of [first.token, second.token])
|
||||
await assert.rejects(
|
||||
requireAdmin(store.database, token, undefined, 2000),
|
||||
denied(),
|
||||
);
|
||||
await requireAdmin(store.database, observer.token, undefined, 2000);
|
||||
const beforeRetry = snapshot(store);
|
||||
await assert.rejects(
|
||||
adminLogoutAll(store.database, identity, 3000),
|
||||
denied(),
|
||||
);
|
||||
assert.equal(snapshot(store), beforeRetry);
|
||||
const fresh = await adminLogin(store.database, owner.key, 3000);
|
||||
await requireAdmin(store.database, fresh.token, undefined, 3000);
|
||||
assert.equal(
|
||||
store.sqlite
|
||||
.prepare(
|
||||
"SELECT count(*) AS n FROM admin_audit WHERE action = 'logout_all'",
|
||||
)
|
||||
.get()!.n,
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
store.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('logout-all rechecks expiry inside its mutation batch', async () => {
|
||||
const store = adminStore();
|
||||
try {
|
||||
const owner = store.provision();
|
||||
const login = await adminLogin(store.database, owner.key, 1000);
|
||||
const identity = await requireAdmin(
|
||||
store.database,
|
||||
login.token,
|
||||
undefined,
|
||||
1000,
|
||||
);
|
||||
const before = snapshot(store);
|
||||
await assert.rejects(
|
||||
adminLogoutAll(store.database, identity, login.expiresAt),
|
||||
denied(),
|
||||
);
|
||||
assert.equal(snapshot(store), before);
|
||||
} finally {
|
||||
store.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('a concurrent logout-all fences an already-authorized login before session creation', async () => {
|
||||
const store = adminStore();
|
||||
try {
|
||||
const owner = store.provision();
|
||||
const current = await adminLogin(store.database, owner.key, 1000);
|
||||
const identity = await requireAdmin(
|
||||
store.database,
|
||||
current.token,
|
||||
undefined,
|
||||
1000,
|
||||
);
|
||||
const gate = pauseAdminBatch(store);
|
||||
const pending = adminLogin(store.database, owner.key, 2000);
|
||||
await gate.waiting;
|
||||
await adminLogoutAll(store.database, identity, 2000);
|
||||
const afterRevoke = snapshot(store);
|
||||
gate.release();
|
||||
await assert.rejects(pending, denied());
|
||||
assert.equal(snapshot(store), afterRevoke);
|
||||
assert.equal(
|
||||
store.sqlite.prepare('SELECT count(*) AS n FROM admin_sessions').get()!.n,
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
store.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('concurrent account disable and reenable cannot resurrect an in-flight login or existing sessions', async () => {
|
||||
const store = adminStore();
|
||||
try {
|
||||
const owner = store.provision();
|
||||
store.provision('owner');
|
||||
const current = await adminLogin(store.database, owner.key, 1000);
|
||||
const gate = pauseAdminBatch(store);
|
||||
const pending = adminLogin(store.database, owner.key, 2000);
|
||||
await gate.waiting;
|
||||
store.sqlite
|
||||
.prepare('UPDATE admin_accounts SET enabled = 0 WHERE id = ?')
|
||||
.run(owner.id);
|
||||
await assert.rejects(
|
||||
requireAdmin(store.database, current.token, undefined, 2000),
|
||||
denied(),
|
||||
);
|
||||
await assert.rejects(adminLogin(store.database, owner.key, 2000), denied());
|
||||
store.sqlite
|
||||
.prepare('UPDATE admin_accounts SET enabled = 1 WHERE id = ?')
|
||||
.run(owner.id);
|
||||
const afterDisable = snapshot(store);
|
||||
gate.release();
|
||||
await assert.rejects(pending, denied());
|
||||
await assert.rejects(
|
||||
requireAdmin(store.database, current.token, undefined, 2000),
|
||||
denied(),
|
||||
);
|
||||
assert.equal(snapshot(store), afterDisable);
|
||||
assert.equal(
|
||||
store.sqlite
|
||||
.prepare(
|
||||
"SELECT count(*) AS n FROM admin_audit WHERE action = 'revoke'",
|
||||
)
|
||||
.get()!.n,
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
store.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('a concurrent browser logout denies stale logout-all without revoking other browser sessions', async () => {
|
||||
const store = adminStore();
|
||||
try {
|
||||
const owner = store.provision();
|
||||
const current = await adminLogin(store.database, owner.key, 1000);
|
||||
const other = await adminLogin(store.database, owner.key, 1000);
|
||||
const identity = await requireAdmin(
|
||||
store.database,
|
||||
current.token,
|
||||
undefined,
|
||||
1000,
|
||||
);
|
||||
const gate = pauseAdminBatch(store);
|
||||
const pending = adminLogoutAll(store.database, identity, 2000);
|
||||
await gate.waiting;
|
||||
await adminLogout(store.database, current.token, 2000);
|
||||
const afterLogout = snapshot(store);
|
||||
gate.release();
|
||||
await assert.rejects(pending, denied());
|
||||
assert.equal(snapshot(store), afterLogout);
|
||||
await requireAdmin(store.database, other.token, undefined, 2000);
|
||||
} finally {
|
||||
store.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('concurrent logout-all calls commit a single generation advance and audit event', async () => {
|
||||
const store = adminStore();
|
||||
try {
|
||||
const owner = store.provision();
|
||||
const login = await adminLogin(store.database, owner.key, 1000);
|
||||
const identity = await requireAdmin(
|
||||
store.database,
|
||||
login.token,
|
||||
undefined,
|
||||
1000,
|
||||
);
|
||||
const gate = pauseAdminBatch(store);
|
||||
const pending = adminLogoutAll(store.database, identity, 2000);
|
||||
await gate.waiting;
|
||||
await adminLogoutAll(store.database, identity, 2000);
|
||||
const afterWinner = snapshot(store);
|
||||
gate.release();
|
||||
await assert.rejects(pending, denied());
|
||||
assert.equal(snapshot(store), afterWinner);
|
||||
assert.equal(
|
||||
store.sqlite
|
||||
.prepare('SELECT session_generation FROM admin_accounts WHERE id = ?')
|
||||
.get(owner.id)!.session_generation,
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
store.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('audit failures roll back both session creation and logout-all generation changes', async () => {
|
||||
const store = adminStore();
|
||||
try {
|
||||
const owner = store.provision();
|
||||
store.sqlite.exec(
|
||||
"CREATE TRIGGER fail_login_audit BEFORE INSERT ON admin_audit WHEN NEW.action = 'login' BEGIN SELECT RAISE(ABORT, 'audit failure'); END",
|
||||
);
|
||||
const beforeLogin = snapshot(store);
|
||||
await assert.rejects(
|
||||
adminLogin(store.database, owner.key, 1000),
|
||||
/audit failure/,
|
||||
);
|
||||
assert.equal(snapshot(store), beforeLogin);
|
||||
store.sqlite.exec('DROP TRIGGER fail_login_audit');
|
||||
const login = await adminLogin(store.database, owner.key, 1000);
|
||||
const identity = await requireAdmin(
|
||||
store.database,
|
||||
login.token,
|
||||
undefined,
|
||||
1000,
|
||||
);
|
||||
store.sqlite.exec(
|
||||
"CREATE TRIGGER fail_logout_audit BEFORE INSERT ON admin_audit WHEN NEW.action = 'logout_all' BEGIN SELECT RAISE(ABORT, 'audit failure'); END",
|
||||
);
|
||||
const beforeLogout = snapshot(store);
|
||||
await assert.rejects(
|
||||
adminLogoutAll(store.database, identity, 2000),
|
||||
/audit failure/,
|
||||
);
|
||||
assert.equal(snapshot(store), beforeLogout);
|
||||
await requireAdmin(store.database, login.token, undefined, 2000);
|
||||
} finally {
|
||||
store.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('operator revocation protects the final enabled owner and persists account, session and audit changes atomically', async () => {
|
||||
const store = adminStore();
|
||||
try {
|
||||
const first = store.provision();
|
||||
const second = store.provision('owner', 'Second owner');
|
||||
const login = await adminLogin(store.database, first.key, 1000);
|
||||
const identity = await requireAdmin(
|
||||
store.database,
|
||||
login.token,
|
||||
undefined,
|
||||
1000,
|
||||
);
|
||||
store.sqlite
|
||||
.prepare('UPDATE admin_accounts SET enabled = 0 WHERE id = ?')
|
||||
.run(first.id);
|
||||
await assert.rejects(
|
||||
requireAdmin(store.database, login.token, undefined, 2000),
|
||||
denied(),
|
||||
);
|
||||
await assert.rejects(
|
||||
adminLogoutAll(store.database, identity, 2000),
|
||||
denied(),
|
||||
);
|
||||
const beforeDenied = snapshot(store);
|
||||
for (const sql of [
|
||||
'UPDATE admin_accounts SET enabled = 0 WHERE id = ?',
|
||||
"UPDATE admin_accounts SET role = 'operator' WHERE id = ?",
|
||||
'DELETE FROM admin_accounts WHERE id = ?',
|
||||
])
|
||||
assert.throws(
|
||||
() => store.sqlite.prepare(sql).run(second.id),
|
||||
/final enabled administrator owner/,
|
||||
);
|
||||
assert.equal(snapshot(store), beforeDenied);
|
||||
assert.equal(
|
||||
store.sqlite
|
||||
.prepare(
|
||||
'SELECT count(*) AS n FROM admin_sessions WHERE admin_id = ? AND revoked_at IS NULL',
|
||||
)
|
||||
.get(first.id)!.n,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
store.sqlite.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// @dune-suite integration
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
|
||||
|
||||
void test('admin HTTP denies anonymous, forged admin, room-host and spoofed identity-header access', async () => {
|
||||
const created = await fetch(base + '/api/rooms', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', origin: base },
|
||||
body: JSON.stringify({ name: 'Admin boundary QA', faction: 'atreides', advanced: false, expansions: [] }),
|
||||
});
|
||||
assert.equal(created.status, 201);
|
||||
const cookie = created.headers.get('set-cookie')?.split(';')[0];
|
||||
assert.ok(cookie);
|
||||
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/rooms']) {
|
||||
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']);
|
||||
}
|
||||
}
|
||||
});
|
||||
void test('admin HTTP rejects login CSRF, oversized bodies and invalid credentials without setting sessions', async () => {
|
||||
for (const [origin, body, expected] of [
|
||||
['', '{}', 403], ['https://foreign.invalid', '{}', 403],
|
||||
[base, ' '.repeat(2049), 413], [base, JSON.stringify({ action: 'login', key: 'invalid' }), 401],
|
||||
] as const) {
|
||||
const result = await fetch(base + '/api/admin/session', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', ...(origin ? { origin } : {}) }, body,
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
assert.equal(result.status, expected);
|
||||
assert.equal(result.headers.get('set-cookie'), null);
|
||||
if (expected !== 403) assert.equal(result.headers.get('cache-control'), 'no-store');
|
||||
await result.text();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { adminStore, pauseAdminBatch } from './admin-access-fixture';
|
||||
import { adminLogin, requireAdmin, adminLogoutAll, AdminError } from '../db/admin-access';
|
||||
import { projectAdminRoom, readAdminDirectory } from '../db/admin-directory';
|
||||
|
||||
const state = (name = 'Directory QA', status = 'lobby', advanced = false) => ({
|
||||
status, advanced, host: 'p1', turn: 1, phase: 0, expansions: [],
|
||||
players: [{ id: 'p1', name, faction: 'atreides', ready: false, hand: ['SECRET_HAND'], traitors: ['SECRET_TRAITOR'], prediction: 'SECRET_PREDICTION', spice: 17 }],
|
||||
battle: null, response: null, decision: null, active: 'p1',
|
||||
log: ['SECRET_HISTORY'], unknownFuturePrivateField: 'SECRET_NEW_FIELD',
|
||||
});
|
||||
const insert = (sqlite: DatabaseSync, code: string, value: unknown, updated = 100) => sqlite.prepare('INSERT INTO rooms (code,state,version,updated_at) VALUES (?,?,0,?)').run(code, typeof value === 'string' ? value : JSON.stringify(value), updated);
|
||||
async function fixture() {
|
||||
const store = adminStore();
|
||||
const account = store.provision('viewer');
|
||||
const login = await adminLogin(store.database, account.key);
|
||||
const identity = await requireAdmin(store.database, login.token);
|
||||
return { ...store, identity, login };
|
||||
}
|
||||
void test('admin directory allowlist reveals no private state, role power or seat credential', () => {
|
||||
const input = state();
|
||||
const output = projectAdminRoom({ code: 'ABCDEFGH', version: 3, updated_at: 100, state: JSON.stringify(input) });
|
||||
assert.deepEqual(output.players, [{ id: 'p1', name: 'Directory QA', faction: 'atreides', control: 'human' }]);
|
||||
assert.deepEqual(output.pending, { label: 'Lobby readiness', owners: ['p1'] });
|
||||
assert.equal(JSON.stringify(output).includes('SECRET'), false);
|
||||
assert.deepEqual(Object.keys(output.players[0]).sort(), ['control', 'faction', 'id', 'name']);
|
||||
const decision = projectAdminRoom({ code: 'ABCDEFGH', version: 3, updated_at: 100, state: JSON.stringify({ ...input, status: 'playing', decision: { kind: 'capturedLeader', player: 'p1', leader: 'SECRET_CAPTURE', owner: 'SECRET_OWNER' } }) });
|
||||
assert.deepEqual(decision.pending, { label: 'Special decision', owners: ['p1'] });
|
||||
assert.equal(JSON.stringify(decision).includes('SECRET'), false);
|
||||
});
|
||||
void test('admin directory filters, stable pages, literal wildcard search and nested malformed saves', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
for (let n = 0; n < 28; n++) insert(f.sqlite, `QA${String(n).padStart(6, '0')}`, state(n === 0 ? 'Literal %_ QA' : 'Directory QA', n % 2 ? 'playing' : 'lobby', n % 2 === 1));
|
||||
insert(f.sqlite, 'BROKEN01', '{bad');
|
||||
insert(f.sqlite, 'BROKEN02', { ...state(), players: ['broken'] });
|
||||
insert(f.sqlite, 'BROKEN03', { ...state(), players: 'broken' });
|
||||
const before = f.sqlite.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||
const read = (query = '') => readAdminDirectory(f.database, f.identity, new URLSearchParams(query));
|
||||
const first = await read(), next = await read('page=2');
|
||||
assert.equal(first.total, 31); assert.equal(first.rooms.length, 25); assert.equal(next.rooms.length, 6);
|
||||
assert.equal(new Set([...first.rooms, ...next.rooms].map(r => r.code)).size, 31);
|
||||
assert.equal((await read('q=Directory')).total, 27);
|
||||
assert.equal((await read('q=%25_')).total, 1);
|
||||
assert.equal((await read('status=playing&rules=advanced')).total, 14);
|
||||
assert.equal((await read('status=lobby&rules=advanced')).total, 0);
|
||||
assert.equal((await read('q=SECRET')).total, 0, 'Search cannot inspect hands, predictions or history');
|
||||
assert.equal((await read('q=BROKEN')).rooms.every(r => r.status === 'unreadable'), true);
|
||||
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms ORDER BY code').all(), before);
|
||||
for (const query of ['page=0', 'page=1.5', 'page=-1', 'sort=state', 'status=bogus', 'q=a&q=b', 'x=1', 'q=' + 'a'.repeat(81)]) {
|
||||
await assert.rejects(read(query), (e: unknown) => e instanceof AdminError && e.status === 400, query);
|
||||
}
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
void test('admin directory rechecks authority inside its snapshot after concurrent revocation', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
insert(f.sqlite, 'ABCDEFGH', state());
|
||||
const gate = pauseAdminBatch(f);
|
||||
const pending = readAdminDirectory(f.database, f.identity, new URLSearchParams());
|
||||
await gate.waiting;
|
||||
await adminLogoutAll(f.database, f.identity);
|
||||
gate.release();
|
||||
await assert.rejects(pending, (e: unknown) => e instanceof AdminError && e.status === 401);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { adminBody, adminCookie, adminFailure, adminResponse, adminToken } from '../lib/admin-http';
|
||||
import { AdminError } from '../db/admin-access';
|
||||
|
||||
const endpoint = 'http://localhost:3000/api/admin/session';
|
||||
const request = (body: string, headers: Record<string, string> = {}) => new Request(endpoint, {
|
||||
method: 'POST', body, headers: { origin: 'http://localhost:3000', 'content-type': 'application/json', ...headers },
|
||||
});
|
||||
void test('administrator mutations require exact configured origin and bounded JSON', async () => {
|
||||
assert.deepEqual(await adminBody(request('{"action":"logout"}')), { action: 'logout' });
|
||||
for (const headers of [{ origin: '' }, { origin: 'https://foreign.invalid' }, { 'sec-fetch-site': 'cross-site' }] as Record<string, string>[]) {
|
||||
await assert.rejects(adminBody(request('{}', headers)), (e: unknown) => e instanceof AdminError && e.status === 403);
|
||||
}
|
||||
await assert.rejects(adminBody(request('{}', { 'content-type': 'text/plain' })), (e: unknown) => e instanceof AdminError && e.status === 415);
|
||||
await assert.rejects(adminBody(request(' '.repeat(2049))), (e: unknown) => e instanceof AdminError && e.status === 413);
|
||||
await assert.rejects(adminBody(request('[]')), (e: unknown) => e instanceof AdminError && e.status === 400);
|
||||
await assert.rejects(adminBody(request('bad')), SyntaxError);
|
||||
assert.deepEqual(await adminBody(request('{}', { origin: 'https://dune.procrastination.games' }), 'https://dune.procrastination.games'), {});
|
||||
await assert.rejects(adminBody(request('{}', { 'x-forwarded-host': 'foreign.invalid', origin: 'https://foreign.invalid' })), AdminError);
|
||||
});
|
||||
void test('administrator cookies isolate room credentials and secure the configured HTTPS origin', () => {
|
||||
const token = 'a'.repeat(64);
|
||||
assert.equal(adminToken(new Request(endpoint, { headers: { cookie: `dune_ABCDEFGH=room-secret; dune_admin_session=${token}` } })), token);
|
||||
assert.equal(adminToken(new Request(endpoint, { headers: { cookie: 'dune_ABCDEFGH=room-secret' } })), undefined);
|
||||
const value = adminCookie(new Request(endpoint), token, 'https://dune.procrastination.games');
|
||||
for (const part of ['Path=/api/admin', 'HttpOnly', 'SameSite=Strict', 'Max-Age=28800', '; Secure']) assert.ok(value.includes(part));
|
||||
assert.ok(adminCookie(new Request(endpoint), '', 'https://dune.procrastination.games').includes('Max-Age=0'));
|
||||
assert.ok(!adminCookie(new Request(endpoint), token).includes('; Secure'));
|
||||
});
|
||||
void test('administrator responses are not cacheable and failures redact internal details', async () => {
|
||||
assert.equal(adminResponse({}).headers.get('cache-control'), 'no-store');
|
||||
assert.equal(adminResponse({}).headers.get('vary'), 'Cookie');
|
||||
const response = adminFailure(new Error('SECRET_DATABASE_OR_CREDENTIAL'));
|
||||
assert.equal(response.status, 503);
|
||||
assert.ok(!(await response.text()).includes('SECRET_DATABASE_OR_CREDENTIAL'));
|
||||
assert.equal(adminFailure(new AdminError('Not permitted.', 403)).status, 403);
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { mkdir, realpath, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const usage = `Create an administrator access key and provisioning SQL:
|
||||
node tools/admin-access.mjs --name 'Owner' --role owner --out /private/new-directory
|
||||
|
||||
Revoke an account and all its sessions (the final enabled owner is protected):
|
||||
node tools/admin-access.mjs --revoke <account-uuid> --out /private/new-directory
|
||||
|
||||
Roles: owner, operator, viewer. The output directory must be new, absolute and
|
||||
outside the application checkout. Apply the generated SQL to the intended D1
|
||||
database with your operator tooling. No database is contacted by this command.
|
||||
The private access key is written only to access-key.txt, never to the SQL or stdout.
|
||||
`;
|
||||
|
||||
function parseArgs(args) {
|
||||
if (args.length === 1 && args[0] === '--help') return null;
|
||||
const options = {};
|
||||
for (let i = 0; i < args.length; i += 2) {
|
||||
const option = args[i];
|
||||
if (
|
||||
!['--name', '--role', '--out', '--revoke'].includes(option) ||
|
||||
Object.hasOwn(options, option) ||
|
||||
!args[i + 1] ||
|
||||
args[i + 1].startsWith('--')
|
||||
)
|
||||
throw new Error('Use --help for the required command options.');
|
||||
options[option] = args[i + 1];
|
||||
}
|
||||
if (!options['--out'] || !isAbsolute(options['--out']))
|
||||
throw new Error(
|
||||
'--out must name a new absolute directory outside the checkout.',
|
||||
);
|
||||
if (options['--revoke']) {
|
||||
if (
|
||||
options['--name'] ||
|
||||
options['--role'] ||
|
||||
options['--revoke'].length !== 36 ||
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(
|
||||
options['--revoke'],
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
'Revocation requires only --revoke with an account UUID and --out.',
|
||||
);
|
||||
} else {
|
||||
const name = options['--name']?.trim();
|
||||
if (
|
||||
!name ||
|
||||
name.length > 80 ||
|
||||
[...name].some((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return code < 32 || (code >= 127 && code <= 159);
|
||||
})
|
||||
)
|
||||
throw new Error(
|
||||
'--name must contain 1–80 characters without control characters.',
|
||||
);
|
||||
if (!['owner', 'operator', 'viewer'].includes(options['--role']))
|
||||
throw new Error('--role must be owner, operator or viewer.');
|
||||
options['--name'] = name;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
const quote = (value) => `'${value.replaceAll("'", "''")}'`;
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (!options) {
|
||||
process.stdout.write(usage);
|
||||
return;
|
||||
}
|
||||
const checkout = await realpath(
|
||||
resolve(dirname(fileURLToPath(import.meta.url)), '..'),
|
||||
);
|
||||
const requested = resolve(options['--out']);
|
||||
// Resolve the parent before checking containment, including symlink aliases.
|
||||
const parent = await realpath(dirname(requested));
|
||||
const output = resolve(parent, relative(dirname(requested), requested));
|
||||
const within = relative(checkout, output);
|
||||
if (
|
||||
within === '' ||
|
||||
(within !== '..' && !within.startsWith(`..${sep}`) && !isAbsolute(within))
|
||||
)
|
||||
throw new Error(
|
||||
'The output directory must be outside the application checkout.',
|
||||
);
|
||||
|
||||
await mkdir(output, { mode: 0o700 }); // Existing files/directories are never reused.
|
||||
try {
|
||||
const now = Date.now();
|
||||
const revoked = options['--revoke'];
|
||||
const id = revoked || randomUUID();
|
||||
if (revoked) {
|
||||
const sql = `-- Apply to the intended D1 database; this contains no access key.
|
||||
-- The database trigger refuses to revoke the final enabled owner.
|
||||
UPDATE admin_accounts SET enabled = 0, updated_at = ${now}
|
||||
WHERE id = ${quote(id)} AND enabled = 1
|
||||
RETURNING id, name, role, enabled;
|
||||
`;
|
||||
await writeFile(resolve(output, 'revoke.sql'), sql, {
|
||||
mode: 0o600,
|
||||
flag: 'wx',
|
||||
});
|
||||
} else {
|
||||
const key = `dune-admin.${id}.${randomBytes(32).toString('hex')}`;
|
||||
const keyHash = createHash('sha256').update(key).digest('hex');
|
||||
const sql = `-- Apply to the intended D1 database; this contains only the access-key hash.
|
||||
-- The database trigger records provisioning in the administrator audit history.
|
||||
INSERT INTO admin_accounts (id, name, role, key_hash, enabled, session_generation, created_at, updated_at)
|
||||
VALUES (${quote(id)}, ${quote(options['--name'])}, ${quote(options['--role'])}, ${quote(keyHash)}, 1, 0, ${now}, ${now});
|
||||
`;
|
||||
await writeFile(resolve(output, 'access-key.txt'), key + '\n', {
|
||||
mode: 0o600,
|
||||
flag: 'wx',
|
||||
});
|
||||
await writeFile(resolve(output, 'provision.sql'), sql, {
|
||||
mode: 0o600,
|
||||
flag: 'wx',
|
||||
});
|
||||
}
|
||||
process.stdout.write(
|
||||
`Created ${revoked ? 'revocation' : 'provisioning'} files for administrator ${id} in ${output}.\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
await rm(output, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
// Filesystem errors may include operator paths; credential values never enter errors.
|
||||
process.stderr.write(
|
||||
`Administrator access setup failed: ${error instanceof Error ? error.message : 'unknown error'}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
// Explicit operator-run HTTP acceptance. Use a dedicated QA administrator key:
|
||||
// this signs out ALL sessions for that account. Never use a human operator's key.
|
||||
// Uses one new QA room; never modifies an existing game.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile, mkdir, realpath, writeFile } from 'node:fs/promises';
|
||||
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const options = {};
|
||||
for (let n = 2; n < process.argv.length; n += 2) {
|
||||
const flag = process.argv[n];
|
||||
if (!['--url', '--key-file', '--out', '--qa-account'].includes(flag) || options[flag] || !process.argv[n + 1]) throw new Error('Use --url ORIGIN --key-file PRIVATE_QA_FILE --qa-account ACCOUNT_UUID --out NEW_PRIVATE_DIRECTORY');
|
||||
options[flag] = process.argv[n + 1];
|
||||
}
|
||||
const url = new URL(options['--url']);
|
||||
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('Provide an HTTP(S) origin.');
|
||||
if (!options['--key-file'] || !isAbsolute(options['--out'] ?? '')) throw new Error('Provide a key file and absolute output directory.');
|
||||
if (!/^[0-9a-f-]{36}$/.test(options['--qa-account'] ?? '')) throw new Error('Identify the dedicated QA account with --qa-account; all its sessions will be revoked.');
|
||||
const root = await realpath(resolve(dirname(fileURLToPath(import.meta.url)), '..'));
|
||||
const out = resolve(await realpath(dirname(options['--out'])), relative(dirname(options['--out']), options['--out']));
|
||||
const fromRoot = relative(root, out);
|
||||
if (!fromRoot || (fromRoot !== '..' && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot))) throw new Error('Store evidence outside the checkout.');
|
||||
await mkdir(out, { mode: 0o700 });
|
||||
const key = (await readFile(options['--key-file'], 'utf8')).trim();
|
||||
const base = url.origin;
|
||||
const report = { origin: base, startedAt: new Date().toISOString(), passed: false, checks: [], room: null };
|
||||
let session1, session2;
|
||||
async function request(path, body, cookie, origin = base) {
|
||||
const response = await fetch(base + path, {
|
||||
method: body === undefined ? 'GET' : 'POST',
|
||||
headers: { ...(body === undefined ? {} : { 'content-type': 'application/json', origin }), ...(cookie ? { cookie } : {}) },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
report.lastRequest = { path, status: response.status };
|
||||
const raw = await response.text();
|
||||
let data;
|
||||
try { data = JSON.parse(raw); } catch { data = null; }
|
||||
return { status: response.status, data, cookie: response.headers.get('set-cookie')?.split(';')[0], rawCookie: response.headers.get('set-cookie'), cache: response.headers.get('cache-control') };
|
||||
}
|
||||
try {
|
||||
const denied = await request('/api/admin/rooms');
|
||||
assert.equal(denied.status, 401); assert.equal(denied.cache, 'no-store');
|
||||
const login = await request('/api/admin/session', { action: 'login', key });
|
||||
assert.equal(login.status, 200); assert.ok(login.cookie);
|
||||
session1 = login.cookie;
|
||||
assert.equal(login.data.admin.id, options['--qa-account'], 'Use the explicitly designated QA account.');
|
||||
for (const attribute of ['HttpOnly', 'SameSite=Strict', 'Path=/api/admin']) assert.ok(login.rawCookie.includes(attribute));
|
||||
if (url.protocol === 'https:') assert.ok(login.rawCookie.includes('; Secure'));
|
||||
assert.equal((await request('/api/admin/session', undefined, session1)).data.admin.id, login.data.admin.id);
|
||||
report.checks.push('Authenticated identity and cookie attributes; anonymous denial');
|
||||
const second = await request('/api/admin/session', { action: 'login', key });
|
||||
assert.equal(second.status, 200); session2 = second.cookie;
|
||||
const name = 'Administration QA ' + Date.now();
|
||||
const created = await request('/api/rooms', { name, faction: 'atreides', advanced: false, expansions: [] });
|
||||
assert.equal(created.status, 201); report.room = created.data.code;
|
||||
const path = '/api/rooms/' + report.room;
|
||||
const before = (await request(path, undefined, created.cookie)).data;
|
||||
const directory = await request('/api/admin/rooms?q=' + report.room, undefined, session1);
|
||||
assert.equal(directory.status, 200); assert.equal(directory.data.total, 1);
|
||||
const room = directory.data.rooms[0];
|
||||
assert.equal(room.code, report.room); assert.equal(room.players[0].name, name);
|
||||
assert.deepEqual(Object.keys(room.players[0]).sort(), ['control', 'faction', 'id', 'name']);
|
||||
for (const field of ['state', 'log', 'hand', 'traitors', 'prediction', 'sessionHash', 'token', 'spice']) assert.ok(!Object.hasOwn(room, field));
|
||||
assert.deepEqual((await request(path, undefined, created.cookie)).data, before);
|
||||
assert.equal((await request('/api/admin/rooms', undefined, created.cookie)).status, 401);
|
||||
report.checks.push('QA room search, strict public roster, host denial, unchanged saved game');
|
||||
assert.equal((await request('/api/admin/session', { action: 'logoutAll' }, session1, 'https://foreign.invalid')).status, 403);
|
||||
assert.equal((await request('/api/admin/session', undefined, session1)).status, 200);
|
||||
assert.equal((await request('/api/admin/session', { action: 'logout' }, session1)).status, 200);
|
||||
assert.equal((await request('/api/admin/rooms', undefined, session1)).status, 401);
|
||||
assert.equal((await request('/api/admin/session', undefined, session2)).status, 200);
|
||||
const third = await request('/api/admin/session', { action: 'login', key });
|
||||
assert.equal(third.status, 200); session1 = third.cookie;
|
||||
assert.equal((await request('/api/admin/session', { action: 'logoutAll' }, session2)).status, 200);
|
||||
for (const cookie of [session1, session2]) assert.equal((await request('/api/admin/rooms', undefined, cookie)).status, 401);
|
||||
assert.deepEqual((await request(path, undefined, created.cookie)).data, before);
|
||||
report.checks.push('Foreign-origin denial; individual and all-session revocation; saved-seat continuity');
|
||||
report.passed = true;
|
||||
} catch (error) {
|
||||
// Do not serialize assertion payloads: failed authentication responses might contain credentials.
|
||||
report.failure = 'Administrator HTTP verification failed. Investigate the last completed check.';
|
||||
report.failureRequest = report.lastRequest;
|
||||
report.failureLocation = error instanceof Error ? error.stack?.split('\n').find(line => line.trim().startsWith('at ') && line.includes('verify-admin.mjs'))?.trim() : undefined;
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
for (const cookie of [session1, session2].filter(Boolean)) {
|
||||
try { await request('/api/admin/session', { action: 'logout' }, cookie); } catch { /* Report may already be incomplete; absolute expiry still applies. */ }
|
||||
}
|
||||
await writeFile(resolve(out, 'report.json'), JSON.stringify({ ...report, finishedAt: new Date().toISOString() }, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
||||
console.log(JSON.stringify({ passed: report.passed, completedChecks: report.checks.length, evidence: out }));
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const image = process.argv[2];
|
||||
if (!image) throw new Error('Usage: node tools/verify-container.mjs IMAGE');
|
||||
@@ -10,6 +13,8 @@ const name = `dune-verify-${randomUUID()}`;
|
||||
const volume = `${name}-data`;
|
||||
const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8' }).trim();
|
||||
let base;
|
||||
const adminFiles = mkdtempSync(join(tmpdir(), 'dune-admin-container-'));
|
||||
let adminCookie;
|
||||
async function ready() {
|
||||
for (let attempt = 0; attempt < 90; attempt++) {
|
||||
try {
|
||||
@@ -31,6 +36,19 @@ try {
|
||||
docker('volume', 'create', volume);
|
||||
start();
|
||||
await ready();
|
||||
execFileSync('node', ['tools/admin-access.mjs', '--name', 'Container QA', '--role', 'viewer', '--out', join(adminFiles, 'key')]);
|
||||
execFileSync('docker', ['exec', '-i', name, 'node', '-e', "require('node:fs').writeFileSync('/tmp/dune-admin-provision.sql', require('node:fs').readFileSync(0), {mode:0o600})"], {
|
||||
input: readFileSync(join(adminFiles, 'key/provision.sql')), // Container USER owns this private file.
|
||||
});
|
||||
docker('exec', name, 'node', 'node_modules/wrangler/bin/wrangler.js', 'd1', 'execute', 'DB', '--local', '--config', 'tools/wrangler.local.json', '--persist-to', '/data', '--file', '/tmp/dune-admin-provision.sql');
|
||||
const adminKey = readFileSync(join(adminFiles, 'key/access-key.txt'), 'utf8').trim();
|
||||
const adminLogin = await fetch(`${base}/api/admin/session`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json', origin: base },
|
||||
body: JSON.stringify({ action: 'login', key: adminKey }),
|
||||
});
|
||||
assert.equal(adminLogin.status, 200);
|
||||
adminCookie = adminLogin.headers.get('set-cookie')?.split(';')[0];
|
||||
assert.ok(adminCookie);
|
||||
const headers = { 'content-type': 'application/json', origin: base };
|
||||
const body = JSON.stringify({ name: 'Container verification', faction: 'atreides', advanced: false, expansions: [] });
|
||||
const create = await fetch(`${base}/api/rooms`, { method: 'POST', headers, body });
|
||||
@@ -46,6 +64,10 @@ try {
|
||||
const result = await fetch(`${base}/api/rooms/${room.code}`, { headers: { cookie } });
|
||||
assert.equal(result.status, 200);
|
||||
assert.deepEqual(await result.json(), room, 'Saved seat and room must survive replacement');
|
||||
if (adminCookie) {
|
||||
const admin = await fetch(`${base}/api/admin/session`, { headers: { cookie: adminCookie } });
|
||||
assert.equal(admin.status, 200, 'Administrator session must survive replacement');
|
||||
}
|
||||
}
|
||||
docker('restart', '--time', '60', name);
|
||||
// Docker may assign a different ephemeral host port after a restart.
|
||||
@@ -57,6 +79,8 @@ try {
|
||||
start();
|
||||
await ready();
|
||||
await restored();
|
||||
execFileSync('node', ['tools/verify-admin.mjs', '--url', base, '--key-file', join(adminFiles, 'key/access-key.txt'), '--qa-account', adminKey.split('.')[1], '--out', join(adminFiles, 'http')], { stdio: 'inherit' });
|
||||
adminCookie = undefined; // The acceptance test intentionally signs out all sessions.
|
||||
execFileSync('npm', ['run', 'test:integration'], {
|
||||
stdio: 'inherit', env: { ...process.env, DUNE_TEST_URL: base },
|
||||
});
|
||||
@@ -66,6 +90,12 @@ try {
|
||||
start(publicOrigin);
|
||||
await ready();
|
||||
await restored();
|
||||
const proxyAdmin = await fetch(`${base}/api/admin/session`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json', origin: publicOrigin },
|
||||
body: JSON.stringify({ action: 'login', key: adminKey }),
|
||||
});
|
||||
assert.equal(proxyAdmin.status, 200);
|
||||
assert.ok(proxyAdmin.headers.get('set-cookie')?.includes('; Secure'));
|
||||
const proxyCreate = await fetch(`${base}/api/rooms`, {
|
||||
method: 'POST', headers: { ...headers, origin: publicOrigin }, body,
|
||||
});
|
||||
@@ -98,4 +128,5 @@ try {
|
||||
// Names are unique to this invocation; never remove an existing application.
|
||||
try { docker('rm', '-f', name); } catch { /* Already removed or not created. */ }
|
||||
try { docker('volume', 'rm', volume); } catch { /* Failure remains visible. */ }
|
||||
rmSync(adminFiles, { recursive: true, force: true });
|
||||
}
|
||||
Reference in new issue
Block a user