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