From c2d392e15560ac3c14e9669e27869c8df755e5b9 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:36:11 +1000 Subject: [PATCH] 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. --- Dockerfile | 1 + README.md | 5 + app/admin/admin.css | 46 ++ app/admin/page.tsx | 155 +++++ app/api/admin/rooms/route.ts | 11 + app/api/admin/session/route.ts | 24 + app/page.tsx | 1 + db/admin-access.ts | 214 +++++++ db/admin-directory.ts | 90 +++ db/schema.ts | 72 ++- docs/ADMIN_PANEL.md | 98 ++- docs/CURRENT_STATUS.md | 10 +- docs/IMPLEMENTATION_STATUS.md | 17 + drizzle/0008_admin_access.sql | 83 +++ drizzle/meta/0008_snapshot.json | 982 ++++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/admin-directory.ts | 22 + lib/admin-http.ts | 46 ++ tests/admin-access-cli.test.ts | 227 +++++++ tests/admin-access-fixture.ts | 93 +++ tests/admin-access.test.ts | 567 +++++++++++++++++ tests/admin-boundary-http.test.ts | 37 ++ tests/admin-directory.test.ts | 68 +++ tests/admin-http.test.ts | 38 ++ tools/admin-access.mjs | 141 +++++ tools/verify-admin.mjs | 91 +++ tools/verify-container.mjs | 31 + 27 files changed, 3164 insertions(+), 13 deletions(-) create mode 100644 app/admin/admin.css create mode 100644 app/admin/page.tsx create mode 100644 app/api/admin/rooms/route.ts create mode 100644 app/api/admin/session/route.ts create mode 100644 db/admin-access.ts create mode 100644 db/admin-directory.ts create mode 100644 drizzle/0008_admin_access.sql create mode 100644 drizzle/meta/0008_snapshot.json create mode 100644 lib/admin-directory.ts create mode 100644 lib/admin-http.ts create mode 100644 tests/admin-access-cli.test.ts create mode 100644 tests/admin-access-fixture.ts create mode 100644 tests/admin-access.test.ts create mode 100644 tests/admin-boundary-http.test.ts create mode 100644 tests/admin-directory.test.ts create mode 100644 tests/admin-http.test.ts create mode 100644 tools/admin-access.mjs create mode 100644 tools/verify-admin.mjs diff --git a/Dockerfile b/Dockerfile index fc5c4e32..00b59f35 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index bf1f25f3..f5d4387c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/admin/admin.css b/app/admin/admin.css new file mode 100644 index 00000000..3600eda5 --- /dev/null +++ b/app/admin/admin.css @@ -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); } +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 00000000..983f4698 --- /dev/null +++ b/app/admin/page.tsx @@ -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(null); + const [restoring, setRestoring] = useState(true); + const [key, setKey] = useState(''); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(''); + const [directory, setDirectory] = useState(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('/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
+
+
Arrakis Table

+ {account &&
{account.name} {account.role} +
} +
+ {notice && {notice}} + {restoring ? Checking administrator access… : !account ? +
+

Administrator sign-in

+

Use your personal administrator access key. Room invitations and saved-seat recovery kits cannot sign in here.

+
{ event.preventDefault(); void sessionAction('login'); }}> + + setKey(event.target.value)} required disabled={busy} /> + +
+

First setup: the server owner creates a personal key using the installation instructions. Access is closed until an administrator is provisioned.

+
: <> +
+

Rooms

Public room details. Cards, sealed choices and private discussion stay hidden.

+
+
{ event.preventDefault(); void load(filters); }}> + + + + + +
+
+ {!directory ? {busy ? 'Loading rooms…' : 'Use Refresh to load rooms.'} : <> +

{directory.total.toLocaleString()} {directory.total === 1 ? 'room' : 'rooms'} match

+ {directory.rooms.length === 0 ?

No rooms on this page. Change the filters or return to the first page.

: + + Room & playersRulesProgressLast game change + {directory.rooms.map(room => + {room.code} {room.status} +

Host: {room.players.find(p => p.id === room.host)?.name ?? 'Unavailable'}

+
{room.players.length} {room.players.length === 1 ? 'player' : 'players'}
    {room.players.map(player =>
  • {player.name} · {FACTIONS.find(f => f.id === player.faction)?.name ?? player.faction} · {player.control === 'ai' ? 'AI' : player.control === 'autopilot' ? 'Human seat on AI' : 'Human'}
  • )}
+
+ {room.advanced ? room.preview ? 'Advanced preview' : 'Advanced development' : 'Basic'} + {room.expansions.length > 0 &&

Expansions: {room.expansions.map(id => id === 'ix' ? 'Ixians & Tleilaxu' : id === 'choam' ? 'CHOAM & Richese' : 'Ecaz & Moritani').join(', ')}

} +

{room.modules.join(', ') || 'No optional modules'}

+
+ {room.turn !== null && room.status !== 'lobby' ? `Turn ${room.turn} · ` : ''}{room.phase} +

{room.pending.label}{room.pending.owners.length > 0 ? ': ' + room.pending.owners.map(id => room.players.find(p => p.id === id)?.name).filter(Boolean).join(', ') : ''}

+
+

Version {room.version}

+
)}
} + + } +
+
+

Your access

+

Sessions expire after eight hours. Sign out everywhere to end all your current administrator sessions, including this browser. Your personal access key remains valid.

+ {!confirmAll ? : +

End all your administrator sessions now?

} +
+

Administration preview: room creation, lifecycle controls, participant support and backup tools are still being implemented.

+ } +
; +} diff --git a/app/api/admin/rooms/route.ts b/app/api/admin/rooms/route.ts new file mode 100644 index 00000000..e900ee5c --- /dev/null +++ b/app/api/admin/rooms/route.ts @@ -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); } +} diff --git a/app/api/admin/session/route.ts b/app/api/admin/session/route.ts new file mode 100644 index 00000000..da7e8f6b --- /dev/null +++ b/app/api/admin/session/route.ts @@ -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); } +} diff --git a/app/page.tsx b/app/page.tsx index d5da75f6..afb10945 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1125,6 +1125,7 @@ export default function Home() {
Unofficial fan implementation + Administration Dune · A game of conquest, diplomacy & betrayal
diff --git a/db/admin-access.ts b/db/admin-access.ts new file mode 100644 index 00000000..e02ec986 --- /dev/null +++ b/db/admin-access.ts @@ -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(); + 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 { + 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(); + 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 { + 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 { + 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(); +} diff --git a/db/admin-directory.ts b/db/admin-directory.ts new file mode 100644 index 00000000..be9f5692 --- /dev/null +++ b/db/admin-directory.ts @@ -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 { + 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 }; +} diff --git a/db/schema.ts b/db/schema.ts index 84d5436d..7f27fd8c 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -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)], +); diff --git a/docs/ADMIN_PANEL.md b/docs/ADMIN_PANEL.md index d55cd99a..3833fd4d 100644 --- a/docs/ADMIN_PANEL.md +++ b/docs/ADMIN_PANEL.md @@ -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 diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 48cba95c..c36fe027 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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 diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index dd08d8f7..9999e77b 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -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 diff --git a/drizzle/0008_admin_access.sql b/drizzle/0008_admin_access.sql new file mode 100644 index 00000000..3167c5df --- /dev/null +++ b/drizzle/0008_admin_access.sql @@ -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; diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 00000000..0718dbda --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 89153993..3d9864e2 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1789947127493, "tag": "0007_clammy_sprite", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1790032690330, + "tag": "0008_admin_access", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/admin-directory.ts b/lib/admin-directory.ts new file mode 100644 index 00000000..90e78d61 --- /dev/null +++ b/lib/admin-directory.ts @@ -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; +}; diff --git a/lib/admin-http.ts b/lib/admin-http.ts new file mode 100644 index 00000000..2ce8ad8f --- /dev/null +++ b/lib/admin-http.ts @@ -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> { + // 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; +} +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' } }); +} diff --git a/tests/admin-access-cli.test.ts b/tests/admin-access-cli.test.ts new file mode 100644 index 00000000..a28d243f --- /dev/null +++ b/tests/admin-access-cli.test.ts @@ -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 }); + } +}); diff --git a/tests/admin-access-fixture.ts b/tests/admin-access-fixture.ts new file mode 100644 index 00000000..c20a7285 --- /dev/null +++ b/tests/admin-access-fixture.ts @@ -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 } = {}; + 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) { + let entered!: () => void; + let release!: () => void; + const waiting = new Promise((resolve) => (entered = resolve)); + const gate = new Promise((resolve) => (release = resolve)); + store.hooks.beforeBatch = async () => { + delete store.hooks.beforeBatch; + entered(); + await gate; + }; + return { waiting, release }; +} diff --git a/tests/admin-access.test.ts b/tests/admin-access.test.ts new file mode 100644 index 00000000..f2d5f841 --- /dev/null +++ b/tests/admin-access.test.ts @@ -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) { + 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(); + } +}); diff --git a/tests/admin-boundary-http.test.ts b/tests/admin-boundary-http.test.ts new file mode 100644 index 00000000..e7b5b100 --- /dev/null +++ b/tests/admin-boundary-http.test.ts @@ -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[]) { + 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(); + } +}); diff --git a/tests/admin-directory.test.ts b/tests/admin-directory.test.ts new file mode 100644 index 00000000..beca556c --- /dev/null +++ b/tests/admin-directory.test.ts @@ -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(); } +}); diff --git a/tests/admin-http.test.ts b/tests/admin-http.test.ts new file mode 100644 index 00000000..3bbe7d22 --- /dev/null +++ b/tests/admin-http.test.ts @@ -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 = {}) => 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[]) { + 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); +}); diff --git a/tools/admin-access.mjs b/tools/admin-access.mjs new file mode 100644 index 00000000..3647cb91 --- /dev/null +++ b/tools/admin-access.mjs @@ -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 --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; +}); diff --git a/tools/verify-admin.mjs b/tools/verify-admin.mjs new file mode 100644 index 00000000..867b5d6b --- /dev/null +++ b/tools/verify-admin.mjs @@ -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 })); +} diff --git a/tools/verify-container.mjs b/tools/verify-container.mjs index 128af8ff..3611e17e 100644 --- a/tools/verify-container.mjs +++ b/tools/verify-container.mjs @@ -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 }); }