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 --from=build /app/drizzle ./drizzle
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
COPY tools/wrangler.local.json ./tools/wrangler.local.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/container-entrypoint.sh ./deploy/container-entrypoint.sh
|
||||||
COPY deploy/serve.mjs ./deploy/serve.mjs
|
COPY deploy/serve.mjs ./deploy/serve.mjs
|
||||||
RUN mkdir -p /data /app/.wrangler && chown node:node /data /app/.wrangler
|
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
|
[TrueNAS container guide](docs/TRUENAS.md). Its separate persistent storage starts
|
||||||
with new games; the requested host port is 33046.
|
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
|
## 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.
|
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>
|
</div>
|
||||||
<footer>
|
<footer>
|
||||||
<span>Unofficial fan implementation</span>
|
<span>Unofficial fan implementation</span>
|
||||||
|
<a href="/admin">Administration</a>
|
||||||
<span>Dune · A game of conquest, diplomacy & betrayal</span>
|
<span>Dune · A game of conquest, diplomacy & betrayal</span>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</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,
|
primaryKey,
|
||||||
index,
|
index,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
|
check,
|
||||||
} from 'drizzle-orm/sqlite-core';
|
} from 'drizzle-orm/sqlite-core';
|
||||||
export const rooms = sqliteTable('rooms', {
|
import { sql } from 'drizzle-orm';
|
||||||
code: text('code').primaryKey(),
|
|
||||||
state: text('state').notNull(),
|
export const rooms = sqliteTable(
|
||||||
version: integer('version').notNull().default(0),
|
'rooms',
|
||||||
updatedAt: integer('updated_at').notNull(),
|
{
|
||||||
});
|
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', {
|
export const seats = sqliteTable('seats', {
|
||||||
tokenHash: text('token_hash').primaryKey(),
|
tokenHash: text('token_hash').primaryKey(),
|
||||||
revoked: integer('revoked').notNull().default(0),
|
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
|
# Administration panel requirements
|
||||||
|
|
||||||
Added to the goal on 22 September 2026 at the user's request. This is required
|
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
|
product scope. The first access and directory prototype is available at `/admin`;
|
||||||
room, session, recovery and persistence systems where suitable.
|
the remaining room operations are unfinished. Reuse the existing room, session,
|
||||||
|
recovery and persistence systems where suitable.
|
||||||
|
|
||||||
## Required capabilities
|
## Required capabilities
|
||||||
|
|
||||||
| Area | Required behavior | Stage |
|
| 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 |
|
| 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, filter, sort and paginate rooms; show lifecycle status, host, roster, rules/modules, last activity and pending decision ownership without exposing private game information. | Missing |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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,
|
As implementation begins, link its controls, server actions, persistence,
|
||||||
documentation and evidence. A working directory alone is not a full admin panel.
|
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
|
## Behavior and safeguards
|
||||||
|
|
||||||
Every privileged endpoint must authenticate and authorize its caller. Support
|
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
|
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,
|
22 September: secure admin access, room creation/removal and lifecycle controls,
|
||||||
participant support, backup/restore, operational views and audited actions.
|
participant support, backup/restore, operational views and audited actions.
|
||||||
This is required scope, not implemented functionality, and belongs before full
|
The first prototype now connects separate administrator sign-in, persistent
|
||||||
AI refinement/calibration. Existing rules, Git, subagent and preservation
|
sessions/revocation and a searchable, filtered, paginated room directory at
|
||||||
requirements remain in force.
|
`/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
|
The goal also requires testing the deployed application at
|
||||||
**https://dune.procrastination.games**. Match the running revision, exercise
|
**https://dune.procrastination.games**. Match the running revision, exercise
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
# Dune implementation status
|
# 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
|
## 22 September 2026 — Administration and deployed verification added to the goal
|
||||||
|
|
||||||
The user requested a complete [admin panel](ADMIN_PANEL.md), including adding
|
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,
|
"when": 1789947127493,
|
||||||
"tag": "0007_clammy_sprite",
|
"tag": "0007_clammy_sprite",
|
||||||
"breakpoints": true
|
"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 { execFileSync } from 'node:child_process';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { setTimeout as delay } from 'node:timers/promises';
|
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];
|
const image = process.argv[2];
|
||||||
if (!image) throw new Error('Usage: node tools/verify-container.mjs IMAGE');
|
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 volume = `${name}-data`;
|
||||||
const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8' }).trim();
|
const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8' }).trim();
|
||||||
let base;
|
let base;
|
||||||
|
const adminFiles = mkdtempSync(join(tmpdir(), 'dune-admin-container-'));
|
||||||
|
let adminCookie;
|
||||||
async function ready() {
|
async function ready() {
|
||||||
for (let attempt = 0; attempt < 90; attempt++) {
|
for (let attempt = 0; attempt < 90; attempt++) {
|
||||||
try {
|
try {
|
||||||
@@ -31,6 +36,19 @@ try {
|
|||||||
docker('volume', 'create', volume);
|
docker('volume', 'create', volume);
|
||||||
start();
|
start();
|
||||||
await ready();
|
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 headers = { 'content-type': 'application/json', origin: base };
|
||||||
const body = JSON.stringify({ name: 'Container verification', faction: 'atreides', advanced: false, expansions: [] });
|
const body = JSON.stringify({ name: 'Container verification', faction: 'atreides', advanced: false, expansions: [] });
|
||||||
const create = await fetch(`${base}/api/rooms`, { method: 'POST', headers, body });
|
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 } });
|
const result = await fetch(`${base}/api/rooms/${room.code}`, { headers: { cookie } });
|
||||||
assert.equal(result.status, 200);
|
assert.equal(result.status, 200);
|
||||||
assert.deepEqual(await result.json(), room, 'Saved seat and room must survive replacement');
|
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('restart', '--time', '60', name);
|
||||||
// Docker may assign a different ephemeral host port after a restart.
|
// Docker may assign a different ephemeral host port after a restart.
|
||||||
@@ -57,6 +79,8 @@ try {
|
|||||||
start();
|
start();
|
||||||
await ready();
|
await ready();
|
||||||
await restored();
|
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'], {
|
execFileSync('npm', ['run', 'test:integration'], {
|
||||||
stdio: 'inherit', env: { ...process.env, DUNE_TEST_URL: base },
|
stdio: 'inherit', env: { ...process.env, DUNE_TEST_URL: base },
|
||||||
});
|
});
|
||||||
@@ -66,6 +90,12 @@ try {
|
|||||||
start(publicOrigin);
|
start(publicOrigin);
|
||||||
await ready();
|
await ready();
|
||||||
await restored();
|
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`, {
|
const proxyCreate = await fetch(`${base}/api/rooms`, {
|
||||||
method: 'POST', headers: { ...headers, origin: publicOrigin }, body,
|
method: 'POST', headers: { ...headers, origin: publicOrigin }, body,
|
||||||
});
|
});
|
||||||
@@ -98,4 +128,5 @@ try {
|
|||||||
// Names are unique to this invocation; never remove an existing application.
|
// Names are unique to this invocation; never remove an existing application.
|
||||||
try { docker('rm', '-f', name); } catch { /* Already removed or not created. */ }
|
try { docker('rm', '-f', name); } catch { /* Already removed or not created. */ }
|
||||||
try { docker('volume', 'rm', volume); } catch { /* Failure remains visible. */ }
|
try { docker('volume', 'rm', volume); } catch { /* Failure remains visible. */ }
|
||||||
|
rmSync(adminFiles, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user