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:
Quintine committed 2026-09-22 09:36:11 +10:00
1 parent 36a03bda9c
commit c2d392e155
27 files changed
+3164 -13

No files matched your search

+1
View File
@@ -16,6 +16,7 @@ COPY --from=build /app/dist ./dist
COPY --from=build /app/drizzle ./drizzle
COPY package.json ./
COPY tools/wrangler.local.json ./tools/wrangler.local.json
COPY tools/admin-access.mjs ./tools/admin-access.mjs
COPY deploy/container-entrypoint.sh ./deploy/container-entrypoint.sh
COPY deploy/serve.mjs ./deploy/serve.mjs
RUN mkdir -p /data /app/.wrangler && chown node:node /data /app/.wrangler
+5
View File
@@ -39,6 +39,11 @@ For private NAS hosting and manually applied checkpoint image updates, see the
[TrueNAS container guide](docs/TRUENAS.md). Its separate persistent storage starts
with new games; the requested host port is 33046.
The **Administration** link opens `/admin`, with separate administrator sign-in,
session controls and a searchable room directory. See [initial owner setup and
scope](docs/ADMIN_PANEL.md#initial-owner-and-access-recovery). Room creation,
lifecycle/removal, participant support and backup tools are still being built.
## Saved seats and reconnecting
The room cookie identifies your seat. Refreshing or reopening the invitation with that cookie restores your private hand and pending decisions. A failed restoration offers an explicit retry. Requests time out after 15 seconds; uncertain game actions are reconciled with a read rather than automatically repeated.
+46
View File
@@ -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); }
}
+155
View File
@@ -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>;
}
+11
View File
@@ -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); }
}
+24
View File
@@ -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); }
}
+1
View File
@@ -1125,6 +1125,7 @@ export default function Home() {
</div>
<footer>
<span>Unofficial fan implementation</span>
<a href="/admin">Administration</a>
<span>Dune · A game of conquest, diplomacy & betrayal</span>
</footer>
</main>
+214
View File
@@ -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();
}
+90
View File
@@ -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
View File
@@ -5,13 +5,20 @@ import {
primaryKey,
index,
uniqueIndex,
check,
} from 'drizzle-orm/sqlite-core';
export const rooms = sqliteTable('rooms', {
code: text('code').primaryKey(),
state: text('state').notNull(),
version: integer('version').notNull().default(0),
updatedAt: integer('updated_at').notNull(),
});
import { sql } from 'drizzle-orm';
export const rooms = sqliteTable(
'rooms',
{
code: text('code').primaryKey(),
state: text('state').notNull(),
version: integer('version').notNull().default(0),
updatedAt: integer('updated_at').notNull(),
},
(table) => [index('rooms_updated_at').on(table.updatedAt)],
);
export const seats = sqliteTable('seats', {
tokenHash: text('token_hash').primaryKey(),
revoked: integer('revoked').notNull().default(0),
@@ -152,3 +159,56 @@ export const roomMessages = sqliteTable(
),
],
);
export const adminAccounts = sqliteTable(
'admin_accounts',
{
id: text('id').primaryKey(),
name: text('name').notNull(),
role: text('role', { enum: ['owner', 'operator', 'viewer'] }).notNull(),
keyHash: text('key_hash').notNull().unique(),
enabled: integer('enabled').notNull().default(1),
sessionGeneration: integer('session_generation').notNull().default(0),
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
},
(table) => [
check(
'admin_accounts_role',
sql`${table.role} IN ('owner', 'operator', 'viewer')`,
),
check('admin_accounts_enabled', sql`${table.enabled} IN (0, 1)`),
check('admin_accounts_generation', sql`${table.sessionGeneration} >= 0`),
],
);
export const adminSessions = sqliteTable(
'admin_sessions',
{
tokenHash: text('token_hash').primaryKey(),
id: text('id').notNull().unique(),
adminId: text('admin_id')
.notNull()
.references(() => adminAccounts.id, { onDelete: 'cascade' }),
generation: integer('generation').notNull(),
createdAt: integer('created_at').notNull(),
expiresAt: integer('expires_at').notNull(),
revokedAt: integer('revoked_at'),
},
(table) => [index('admin_sessions_account').on(table.adminId)],
);
// Audit records intentionally have no cascading account/room dependency.
// `detail` contains safe operation IDs only, never credentials or game state.
export const adminAudit = sqliteTable(
'admin_audit',
{
id: text('id').primaryKey(),
actorAdminId: text('actor_admin_id'),
targetAdminId: text('target_admin_id').notNull(),
action: text('action').notNull(),
createdAt: integer('created_at').notNull(),
detail: text('detail').notNull().default('{}'),
},
(table) => [index('admin_audit_created_at').on(table.createdAt)],
);
+94 -4
View File
@@ -1,15 +1,16 @@
# Administration panel requirements
Added to the goal on 22 September 2026 at the user's request. This is required
product scope, not a claim that the panel is implemented. Reuse the existing
room, session, recovery and persistence systems where suitable.
product scope. The first access and directory prototype is available at `/admin`;
the remaining room operations are unfinished. Reuse the existing room, session,
recovery and persistence systems where suitable.
## Required capabilities
| Area | Required behavior | Stage |
| --- | --- | --- |
| Administrator access | Secure administrator sign-in, server-enforced permissions, session revocation and a documented initial-owner setup. A room host is not automatically a site administrator. | Missing |
| Room directory | Search, filter, sort and paginate rooms; show lifecycle status, host, roster, rules/modules, last activity and pending decision ownership without exposing private game information. | Missing |
| Administrator access | Personal access keys, server-enforced owner/operator/viewer roles, persistent eight-hour sessions, individual/all-session sign-out and operator provisioning/revocation. A room host is not a site administrator. Account-management UI and further operational permissions remain. | Prototyped |
| Room directory | Search room codes/public player names, filter status/rules, sort and paginate; show host, roster, modules, game change time and bounded decision ownership. Detailed setup/shared-window ownership and later lifecycle metadata remain. | Prototyped |
| Create and configure | Create rooms with supported rules and human/AI seats, manage lobby settings and invitations, and assign a host through an explicit workflow. Preserve normal prerequisites and readiness rules. | Missing |
| Lifecycle and removal | Pause/resume, lock/unlock new joins, close/end, archive, restore and delete selected rooms. Offer recoverable removal and explicit permanent deletion with clear scope, consequences and safeguards for related records. | Missing |
| Participant support | Remove disruptive participants or revoke access, manage appropriate restrictions, assist saved-seat recovery and host reassignment, and use supported AI takeover/replacement without losing forces, cards or decisions. | Missing |
@@ -20,6 +21,95 @@ Track each area through Missing, Prototyped, Integrated, Verified and Polished.
As implementation begins, link its controls, server actions, persistence,
documentation and evidence. A working directory alone is not a full admin panel.
## Access and directory prototype
Use **Administration** in the home-page footer or visit `/admin`. Every directory
request checks a separate administrator session; seat cookies and forwarded
identity headers cannot grant access. The standalone NAS runtime has no trusted
Sites identity dispatcher, so this panel uses separately provisioned personal
keys. Each key contains a random 256-bit secret. Only its SHA-256 hash is stored;
sign-in issues a separate random, hashed session with an eight-hour absolute
expiry. HTTPS uses Secure, HttpOnly, SameSite=Strict cookies scoped to the admin
API. Mutations require the exact configured origin and bounded JSON requests.
All three roles may read the current directory and sign out their own sessions.
Owner/operator mutation capabilities will be added with the room-management
slices. The role is checked from the live account, never accepted from a client.
Disabling an account invalidates its sessions; later re-enabling it cannot restore
them. The database refuses to disable, demote or delete the final enabled owner.
No default key, public account creation or host-to-administrator promotion exists.
The directory uses an explicit field allowlist, independent of any player view.
It never returns hands, Traitors, predictions, plans, private messages, recovery
records or raw saved state. Searches inspect only codes and public player names.
Each page is limited to 25 rooms and repeats authorization in its read transaction
so concurrent revocation cannot expose a later directory snapshot. Malformed
saves are marked unreadable without leaking their contents or breaking searches.
Last game change refers to the room's saved-state timestamp, not private messages.
Shared windows and setup currently use broad pending labels; these labels do not
assert that a human is stalled or identify hidden-card-dependent eligibility.
Implementation: [panel](../app/admin/page.tsx), [access](../db/admin-access.ts),
[directory](../db/admin-directory.ts), [HTTP boundary](../lib/admin-http.ts),
[additive migration](../drizzle/0008_admin_access.sql), and
[operator CLI](../tools/admin-access.mjs). The migration's explicit SQLite triggers
enforce final-owner protection and account revocation/provisioning audit; retain
them when generating future schema changes. Audit browsing is still pending.
### Initial owner and access recovery
From the development checkout, apply additive migrations, then create a personal
owner key in a new private directory outside the checkout:
```sh
npm run db:local
node tools/admin-access.mjs --name 'Owner' --role owner --out /private/dune-owner
npx wrangler d1 execute DB --local --config tools/wrangler.local.json --persist-to .wrangler/state --file /private/dune-owner/provision.sql
```
The parent directory must already exist. Output files are mode 0600 in a new
mode-0700 directory. `access-key.txt` contains the sign-in key; `provision.sql`
contains only its hash. Keep the key in a private password manager or protected
file, outside Git, logs and chat. The command never connects to a database or
prints the key. Apply the SQL only to the intended instance. Do not upload a
development key to production; provision separate accounts there.
For the container, run the bundled CLI inside the existing `dune` container
(using its actual container ID/name), with output such as `/data/admin-owner`.
Apply the generated SQL using `node node_modules/wrangler/bin/wrangler.js d1
execute DB --local --config tools/wrangler.local.json --persist-to /data --file
/data/admin-owner/provision.sql`. A protected volume snapshot should precede
operational changes. Retrieve the key through an authorized private channel and
retain the account ID shown by the CLI. No application restart is needed.
To revoke an account, generate a new private output directory with
`node tools/admin-access.mjs --revoke ACCOUNT_UUID --out /private/dune-revoke`
and apply its `revoke.sql` using the same instance-specific command. To replace
a lost/compromised owner key, provision a replacement owner first, verify access,
then revoke the previous account. Existing games and seats are unchanged.
**Sign out everywhere** revokes browser sessions but keeps the personal key valid.
### Focused verification
```sh
npm test -- admin-access admin-directory admin-http
npm run test:integration -- admin-boundary
node tools/verify-admin.mjs --url http://localhost:3000 --key-file /private/dune-qa/access-key.txt --qa-account ACCOUNT_UUID --out /private/dune-admin-report
```
The HTTP verifier requires a **dedicated QA administrator account** and explicitly
checks its ID before proceeding: it creates one named QA room and signs out all
sessions of that QA account. Never supply a human operator's normal key. It checks
authentication, cookie properties, ordinary-host denial, directory privacy,
session revocation and unchanged game/seat continuation. Private reports retain
room IDs and safe outcomes, never keys/cookies. The container verifier provisions
its own disposable QA account and checks admin-session continuity across restart
and replacement, followed by the HTTP flow and HTTPS-proxy cookie behavior.
Local browser checks cover sign-in, directory filters, refresh restoration and
mobile layout. Final source-bound checks and separately recorded deployed
acceptance accompany each checkpoint; a local pass is not production evidence.
## Behavior and safeguards
Every privileged endpoint must authenticate and authorize its caller. Support
+7 -3
View File
@@ -9,9 +9,13 @@ Expansion starts and full rules/publication acceptance remain gated.**
The user added a complete [administration panel](ADMIN_PANEL.md) to the goal on
22 September: secure admin access, room creation/removal and lifecycle controls,
participant support, backup/restore, operational views and audited actions.
This is required scope, not implemented functionality, and belongs before full
AI refinement/calibration. Existing rules, Git, subagent and preservation
requirements remain in force.
The first prototype now connects separate administrator sign-in, persistent
sessions/revocation and a searchable, filtered, paginated room directory at
`/admin`. Private game contents remain excluded. Operator provisioning and
recovery instructions are in the admin guide. Room creation, lifecycle/removal,
participant support, backups and operational controls remain unfinished; these
belong before full AI refinement/calibration. Existing rules, Git, subagent and
preservation requirements remain in force.
The goal also requires testing the deployed application at
**https://dune.procrastination.games**. Match the running revision, exercise
+17
View File
@@ -1,5 +1,22 @@
# Dune implementation status
## 22 September 2026 — Administrator access and room directory prototype
The first [admin panel](ADMIN_PANEL.md#access-and-directory-prototype) slice adds
personal operator-provisioned keys, live owner/operator/viewer permissions,
separate persistent eight-hour sessions, current/all-session sign-out and a
privacy-preserving room directory. Additive schema/trigger changes protect the
last enabled owner, invalidate disabled-account sessions and retain safe audit
events. Filters, paging, public roster inspection, refresh and mobile controls
are connected; no existing game state is rewritten.
Independent review found and resolved malformed-roster search failure and a
revocation race at directory reads. Focused auth/SQL/privacy tests, authenticated
HTTP and local browser checks precede source-bound check/build/integration and
deployed acceptance. Evidence and actual final results belong to the checkpoint
report. Room lifecycle, creation, participant support, backups, audit browsing
and complete administration acceptance remain open; AI refinement remains gated.
## 22 September 2026 — Administration and deployed verification added to the goal
The user requested a complete [admin panel](ADMIN_PANEL.md), including adding
+83
View File
@@ -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;
+982
View File
@@ -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": {}
}
}
+7
View File
@@ -57,6 +57,13 @@
"when": 1789947127493,
"tag": "0007_clammy_sprite",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1790032690330,
"tag": "0008_admin_access",
"breakpoints": true
}
]
}
+22
View File
@@ -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;
};
+46
View File
@@ -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' } });
}
+227
View File
@@ -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 });
}
});
+93
View File
@@ -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 };
}
+567
View File
@@ -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();
}
});
+37
View File
@@ -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();
}
});
+68
View File
@@ -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(); }
});
+38
View File
@@ -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);
});
+141
View File
@@ -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;
});
+91
View File
@@ -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 }));
}
+31
View File
@@ -3,6 +3,9 @@ import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const image = process.argv[2];
if (!image) throw new Error('Usage: node tools/verify-container.mjs IMAGE');
@@ -10,6 +13,8 @@ const name = `dune-verify-${randomUUID()}`;
const volume = `${name}-data`;
const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8' }).trim();
let base;
const adminFiles = mkdtempSync(join(tmpdir(), 'dune-admin-container-'));
let adminCookie;
async function ready() {
for (let attempt = 0; attempt < 90; attempt++) {
try {
@@ -31,6 +36,19 @@ try {
docker('volume', 'create', volume);
start();
await ready();
execFileSync('node', ['tools/admin-access.mjs', '--name', 'Container QA', '--role', 'viewer', '--out', join(adminFiles, 'key')]);
execFileSync('docker', ['exec', '-i', name, 'node', '-e', "require('node:fs').writeFileSync('/tmp/dune-admin-provision.sql', require('node:fs').readFileSync(0), {mode:0o600})"], {
input: readFileSync(join(adminFiles, 'key/provision.sql')), // Container USER owns this private file.
});
docker('exec', name, 'node', 'node_modules/wrangler/bin/wrangler.js', 'd1', 'execute', 'DB', '--local', '--config', 'tools/wrangler.local.json', '--persist-to', '/data', '--file', '/tmp/dune-admin-provision.sql');
const adminKey = readFileSync(join(adminFiles, 'key/access-key.txt'), 'utf8').trim();
const adminLogin = await fetch(`${base}/api/admin/session`, {
method: 'POST', headers: { 'content-type': 'application/json', origin: base },
body: JSON.stringify({ action: 'login', key: adminKey }),
});
assert.equal(adminLogin.status, 200);
adminCookie = adminLogin.headers.get('set-cookie')?.split(';')[0];
assert.ok(adminCookie);
const headers = { 'content-type': 'application/json', origin: base };
const body = JSON.stringify({ name: 'Container verification', faction: 'atreides', advanced: false, expansions: [] });
const create = await fetch(`${base}/api/rooms`, { method: 'POST', headers, body });
@@ -46,6 +64,10 @@ try {
const result = await fetch(`${base}/api/rooms/${room.code}`, { headers: { cookie } });
assert.equal(result.status, 200);
assert.deepEqual(await result.json(), room, 'Saved seat and room must survive replacement');
if (adminCookie) {
const admin = await fetch(`${base}/api/admin/session`, { headers: { cookie: adminCookie } });
assert.equal(admin.status, 200, 'Administrator session must survive replacement');
}
}
docker('restart', '--time', '60', name);
// Docker may assign a different ephemeral host port after a restart.
@@ -57,6 +79,8 @@ try {
start();
await ready();
await restored();
execFileSync('node', ['tools/verify-admin.mjs', '--url', base, '--key-file', join(adminFiles, 'key/access-key.txt'), '--qa-account', adminKey.split('.')[1], '--out', join(adminFiles, 'http')], { stdio: 'inherit' });
adminCookie = undefined; // The acceptance test intentionally signs out all sessions.
execFileSync('npm', ['run', 'test:integration'], {
stdio: 'inherit', env: { ...process.env, DUNE_TEST_URL: base },
});
@@ -66,6 +90,12 @@ try {
start(publicOrigin);
await ready();
await restored();
const proxyAdmin = await fetch(`${base}/api/admin/session`, {
method: 'POST', headers: { 'content-type': 'application/json', origin: publicOrigin },
body: JSON.stringify({ action: 'login', key: adminKey }),
});
assert.equal(proxyAdmin.status, 200);
assert.ok(proxyAdmin.headers.get('set-cookie')?.includes('; Secure'));
const proxyCreate = await fetch(`${base}/api/rooms`, {
method: 'POST', headers: { ...headers, origin: publicOrigin }, body,
});
@@ -98,4 +128,5 @@ try {
// Names are unique to this invocation; never remove an existing application.
try { docker('rm', '-f', name); } catch { /* Already removed or not created. */ }
try { docker('volume', 'rm', volume); } catch { /* Failure remains visible. */ }
rmSync(adminFiles, { recursive: true, force: true });
}