Add administrator sign-in and private-safe room directory

Connect personal operator-provisioned admin keys, persisted roles and eight-hour sessions, current/all-session revocation and a filtered room directory. Additive schema protects final owner, revokes disabled accounts and records safe audit history. No game state changes; remaining administration scope stays explicit.

Verified: typecheck, lint, 5196 offline tests, 52 HTTP tests, production build; authenticated local HTTP and desktop/mobile browser checks. Independent privacy/security review fixed malformed nested roster searches and read-time session revocation. Preserved 1,671 prior rooms and 3,538 seats. Container/deployed acceptance follows publication.
This commit is contained in:
Quintine committed 2026-09-22 09:36:11 +10:00
1 parent 36a03bda9c
commit c2d392e155
27 files changed
+3164 -13

No files matched your search

+214
View File
@@ -0,0 +1,214 @@
export type AdminRole = 'owner' | 'operator' | 'viewer';
export type AdminAccount = { id: string; name: string; role: AdminRole };
export type AdminIdentity = AdminAccount & { sessionHash: string };
export const ADMIN_SESSION_DURATION_MS = 8 * 60 * 60 * 1000;
const UUID =
'[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}';
const KEY_PATTERN = new RegExp(`^dune-admin\\.(${UUID})\\.[0-9a-f]{64}$`);
const TOKEN_PATTERN = /^[0-9a-f]{64}$/;
export class AdminError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = 'AdminError';
}
}
function isRole(value: string): value is AdminRole {
return value === 'owner' || value === 'operator' || value === 'viewer';
}
async function hash(value: string) {
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(value),
);
return Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, '0'),
).join('');
}
function sessionToken() {
return Array.from(crypto.getRandomValues(new Uint8Array(32)), (byte) =>
byte.toString(16).padStart(2, '0'),
).join('');
}
const signInRequired = () =>
new AdminError('Administrator sign-in required.', 401);
/** Creates an independent admin session. Access keys and session tokens never enter audit records. */
export async function adminLogin(
database: D1Database,
key: unknown,
now = Date.now(),
): Promise<{
token: string;
admin: AdminAccount;
expiresAt: number;
}> {
const invalid = () =>
new AdminError('Administrator access key is invalid or revoked.', 401);
if (typeof key !== 'string') throw invalid();
const match = KEY_PATTERN.exec(key);
if (!match || match[0] !== key) throw invalid();
const keyHash = await hash(key);
const account = await database
.prepare(
'SELECT id,name,role,session_generation FROM admin_accounts WHERE id = ? AND key_hash = ? AND enabled = 1',
)
.bind(match[1], keyHash)
.first<AdminAccount & { session_generation: number }>();
if (!account || !isRole(account.role)) throw invalid();
const token = sessionToken();
const tokenHash = await hash(token);
const sessionId = crypto.randomUUID();
const expiresAt = now + ADMIN_SESSION_DURATION_MS;
// Repeat the credential and generation fence at the write. A disable or
// logout-all between the read and this batch cannot resurrect an old session.
const results = await database.batch([
database
.prepare(
`INSERT INTO admin_sessions (token_hash,id,admin_id,generation,created_at,expires_at)
SELECT ?,?,id,session_generation,?,? FROM admin_accounts
WHERE id = ? AND key_hash = ? AND enabled = 1 AND session_generation = ?`,
)
.bind(
tokenHash,
sessionId,
now,
expiresAt,
account.id,
keyHash,
account.session_generation,
),
database
.prepare(
`INSERT INTO admin_audit (id,actor_admin_id,target_admin_id,action,created_at,detail)
SELECT ?,admin_id,admin_id,'login',?,? FROM admin_sessions WHERE token_hash = ?`,
)
.bind(crypto.randomUUID(), now, JSON.stringify({ sessionId }), tokenHash),
]);
if (results[0].meta.changes !== 1) throw invalid();
// Return current role/name, and fail if revocation committed after creation.
const { id, name, role } = await requireAdmin(
database,
token,
undefined,
now,
);
return { token, admin: { id, name, role }, expiresAt };
}
/** Every privileged request checks live account status, role, generation and absolute expiry. */
export async function requireAdmin(
database: D1Database,
token: string | undefined,
roles?: AdminRole[],
now = Date.now(),
): Promise<AdminIdentity> {
if (
typeof token !== 'string' ||
token.length !== 64 ||
!TOKEN_PATTERN.test(token)
)
throw signInRequired();
const sessionHash = await hash(token);
const account = await database
.prepare(
`SELECT a.id,a.name,a.role FROM admin_accounts a
JOIN admin_sessions s ON s.admin_id = a.id
WHERE s.token_hash = ? AND s.revoked_at IS NULL AND s.expires_at > ?
AND s.generation = a.session_generation AND a.enabled = 1`,
)
.bind(sessionHash, now)
.first<AdminAccount>();
if (!account || !isRole(account.role)) throw signInRequired();
if (roles && !roles.includes(account.role))
throw new AdminError(
'Your administrator role does not permit this action.',
403,
);
return {
id: account.id,
name: account.name,
role: account.role,
sessionHash,
};
}
/** Idempotent browser logout also accepts an expired/disabled session, without granting any privilege. */
export async function adminLogout(
database: D1Database,
token: string | undefined,
now = Date.now(),
): Promise<void> {
if (
typeof token !== 'string' ||
token.length !== 64 ||
!TOKEN_PATTERN.test(token)
)
return;
const tokenHash = await hash(token);
await database.batch([
database
.prepare(
`INSERT INTO admin_audit (id,actor_admin_id,target_admin_id,action,created_at,detail)
SELECT ?,admin_id,admin_id,'logout',?,json_object('sessionId',id)
FROM admin_sessions WHERE token_hash = ? AND revoked_at IS NULL`,
)
.bind(crypto.randomUUID(), now, tokenHash),
database
.prepare(
'UPDATE admin_sessions SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL',
)
.bind(now, tokenHash),
]);
}
/** Revoke every current browser session, including this one, while retaining the access key. */
export async function adminLogoutAll(
database: D1Database,
identity: AdminIdentity,
now = Date.now(),
): Promise<void> {
if (
!identity ||
typeof identity.sessionHash !== 'string' ||
identity.sessionHash.length !== 64 ||
!TOKEN_PATTERN.test(identity.sessionHash)
)
throw signInRequired();
const auditId = crypto.randomUUID();
const results = await database.batch([
database
.prepare(
`UPDATE admin_accounts SET session_generation = session_generation + 1, updated_at = ?
WHERE id = ? AND enabled = 1 AND EXISTS (
SELECT 1 FROM admin_sessions s WHERE s.token_hash = ? AND s.admin_id = admin_accounts.id
AND s.generation = admin_accounts.session_generation AND s.revoked_at IS NULL AND s.expires_at > ?
)`,
)
.bind(now, identity.id, identity.sessionHash, now),
// changes() refers to the account update immediately before this statement.
database
.prepare(
`INSERT INTO admin_audit (id,actor_admin_id,target_admin_id,action,created_at)
SELECT ?,id,id,'logout_all',? FROM admin_accounts WHERE id = ? AND changes() = 1`,
)
.bind(auditId, now, identity.id),
database
.prepare(
`UPDATE admin_sessions SET revoked_at = ? WHERE admin_id = ? AND revoked_at IS NULL
AND EXISTS (SELECT 1 FROM admin_audit WHERE id = ? AND action = 'logout_all')`,
)
.bind(now, identity.id, auditId),
]);
if (results[0].meta.changes !== 1) throw signInRequired();
}
+90
View File
@@ -0,0 +1,90 @@
import type { Game } from '../game/engine';
import type { AdminDirectory, AdminRoom } from '../lib/admin-directory';
import { AdminError, type AdminIdentity } from './admin-access';
const phases = ['Storm', 'Spice Blow', 'CHOAM Charity', 'Bidding', 'Revival', 'Shipment & Movement', 'Battle', 'Spice Collection', 'Mentat Pause'];
const statuses = ['lobby', 'setup', 'playing', 'finished'] as const;
const expansionIds = ['ix', 'choam', 'ecaz'];
const text = (value: unknown, max = 80) => typeof value === 'string' ? value.slice(0, max) : '';
const integer = (value: unknown) => Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : null;
/** Only emit allowlisted fields. Never call viewGame with an administrator's or host's seat. */
export function projectAdminRoom(row: { code: string; version: number; updated_at: number; state: string }): AdminRoom {
const base: AdminRoom = {
code: row.code, version: row.version, updatedAt: row.updated_at,
status: 'unreadable', host: null, players: [], advanced: false, preview: false,
expansions: [], modules: [], turn: null, phase: 'Unavailable',
pending: { label: 'Saved room needs inspection', owners: [] },
};
try {
const g = JSON.parse(row.state) as Game;
if (!g || !statuses.includes(g.status) || !Array.isArray(g.players) ||
g.players.length > 6 || g.players.some(p => !p || typeof p.id !== 'string' || p.id.length > 80 || typeof p.name !== 'string')) return base;
const players: AdminRoom['players'] = g.players.map(p => ({
id: text(p.id), name: text(p.name), faction: text(p.faction, 30),
control: p.bot ? 'ai' : p.autopilot ? 'autopilot' : 'human',
}));
const seated = (id: unknown): id is string => typeof id === 'string' && players.some(p => p.id === id);
let pending: AdminRoom['pending'] = { label: 'Table decision', owners: [] };
if (g.status === 'finished') pending = { label: 'Game finished', owners: [] };
else if (g.status === 'lobby') pending = { label: 'Lobby readiness', owners: g.players.filter(p => !p.ready).map(p => p.id) };
else if (g.status === 'setup') pending = { label: 'Starting choices', owners: [] };
else if (g.pendingTreacheryDiscard) pending = { label: 'Automatic continuation', owners: [] };
else if (g.response || g.phaseOpening) pending = { label: 'Shared response window', owners: [] };
else if (g.truthtrance) pending = { label: 'Truthtrance window', owners: [] };
else if (g.decision) pending = { label: 'Special decision', owners: seated(g.decision.player) ? [g.decision.player] : [] };
else if (g.battle) pending = { label: 'Battle decisions', owners: [g.battle.attacker, g.battle.defender].filter(seated) };
else if (g.nexus) pending = { label: 'Nexus negotiations', owners: [] };
else if (g.phase === 0 && g.stormDialers?.length) pending = { label: 'Storm dials', owners: g.stormDialers.filter(id => g.stormDials?.[id] === undefined).filter(seated) };
else if (g.auction) pending = { label: 'Auction decision', owners: seated(g.auction.active) ? [g.auction.active] : [] };
else if (seated(g.active)) pending = { label: 'Turn decision', owners: [g.active] };
return { ...base, status: g.status, host: seated(g.host) ? g.host : null, players,
advanced: g.advanced === true, preview: g.advancedPreview === true,
expansions: expansionIds.filter(id => Array.isArray(g.expansions) && g.expansions.includes(id)),
modules: [g.techTokens && 'Tech Tokens', g.strongholdCards && 'Stronghold Cards', g.leaderSkills && 'Leader Skills', g.homeworlds && 'Homeworlds', g.nexusCards && 'Nexus Cards', g.discoveryEnabled && 'Discoveries', g.ecazTreachery && 'Ecaz Treachery'].filter((s): s is string => typeof s === 'string'),
turn: integer(g.turn), phase: g.status === 'lobby' ? 'Lobby' : g.status === 'setup' ? 'Setup' : phases[g.phase] ?? 'Unknown phase', pending };
} catch { return base; }
}
export async function readAdminDirectory(database: D1Database, identity: AdminIdentity, search: URLSearchParams, now = Date.now()): Promise<AdminDirectory> {
const allowed = ['q', 'status', 'rules', 'sort', 'page'];
if ([...search.keys()].some(k => !allowed.includes(k) || search.getAll(k).length !== 1)) throw new AdminError('Invalid room filters.', 400);
const q = (search.get('q') ?? '').trim();
const status = search.get('status') ?? 'all';
const rules = search.get('rules') ?? 'all';
const sort = search.get('sort') ?? 'recent';
const rawPage = search.get('page') ?? '1';
if (q.length > 80 || !['all', ...statuses].includes(status) || !['all', 'basic', 'advanced'].includes(rules) ||
!['recent', 'oldest', 'code'].includes(sort) || !/^[1-9][0-9]{0,5}$/.test(rawPage)) throw new AdminError('Invalid room filters.', 400);
const page = Number(rawPage), pageSize = 25;
// json_valid protects the rest of the directory if a legacy save is malformed.
const state = "CASE WHEN json_valid(state) THEN state ELSE '{}' END";
// Each read is fenced again inside the transaction: prior authentication is
// not authority to read after another browser revokes this session.
const authority = `SELECT 1 FROM admin_sessions s JOIN admin_accounts a ON a.id = s.admin_id
WHERE s.token_hash = ? AND a.id = ? AND a.enabled = 1 AND s.revoked_at IS NULL
AND s.expires_at > ? AND s.generation = a.session_generation
AND a.role IN ('owner','operator','viewer')`;
const credentials = [identity.sessionHash, identity.id, now];
const where: string[] = [`EXISTS (${authority})`];
const args: (string | number)[] = [...credentials];
if (q) {
const players = `CASE WHEN json_type(${state}, '$.players') = 'array' THEN json_extract(${state}, '$.players') ELSE '[]' END`;
where.push(`(code LIKE ? ESCAPE '\\' OR EXISTS (SELECT 1 FROM json_each(${players}) p WHERE json_extract(CASE WHEN p.type = 'object' THEN p.value ELSE '{}' END, '$.name') LIKE ? ESCAPE '\\'))`);
const pattern = `%${q.replace(/[\\%_]/g, '\\$&')}%`;
args.push(pattern, pattern);
}
if (status !== 'all') { where.push(`json_extract(${state}, '$.status') = ?`); args.push(status); }
if (rules !== 'all') { where.push(`json_extract(${state}, '$.advanced') = ?`); args.push(rules === 'advanced' ? 1 : 0); }
const predicate = where.length ? 'WHERE ' + where.join(' AND ') : '';
const order = sort === 'code' ? 'code ASC' : `updated_at ${sort === 'oldest' ? 'ASC' : 'DESC'}, code ASC`;
const results = await database.batch([
database.prepare(authority).bind(...credentials),
database.prepare(`SELECT COUNT(*) AS total FROM rooms ${predicate}`).bind(...args),
database.prepare(`SELECT code,version,updated_at,state FROM rooms ${predicate} ORDER BY ${order} LIMIT ? OFFSET ?`).bind(...args, pageSize, (page - 1) * pageSize),
]);
if (!results[0].results.length) throw new AdminError('Administrator sign-in required.', 401);
const total = Number((results[1].results[0] as { total: number }).total);
const rows = results[2].results as { code: string; version: number; updated_at: number; state: string }[];
return { rooms: rows.map(projectAdminRoom), total, page, pageSize };
}
+66 -6
View File
@@ -5,13 +5,20 @@ import {
primaryKey,
index,
uniqueIndex,
check,
} from 'drizzle-orm/sqlite-core';
export const rooms = sqliteTable('rooms', {
code: text('code').primaryKey(),
state: text('state').notNull(),
version: integer('version').notNull().default(0),
updatedAt: integer('updated_at').notNull(),
});
import { sql } from 'drizzle-orm';
export const rooms = sqliteTable(
'rooms',
{
code: text('code').primaryKey(),
state: text('state').notNull(),
version: integer('version').notNull().default(0),
updatedAt: integer('updated_at').notNull(),
},
(table) => [index('rooms_updated_at').on(table.updatedAt)],
);
export const seats = sqliteTable('seats', {
tokenHash: text('token_hash').primaryKey(),
revoked: integer('revoked').notNull().default(0),
@@ -152,3 +159,56 @@ export const roomMessages = sqliteTable(
),
],
);
export const adminAccounts = sqliteTable(
'admin_accounts',
{
id: text('id').primaryKey(),
name: text('name').notNull(),
role: text('role', { enum: ['owner', 'operator', 'viewer'] }).notNull(),
keyHash: text('key_hash').notNull().unique(),
enabled: integer('enabled').notNull().default(1),
sessionGeneration: integer('session_generation').notNull().default(0),
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
},
(table) => [
check(
'admin_accounts_role',
sql`${table.role} IN ('owner', 'operator', 'viewer')`,
),
check('admin_accounts_enabled', sql`${table.enabled} IN (0, 1)`),
check('admin_accounts_generation', sql`${table.sessionGeneration} >= 0`),
],
);
export const adminSessions = sqliteTable(
'admin_sessions',
{
tokenHash: text('token_hash').primaryKey(),
id: text('id').notNull().unique(),
adminId: text('admin_id')
.notNull()
.references(() => adminAccounts.id, { onDelete: 'cascade' }),
generation: integer('generation').notNull(),
createdAt: integer('created_at').notNull(),
expiresAt: integer('expires_at').notNull(),
revokedAt: integer('revoked_at'),
},
(table) => [index('admin_sessions_account').on(table.adminId)],
);
// Audit records intentionally have no cascading account/room dependency.
// `detail` contains safe operation IDs only, never credentials or game state.
export const adminAudit = sqliteTable(
'admin_audit',
{
id: text('id').primaryKey(),
actorAdminId: text('actor_admin_id'),
targetAdminId: text('target_admin_id').notNull(),
action: text('action').notNull(),
createdAt: integer('created_at').notNull(),
detail: text('detail').notNull().default('{}'),
},
(table) => [index('admin_audit_created_at').on(table.createdAt)],
);