diff --git a/app/admin/operations/page.tsx b/app/admin/operations/page.tsx index 27506823..1ad06331 100644 --- a/app/admin/operations/page.tsx +++ b/app/admin/operations/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { ClientRequestError, requestJson } from '@/lib/client-request'; +import type { AdminAttempt, AdminAttemptReason } from '@/db/admin-attempts'; import type { AdminIntegrity, AdminOperations, AdminStalledDecision } from '@/db/admin-operations'; import '../admin.css'; import './operations.css'; @@ -10,6 +11,20 @@ import './operations.css'; type Account = { id: string; role: 'owner' | 'operator' | 'viewer' }; type Snapshot = AdminOperations & { revision: string }; type Stalled = { observedAt: number; stallMs: number; stalled: AdminStalledDecision[] }; +type Attempts = { + observedAt: number; + windowMs: number; + total: number; + byReason: { reason: AdminAttemptReason; count: number }[]; + recent: AdminAttempt[]; +}; +const ATTEMPT_LABELS: Record = { + invalid_key_format: 'Malformed access key', + unknown_or_disabled_key: 'Unknown or disabled access key', + missing_session: 'Missing or malformed session', + unknown_or_expired_session: 'Unknown, expired or revoked session', + role_denied: 'Role does not permit the action', +}; const message = (error: unknown) => error instanceof Error ? error.message : 'Operational status is unavailable.'; const idle = (idleMs: number) => `${Math.floor(idleMs / 3_600_000)}h ${Math.floor(idleMs % 3_600_000 / 60_000)}m`; @@ -26,6 +41,10 @@ export default function OperationsPage() { const [stalledNotice, setStalledNotice] = useState(''); const [loadingStalled, setLoadingStalled] = useState(false); const stalledEpoch = useRef(0); + const [attempts, setAttempts] = useState(null); + const [attemptsNotice, setAttemptsNotice] = useState(''); + const [loadingAttempts, setLoadingAttempts] = useState(false); + const attemptsEpoch = useRef(0); useEffect(() => { let canceled = false; const epoch = integrityEpoch; @@ -89,11 +108,34 @@ export default function OperationsPage() { } } + async function checkAttempts() { + const epoch = ++attemptsEpoch.current; + setLoadingAttempts(true); + setAttempts(null); + setAttemptsNotice(''); + try { + const { admin } = await requestJson<{ admin: Account }>('/api/admin/session', { cache: 'no-store' }); + if (admin.role !== 'owner') throw new Error('Operations are available only to an owner.'); + const result = await requestJson('/api/admin/operations?attempts=1', { + cache: 'no-store', headers: { 'X-Dune-Admin-Id': admin.id }, + }); + if (epoch === attemptsEpoch.current) setAttempts(result); + } catch (error) { + if (epoch === attemptsEpoch.current) + setAttemptsNotice(error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0) + ? 'Owner access changed or expired. Sign in to administration again.' : message(error)); + } finally { + if (epoch === attemptsEpoch.current) setLoadingAttempts(false); + } + } + return
Administration

Operations

+

Read-only counters and the twenty most recent rejected administrator requests. Only a fixed reason, the attempted role and an already-known account id are stored: never a key, session token, request body or address.

+ {loadingAttempts && Sampling rejected requests…} + {attemptsNotice &&

{attemptsNotice}

} + {attempts &&
+

Sampled {new Date(attempts.observedAt).toLocaleString()}; counters cover the last {Math.round(attempts.windowMs / 86_400_000)} days ({attempts.total} rejected requests).

+ {attempts.byReason.length === 0 ?

No rejected administrator request was recorded in the window.

+ :
    {attempts.byReason.map(row =>
  • {ATTEMPT_LABELS[row.reason]}: {row.count}
  • )}
} + {attempts.recent.length === 0 ? null + : + + + {attempts.recent.map((row, index) => + + + + + + )} +
Most recent rejected requests, newest first
WhenKindReasonRoleAccount
{new Date(row.createdAt).toLocaleString()}{row.kind}{ATTEMPT_LABELS[row.reason]}{row.role ?? '—'}{row.accountId ? `${row.accountId.slice(0, 8)}…` : '—'}
} +

This sample changes nothing and identifies no credential. Use it to notice repeated failures, not to lock out a seat or account automatically.

+
}

This manual, read-only SQLite quick check and foreign-key check may take time. It reports pass/fail only: not disk capacity, backup recoverability or game-rule validity.

{checking && Checking database structure and references…} diff --git a/app/api/admin/operations/route.ts b/app/api/admin/operations/route.ts index 211f31e6..61cb5885 100644 --- a/app/api/admin/operations/route.ts +++ b/app/api/admin/operations/route.ts @@ -1,5 +1,6 @@ import { env } from 'cloudflare:workers'; import { AdminError, requireAdmin } from '@/db/admin-access'; +import { readAdminAttempts } from '@/db/admin-attempts'; import { ADMIN_STALL_MS, readAdminIntegrity, readAdminOperations, readAdminStalledDecisions } from '@/db/admin-operations'; import { adminFailure, adminResponse, adminToken } from '@/lib/admin-http'; import { buildRevision } from '@/lib/build-revision'; @@ -11,7 +12,7 @@ export async function GET(request: Request) { if (expected && expected !== identity.id) throw new AdminError('The signed-in administrator changed. Reload operations.', 401); const query = new URL(request.url).searchParams; - const reads = ['integrity', 'stalled']; + const reads = ['integrity', 'stalled', 'attempts']; if (query.size && (query.size !== 1 || !reads.some(read => query.get(read) === '1'))) throw new AdminError('Invalid operations request.', 400); if (query.get('integrity') === '1') @@ -19,6 +20,8 @@ export async function GET(request: Request) { if (query.get('stalled') === '1') return adminResponse({ revision: buildRevision, observedAt: Date.now(), stallMs: ADMIN_STALL_MS, stalled: await readAdminStalledDecisions(env.DB, identity) }); + if (query.get('attempts') === '1') + return adminResponse({ revision: buildRevision, ...await readAdminAttempts(env.DB, identity) }); return adminResponse({ revision: buildRevision, ...await readAdminOperations(env.DB, identity) }); } catch (error) { return adminFailure(error); } } diff --git a/db/admin-access.ts b/db/admin-access.ts index e2a8c98a..a166df73 100644 --- a/db/admin-access.ts +++ b/db/admin-access.ts @@ -1,3 +1,6 @@ +import { AdminError } from './admin-error'; +import { recordAdminAttempt } from './admin-attempts'; + export type AdminRole = 'owner' | 'operator' | 'viewer'; export type AdminAccount = { id: string; name: string; role: AdminRole }; export type AdminIdentity = AdminAccount & { sessionHash: string }; @@ -9,15 +12,7 @@ const UUID = 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'; - } -} +export { AdminError }; function isRole(value: string): value is AdminRole { return value === 'owner' || value === 'operator' || value === 'viewer'; @@ -54,9 +49,15 @@ export async function adminLogin( }> { const invalid = () => new AdminError('Administrator access key is invalid or revoked.', 401); - if (typeof key !== 'string') throw invalid(); + if (typeof key !== 'string') { + await recordAdminAttempt(database, { kind: 'login', reason: 'invalid_key_format' }, now); + throw invalid(); + } const match = KEY_PATTERN.exec(key); - if (!match || match[0] !== key) throw invalid(); + if (!match || match[0] !== key) { + await recordAdminAttempt(database, { kind: 'login', reason: 'invalid_key_format' }, now); + throw invalid(); + } const keyHash = await hash(key); const account = await database .prepare( @@ -64,7 +65,12 @@ export async function adminLogin( ) .bind(match[1], keyHash) .first(); - if (!account || !isRole(account.role)) throw invalid(); + if (!account || !isRole(account.role)) { + // The lookup cannot distinguish an unknown key from a disabled account + // without becoming an account-enumeration oracle, so both share one reason. + await recordAdminAttempt(database, { kind: 'login', reason: 'unknown_or_disabled_key' }, now); + throw invalid(); + } const token = sessionToken(); const tokenHash = await hash(token); @@ -95,7 +101,14 @@ export async function adminLogin( ) .bind(crypto.randomUUID(), now, JSON.stringify({ sessionId }), tokenHash), ]); - if (results[0].meta.changes !== 1) throw invalid(); + if (results[0].meta.changes !== 1) { + await recordAdminAttempt( + database, + { kind: 'login', reason: 'unknown_or_disabled_key', accountId: account.id }, + now, + ); + throw invalid(); + } // Return current role/name, and fail if revocation committed after creation. const { id, name, role } = await requireAdmin( database, @@ -117,8 +130,10 @@ export async function requireAdmin( typeof token !== 'string' || token.length !== 64 || !TOKEN_PATTERN.test(token) - ) + ) { + await recordAdminAttempt(database, { kind: 'session', reason: 'missing_session' }, now); throw signInRequired(); + } const sessionHash = await hash(token); const account = await database .prepare( @@ -129,12 +144,25 @@ export async function requireAdmin( ) .bind(sessionHash, now) .first(); - if (!account || !isRole(account.role)) throw signInRequired(); - if (roles && !roles.includes(account.role)) + if (!account || !isRole(account.role)) { + await recordAdminAttempt( + database, + { kind: 'session', reason: 'unknown_or_expired_session' }, + now, + ); + throw signInRequired(); + } + if (roles && !roles.includes(account.role)) { + await recordAdminAttempt( + database, + { kind: 'role', reason: 'role_denied', role: account.role, accountId: account.id }, + now, + ); throw new AdminError( 'Your administrator role does not permit this action.', 403, ); + } return { id: account.id, name: account.name, diff --git a/db/admin-attempts.ts b/db/admin-attempts.ts new file mode 100644 index 00000000..06b83af6 --- /dev/null +++ b/db/admin-attempts.ts @@ -0,0 +1,111 @@ +import { AdminError } from './admin-error'; +import type { AdminIdentity, AdminRole } from './admin-access'; + +/** Rejected authentication or authorization attempts, in durable, secret-free form. */ +export type AdminAttemptKind = 'login' | 'session' | 'role'; +export type AdminAttemptReason = + | 'invalid_key_format' + | 'unknown_or_disabled_key' + | 'missing_session' + | 'unknown_or_expired_session' + | 'role_denied'; + +export type AdminAttempt = { + createdAt: number; + kind: AdminAttemptKind; + reason: AdminAttemptReason; + role: AdminRole | null; + accountId: string | null; +}; + +export type AdminAttempts = { + observedAt: number; + windowMs: number; + total: number; + byReason: { reason: AdminAttemptReason; count: number }[]; + recent: AdminAttempt[]; +}; + +/** Bounded owner sample; the page never lists every rejected request. */ +export const ADMIN_ATTEMPT_LIMIT = 20; +/** Reporting window for the counters. */ +export const ADMIN_ATTEMPT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + +const ownerAuthority = `SELECT a.role 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 a.role='owner' + AND s.revoked_at IS NULL AND s.expires_at>? AND s.generation=a.session_generation`; + +/** Never stores a key, token, hash, request body or address: only the reason. */ +export async function recordAdminAttempt( + database: D1Database, + attempt: { + kind: AdminAttemptKind; + reason: AdminAttemptReason; + role?: AdminRole | null; + accountId?: string | null; + }, + now = Date.now(), +): Promise { + try { + await database + .prepare( + `INSERT INTO admin_attempts (id,created_at,kind,reason,role,account_id) + VALUES (?,?,?,?,?,?)`, + ) + .bind( + crypto.randomUUID(), + now, + attempt.kind, + attempt.reason, + attempt.role ?? null, + attempt.accountId ?? null, + ) + .run(); + } catch { + // Recording is best-effort: a rejected request must keep its own answer + // even when the attempt table is unavailable. + } +} + +/** Owner-only, read-only counters and a bounded recent sample. */ +export async function readAdminAttempts( + database: D1Database, + identity: AdminIdentity, + now = Date.now(), +): Promise { + const since = now - ADMIN_ATTEMPT_WINDOW_MS; + const authority = database.prepare(ownerAuthority).bind(identity.sessionHash, identity.id, now); + const counters = database + .prepare( + `SELECT reason, COUNT(*) count FROM admin_attempts WHERE created_at>=? + GROUP BY reason ORDER BY reason`, + ) + .bind(since); + const recent = database + .prepare( + `SELECT created_at createdAt, kind, reason, role, account_id accountId + FROM admin_attempts ORDER BY created_at DESC LIMIT ?`, + ) + .bind(ADMIN_ATTEMPT_LIMIT); + const [authorized, grouped, latest] = await database.batch([ + authority, + counters, + recent, + ]); + if (!authorized.results.length) throw new AdminError('Owner sign-in required.', 403); + const byReason = (grouped.results as { reason: AdminAttemptReason; count: number }[]) + .map((row) => ({ reason: row.reason, count: row.count })); + return { + observedAt: now, + windowMs: ADMIN_ATTEMPT_WINDOW_MS, + total: byReason.reduce((sum, row) => sum + row.count, 0), + byReason, + recent: (latest.results as AdminAttempt[]).map((row) => ({ + createdAt: row.createdAt, + kind: row.kind, + reason: row.reason, + role: row.role ?? null, + accountId: row.accountId ?? null, + })), + }; +} diff --git a/db/admin-error.ts b/db/admin-error.ts new file mode 100644 index 00000000..95797f40 --- /dev/null +++ b/db/admin-error.ts @@ -0,0 +1,10 @@ +/** Shared administrator failure type; its `status` drives the HTTP answer. */ +export class AdminError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = 'AdminError'; + } +} diff --git a/db/schema.ts b/db/schema.ts index 0f95f487..db041ac7 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -557,3 +557,29 @@ export const adminRoomBackupDownloads = sqliteTable( }, (table) => [index('admin_room_backup_downloads_room').on(table.roomCode, table.createdAt)], ); + +// Rejected administrator authentication and authorization attempts. Secret-free: +// only a fixed reason code, the attempted role and an already-known account id. +export const adminAttempts = sqliteTable( + 'admin_attempts', + { + id: text('id').primaryKey(), + createdAt: integer('created_at').notNull(), + kind: text('kind').notNull(), + reason: text('reason').notNull(), + role: text('role'), + accountId: text('account_id'), + }, + (table) => [ + index('admin_attempts_created').on(table.createdAt), + check('admin_attempts_kind', sql`${table.kind} IN ('login','session','role')`), + check( + 'admin_attempts_reason', + sql`${table.reason} IN ('invalid_key_format','unknown_or_disabled_key','missing_session','unknown_or_expired_session','role_denied')`, + ), + check( + 'admin_attempts_role', + sql`${table.role} IS NULL OR ${table.role} IN ('owner','operator','viewer')`, + ), + ], +); diff --git a/docs/ADMIN_ATTEMPTS.md b/docs/ADMIN_ATTEMPTS.md new file mode 100644 index 00000000..e906d21c --- /dev/null +++ b/docs/ADMIN_ATTEMPTS.md @@ -0,0 +1,51 @@ +# Administrator failed-attempt history + +Added 7 October 2026 as owner-only, read-only administration scope. It records +rejected administrator authentication and authorization attempts in a durable, +secret-free form so an operator can notice repeated failures without learning +anything private about the person who made them. + +## What is recorded + +| Kind | Reason | When | +| --- | --- | --- | +| `login` | `invalid_key_format` | The presented key is not a string or does not match the printed key shape. | +| `login` | `unknown_or_disabled_key` | No enabled account matches the key hash, or the credential changed between the read and the session write. | +| `session` | `missing_session` | No session cookie, or a token that is not 64 hex characters. | +| `session` | `unknown_or_expired_session` | The session is unknown, revoked, expired, generation-stale or belongs to a disabled account. | +| `role` | `role_denied` | A live session whose role is not in the requested set. | + +The unknown/disabled distinction is deliberately collapsed into one reason: the +lookup cannot separate them without becoming an account-enumeration oracle. + +Only the reason, the kind, the attempted role and an already-known account id +are stored. **No key, key hash, session token, session hash, request body, +origin, address or user agent is written.** A successful sign-in and a +successful privileged request record nothing. + +## Reading it + +`GET /api/admin/operations?attempts=1` requires a live owner session, checks the +`X-Dune-Admin-Id` header against that session, and answers `no-store`. Any other +query combination is rejected with `400`, and an anonymous or non-owner request +is rejected with `401`/`403` before any row is read. The same request without a +query returns the aggregate sample, `?integrity=1` runs the manual SQLite check +and `?stalled=1` samples stalled rooms. + +The response carries the sample time, the seven-day reporting window, the total +inside that window, per-reason counters and at most `ADMIN_ATTEMPT_LIMIT` +(twenty) of the most recent attempts, newest first. The operations page renders +both, truncating each account id to eight characters. + +## Privacy and safety + +Recording is **best-effort**: `recordAdminAttempt` swallows its own failure, so +a rejected request keeps its original answer even when the attempts table is +unavailable. The write happens inside the same rejection path that already +decided the outcome, so it cannot turn a rejection into an approval, and the +read path repeats live owner authority inside its own batch. + +The history is diagnostic only. Nothing in it locks out an account, disables a +key, revokes a session or repairs a room automatically; those stay explicit +owner actions with their own audit. It also does not rate-limit or block +requests — it reports them. diff --git a/docs/ADMIN_PANEL.md b/docs/ADMIN_PANEL.md index 63906543..b6487a92 100644 --- a/docs/ADMIN_PANEL.md +++ b/docs/ADMIN_PANEL.md @@ -9,13 +9,13 @@ recovery and persistence systems where suitable. | Area | Required behavior | Stage | | --- | --- | --- | -| Administrator access | Personal access keys, server-enforced owner/operator/viewer roles, persistent eight-hour sessions and individual/all-session sign-out. [Owner-only account management](ADMIN_ACCOUNTS.md) adds a paginated directory, browser-created one-time keys, role changes, disabling and fresh-key rotation/re-enable with session revocation, live authority and exact retries. A room host is not a site administrator. Failed-attempt history and further operational permissions remain. | Prototyped, partial | +| Administrator access | Personal access keys, server-enforced owner/operator/viewer roles, persistent eight-hour sessions and individual/all-session sign-out. [Owner-only account management](ADMIN_ACCOUNTS.md) adds a paginated directory, browser-created one-time keys, role changes, disabling and fresh-key rotation/re-enable with session revocation, live authority and exact retries. A room host is not a site administrator. [Failed-attempt history](ADMIN_ATTEMPTS.md) records rejected sign-ins and role denials without storing any credential; further operational permissions remain. | Prototyped, partial | | Room directory | Search room codes/public player names, filter status/rules/availability, sort and paginate; show host, roster, modules, game change time, pause/join flags and bounded decision ownership. Detailed setup/shared-window ownership remains. | Prototyped | | Create and configure | [Create a lobby](ADMIN_ROOM_CREATION.md) with an explicitly owned new host seat, Basic/Advanced preview and initial AI configuration; invite humans and use ordinary lobby controls/voluntary host-seat handover. [Neutral lobby configuration](ADMIN_LOBBY_CONFIGURATION.md) adds rules/modules, AI seats and host assignment to an existing human without private access. Reserved human seats remain. | Prototyped, partial | | Lifecycle and removal | [Pause/resume and joining locks](ADMIN_ROOM_CONTROLS.md), [administrative close/reopen](ADMIN_ROOM_CLOSURE.md) and [recoverable removal/restoration](ADMIN_ROOM_REMOVAL.md) have controls, durable audit, exact retries and player/AI enforcement. [Archive/unarchive](ADMIN_ROOM_ARCHIVE.md) has separate directory filters and exact retries. Permanent deletion and bulk actions remain missing. | Prototyped, partial | | Participant support | [Participant AI](ADMIN_PARTICIPANT_AI.md) enables an existing difficulty on an eligible human seat in a paused game, retaining access/takeback and private custody. [Discussion controls](ADMIN_DISCUSSION.md) mute/unmute new sends while retaining history and gameplay. Participant removal, access revocation, assisted recovery and further replacement remain. Existing lobby controls can reassign the host. | Prototyped, partial | | Saved-game operations | [Owner-only room backups](ADMIN_BACKUPS.md) capture/list/download a size-limited, version-fenced room snapshot without changing play or access; import validation, safe checkpoint restore and interrupted-work diagnosis/resume remain missing. | Prototyped, partial | -| Operations and audit | [Action history](ADMIN_ACTION_HISTORY.md) searches existing durable operations with safe settings and role-limited reasons. The owner-only [operations page](../app/admin/operations/page.tsx) samples build revision, room/seat flags, JSON syntax failures and backup/export counts without exposing game contents. [Stalled-decision diagnosis](ADMIN_STALLED_DECISIONS.md) adds a bounded, read-only sample of live rooms whose public pending interaction has been idle longest, with aggregate counters. An explicit owner action runs read-only SQLite quick/foreign-key checks and returns only pass/fail, never corrupt row details. Failed-attempt records, disk capacity and maintenance controls remain. | Prototyped, partial | +| Operations and audit | [Action history](ADMIN_ACTION_HISTORY.md) searches existing durable operations with safe settings and role-limited reasons. The owner-only [operations page](../app/admin/operations/page.tsx) samples build revision, room/seat flags, JSON syntax failures and backup/export counts without exposing game contents. [Stalled-decision diagnosis](ADMIN_STALLED_DECISIONS.md) adds a bounded, read-only sample of live rooms whose public pending interaction has been idle longest, with aggregate counters. An explicit owner action runs read-only SQLite quick/foreign-key checks and returns only pass/fail, never corrupt row details. [Failed-attempt history](ADMIN_ATTEMPTS.md) records rejected authentication and authorization attempts with a fixed reason, the attempted role and an already-known account id — never a key, token, body or address — and exposes seven-day counters plus a bounded recent sample to the owner. Disk capacity and maintenance controls remain. | Prototyped, partial | Track each area through Missing, Prototyped, Integrated, Verified and Polished. As implementation begins, link its controls, server actions, persistence, @@ -84,6 +84,14 @@ The check can take time; a failed check calls for a protected snapshot and private diagnosis, not automatic repair. It does not prove disk capacity, backup recoverability or valid game rules. +[Failed-attempt history](ADMIN_ATTEMPTS.md) adds **List failed attempts**: seven-day +per-reason counters and the twenty most recent rejected administrator requests — +malformed, unknown or disabled keys, missing, unknown, expired or revoked +sessions, and role-denied actions. Recording is best-effort inside the rejection +path and stores no credential; the read repeats live owner authority and +answers `no-store`. The history is diagnostic: it never locks out, disables or +revokes anything by itself. + [Stalled-decision diagnosis](ADMIN_STALLED_DECISIONS.md) adds **List stalled decisions**: a bounded, read-only sample of at most ten live rooms whose public pending interaction — a decision, response, Truthtrance or phase opening — has diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 58c2e71b..5d7b6380 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -74,6 +74,15 @@ eligibility and Tleilaxu income repeat scope stay gated exactly as recorded in [TLEILAXU_AMBASSADOR_RULES.md](TLEILAXU_AMBASSADOR_RULES.md#implementation-decision-boundary); no ordinary revival allowance, price, phase or income path changed. +**In progress — administrator failed-attempt history.** Rejected administrator +authentication and authorization attempts are now recorded durably with a fixed +reason, the attempted role and an already-known account id — never a key, token, +body or address — and the owner-only operations page lists seven-day per-reason +counters plus the twenty most recent entries. Recording is best-effort inside +the rejection path, so a rejected request keeps its own answer, and the history +never locks out, disables or revokes anything by itself. See +[ADMIN_ATTEMPTS.md](ADMIN_ATTEMPTS.md). + **In progress — administrator operations.** The owner-only operations page now also diagnoses [stalled decisions](ADMIN_STALLED_DECISIONS.md): a bounded, read-only sample of at most ten live rooms whose public pending interaction — a @@ -82,7 +91,7 @@ decision, response, Truthtrance or phase opening — has been idle longest, plus Paused, closed, removed and archived rooms are excluded and no private game content is selected; `GET /api/admin/operations?stalled=1` stays owner-only, `no-store` and read-only, and it repairs nothing. Failed-attempt records, disk -capacity and maintenance controls remain. +Disk capacity and maintenance controls remain. **Mixed E1/E2 and paired E3 Nexus — bounded Development Verified.** diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index cf1c272f..a001ee31 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,34 @@ # Dune implementation status +## 7 October 2026 — administrator failed-attempt history + +Rejected administrator authentication and authorization attempts are now +recorded durably in a new additive `admin_attempts` table with a fixed reason +(`invalid_key_format`, `unknown_or_disabled_key`, `missing_session`, +`unknown_or_expired_session`, `role_denied`), the attempt kind, the attempted +role and an already-known account id. No key, key hash, session token, session +hash, request body, origin, address or user agent is stored, and the +unknown/disabled distinction is deliberately collapsed so the lookup cannot +become an account-enumeration oracle. + +`GET /api/admin/operations?attempts=1` returns seven-day per-reason counters +plus the twenty most recent attempts, newest first, to a live owner only, with +`no-store` and the same query guard as the other reads. The operations page adds +**List failed attempts** with the counters and the bounded sample, truncating +each account id to eight characters. Recording is best-effort inside the +rejection path, so a rejected request keeps its own answer even when the table +is unavailable; the history never locks out, disables or revokes anything by +itself. + +Evidence: `tests/admin-attempts.test.ts` asserts every recorded reason, the +kind/role/account fields, that successful sign-ins and privileged reads record +nothing, that no credential text appears anywhere in the page, the reporting +window, the twenty-row bound and owner-only recheck after demotion, and that a +dropped attempts table leaves the original rejection answer unchanged. The +anonymous HTTP boundary also denies `?attempts=1`. The shared `AdminError` moved +to `db/admin-error.ts` so the recorder and the access module no longer form a +value-import cycle. No game, seat, saved state or deployment changes. + ## 7 October 2026 — Sneak Attack composes the Bene Gesserit arrival reactions A positive Moritani Sneak Attack now resolves its committed Terror entry first diff --git a/drizzle/0021_wealthy_thunderbird.sql b/drizzle/0021_wealthy_thunderbird.sql new file mode 100644 index 00000000..58668d36 --- /dev/null +++ b/drizzle/0021_wealthy_thunderbird.sql @@ -0,0 +1,13 @@ +CREATE TABLE `admin_attempts` ( + `id` text PRIMARY KEY NOT NULL, + `created_at` integer NOT NULL, + `kind` text NOT NULL, + `reason` text NOT NULL, + `role` text, + `account_id` text, + CONSTRAINT "admin_attempts_kind" CHECK("admin_attempts"."kind" IN ('login','session','role')), + CONSTRAINT "admin_attempts_reason" CHECK("admin_attempts"."reason" IN ('invalid_key_format','unknown_or_disabled_key','missing_session','unknown_or_expired_session','role_denied')), + CONSTRAINT "admin_attempts_role" CHECK("admin_attempts"."role" IS NULL OR "admin_attempts"."role" IN ('owner','operator','viewer')) +); +--> statement-breakpoint +CREATE INDEX `admin_attempts_created` ON `admin_attempts` (`created_at`); \ No newline at end of file diff --git a/drizzle/meta/0021_snapshot.json b/drizzle/meta/0021_snapshot.json new file mode 100644 index 00000000..4794c18a --- /dev/null +++ b/drizzle/meta/0021_snapshot.json @@ -0,0 +1,2608 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "88f4ee3d-2361-4dae-a161-4a81393134ac", + "prevId": "f8f91aa8-3515-4b2e-b549-c3c8a9b7c841", + "tables": { + "admin_account_operations": { + "name": "admin_account_operations", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "admin_account_operations_action": { + "name": "admin_account_operations_action", + "value": "\"admin_account_operations\".\"action\" IN ('provision','role','disable')" + } + } + }, + "admin_account_retired_keys": { + "name": "admin_account_retired_keys", + "columns": { + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "admin_id": { + "name": "admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_account_rotations": { + "name": "admin_account_rotations", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_admin_id": { + "name": "target_admin_id", + "type": "text", + "primaryKey": false, + "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 + }, + "previous_enabled": { + "name": "previous_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "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 + }, + "expected_updated_at": { + "name": "expected_updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_account_rotations_target": { + "name": "admin_account_rotations_target", + "columns": [ + "target_admin_id", + "rotated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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_attempts": { + "name": "admin_attempts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "admin_attempts_created": { + "name": "admin_attempts_created", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "admin_attempts_kind": { + "name": "admin_attempts_kind", + "value": "\"admin_attempts\".\"kind\" IN ('login','session','role')" + }, + "admin_attempts_reason": { + "name": "admin_attempts_reason", + "value": "\"admin_attempts\".\"reason\" IN ('invalid_key_format','unknown_or_disabled_key','missing_session','unknown_or_expired_session','role_denied')" + }, + "admin_attempts_role": { + "name": "admin_attempts_role", + "value": "\"admin_attempts\".\"role\" IS NULL OR \"admin_attempts\".\"role\" IN ('owner','operator','viewer')" + } + } + }, + "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": "'{}'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "admin_audit_created_at": { + "name": "admin_audit_created_at", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_discussion_operations": { + "name": "admin_discussion_operations", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_version": { + "name": "expected_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_revision": { + "name": "expected_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_revision": { + "name": "applied_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "muted": { + "name": "muted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_discussion_room": { + "name": "admin_discussion_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_lobby_operations": { + "name": "admin_lobby_operations", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_version": { + "name": "expected_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_version": { + "name": "applied_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_configuration": { + "name": "before_configuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after_configuration": { + "name": "after_configuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_lobby_operations_room": { + "name": "admin_lobby_operations_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_room_archives": { + "name": "admin_room_archives", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_version": { + "name": "expected_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_revision": { + "name": "expected_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_version": { + "name": "applied_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_revision": { + "name": "applied_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_metadata": { + "name": "before_metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after_metadata": { + "name": "after_metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_room_archives_room": { + "name": "admin_room_archives_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_room_audit": { + "name": "admin_room_audit", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'room_control'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_revision": { + "name": "expected_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_paused": { + "name": "before_paused", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_join_locked": { + "name": "before_join_locked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after_paused": { + "name": "after_paused", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after_join_locked": { + "name": "after_join_locked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_room_audit_room": { + "name": "admin_room_audit_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_room_backup_downloads": { + "name": "admin_room_backup_downloads", + "columns": { + "download_id": { + "name": "download_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_room_backup_downloads_room": { + "name": "admin_room_backup_downloads_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_room_backups": { + "name": "admin_room_backups", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_version": { + "name": "room_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "digest": { + "name": "digest", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_room_backups_room": { + "name": "admin_room_backups_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "admin_room_backups_size": { + "name": "admin_room_backups_size", + "value": "\"admin_room_backups\".\"size_bytes\" BETWEEN 1 AND 1800000 AND \"admin_room_backups\".\"size_bytes\" = length(CAST(\"admin_room_backups\".\"payload\" AS BLOB))" + } + } + }, + "admin_room_closures": { + "name": "admin_room_closures", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_version": { + "name": "expected_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_revision": { + "name": "expected_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_version": { + "name": "applied_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_revision": { + "name": "applied_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closed": { + "name": "closed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_metadata": { + "name": "before_metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after_metadata": { + "name": "after_metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_room_closures_room": { + "name": "admin_room_closures_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_room_creations": { + "name": "admin_room_creations", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "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 + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_room_creations_session_hash_unique": { + "name": "admin_room_creations_session_hash_unique", + "columns": [ + "session_hash" + ], + "isUnique": true + }, + "admin_room_creations_room": { + "name": "admin_room_creations_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_room_removals": { + "name": "admin_room_removals", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_version": { + "name": "expected_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_revision": { + "name": "expected_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_version": { + "name": "applied_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_revision": { + "name": "applied_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "removed": { + "name": "removed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_metadata": { + "name": "before_metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after_metadata": { + "name": "after_metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_room_removals_room": { + "name": "admin_room_removals_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_seat_ai_operations": { + "name": "admin_seat_ai_operations", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_version": { + "name": "expected_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_control_revision": { + "name": "expected_control_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_version": { + "name": "applied_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "difficulty": { + "name": "difficulty", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_seat_ai_room": { + "name": "admin_seat_ai_room", + "columns": [ + "room_code", + "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_archives": { + "name": "room_archives", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "room_archives_room_code_rooms_code_fk": { + "name": "room_archives_room_code_rooms_code_fk", + "tableFrom": "room_archives", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "room_archives_archived": { + "name": "room_archives_archived", + "value": "\"room_archives\".\"archived\" IN (0, 1)" + }, + "room_archives_revision": { + "name": "room_archives_revision", + "value": "\"room_archives\".\"revision\" >= 0" + }, + "room_archives_time": { + "name": "room_archives_time", + "value": "(\"room_archives\".\"archived\" = 1 AND \"room_archives\".\"archived_at\" IS NOT NULL) OR (\"room_archives\".\"archived\" = 0 AND \"room_archives\".\"archived_at\" IS NULL)" + } + } + }, + "room_closures": { + "name": "room_closures", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "closed": { + "name": "closed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "room_closures_room_code_rooms_code_fk": { + "name": "room_closures_room_code_rooms_code_fk", + "tableFrom": "room_closures", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "room_closures_closed": { + "name": "room_closures_closed", + "value": "\"room_closures\".\"closed\" IN (0, 1)" + }, + "room_closures_revision": { + "name": "room_closures_revision", + "value": "\"room_closures\".\"revision\" >= 0" + }, + "room_closures_time": { + "name": "room_closures_time", + "value": "(\"room_closures\".\"closed\" = 1 AND \"room_closures\".\"closed_at\" IS NOT NULL) OR (\"room_closures\".\"closed\" = 0 AND \"room_closures\".\"closed_at\" IS NULL)" + } + } + }, + "room_controls": { + "name": "room_controls", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "paused": { + "name": "paused", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "join_locked": { + "name": "join_locked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "room_controls_room_code_rooms_code_fk": { + "name": "room_controls_room_code_rooms_code_fk", + "tableFrom": "room_controls", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "room_controls_paused": { + "name": "room_controls_paused", + "value": "\"room_controls\".\"paused\" IN (0, 1)" + }, + "room_controls_join_locked": { + "name": "room_controls_join_locked", + "value": "\"room_controls\".\"join_locked\" IN (0, 1)" + }, + "room_controls_revision": { + "name": "room_controls_revision", + "value": "\"room_controls\".\"revision\" >= 0" + } + } + }, + "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": {} + }, + "room_removals": { + "name": "room_removals", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "removed": { + "name": "removed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "removed_at": { + "name": "removed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "room_removals_room_code_rooms_code_fk": { + "name": "room_removals_room_code_rooms_code_fk", + "tableFrom": "room_removals", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "room_removals_removed": { + "name": "room_removals_removed", + "value": "\"room_removals\".\"removed\" IN (0, 1)" + }, + "room_removals_revision": { + "name": "room_removals_revision", + "value": "\"room_removals\".\"revision\" >= 0" + }, + "room_removals_time": { + "name": "room_removals_time", + "value": "(\"room_removals\".\"removed\" = 1 AND \"room_removals\".\"removed_at\" IS NOT NULL) OR (\"room_removals\".\"removed\" = 0 AND \"room_removals\".\"removed_at\" IS NULL)" + } + } + }, + "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_discussion_controls": { + "name": "seat_discussion_controls", + "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 + }, + "muted": { + "name": "muted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "seat_discussion_controls_room_code_rooms_code_fk": { + "name": "seat_discussion_controls_room_code_rooms_code_fk", + "tableFrom": "seat_discussion_controls", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "seat_discussion_controls_room_code_player_id_pk": { + "columns": [ + "room_code", + "player_id" + ], + "name": "seat_discussion_controls_room_code_player_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": { + "seat_discussion_muted": { + "name": "seat_discussion_muted", + "value": "\"seat_discussion_controls\".\"muted\" IN (0, 1)" + }, + "seat_discussion_revision": { + "name": "seat_discussion_revision", + "value": "\"seat_discussion_controls\".\"revision\" >= 0" + } + } + }, + "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 89140534..8fcfbcb1 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1790682568002, "tag": "0020_admin_key_rotation", "breakpoints": true + }, + { + "idx": 21, + "version": "6", + "when": 1791359663204, + "tag": "0021_wealthy_thunderbird", + "breakpoints": true } ] } \ No newline at end of file diff --git a/tests/admin-attempts.test.ts b/tests/admin-attempts.test.ts new file mode 100644 index 00000000..43f109e1 --- /dev/null +++ b/tests/admin-attempts.test.ts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { adminLogin, requireAdmin, AdminError } from '../db/admin-access'; +import { ADMIN_ATTEMPT_LIMIT, readAdminAttempts } from '../db/admin-attempts'; +import { adminStore } from './admin-access-fixture'; + +const now = 10_000; +const owner = async () => { + const store = adminStore(); + const account = store.provision('owner'); + const login = await adminLogin(store.database, account.key, now); + const identity = await requireAdmin(store.database, login.token, ['owner'], now); + return { store, account, login, identity }; +}; +const reasons = (page: Awaited>) => + page.recent.map((row) => row.reason); +const rejects = async (promise: Promise, status: number) => + await assert.rejects( + promise, + (error: unknown) => error instanceof AdminError && error.status === status, + ); + +void test('rejected administrator requests are recorded by reason without storing any credential', async () => { + const { store, account, login, identity } = await owner(); + try { + await rejects(adminLogin(store.database, 42, now), 401); + await rejects(adminLogin(store.database, 'not-a-key', now), 401); + await rejects( + adminLogin(store.database, `dune-admin.${account.id}.${'a'.repeat(64)}`, now), + 401, + ); + await rejects(requireAdmin(store.database, 'short', ['owner'], now), 401); + await rejects(requireAdmin(store.database, 'b'.repeat(64), ['owner'], now), 401); + const spare = store.provision('owner', 'Disabled owner'); + store.sqlite.prepare('UPDATE admin_accounts SET enabled=0 WHERE id=?').run(spare.id); + await rejects(adminLogin(store.database, spare.key, now), 401); + const viewer = store.provision('viewer'); + const viewerLogin = await adminLogin(store.database, viewer.key, now); + await rejects(requireAdmin(store.database, viewerLogin.token, ['owner'], now), 403); + const page = await readAdminAttempts(store.database, identity, now); + assert.equal(page.observedAt, now); + assert.equal(page.windowMs, 7 * 24 * 60 * 60 * 1000); + assert.equal(page.total, 7); + assert.deepEqual(page.byReason, [ + { reason: 'invalid_key_format', count: 2 }, + { reason: 'missing_session', count: 1 }, + { reason: 'role_denied', count: 1 }, + { reason: 'unknown_or_disabled_key', count: 2 }, + { reason: 'unknown_or_expired_session', count: 1 }, + ]); + assert.deepEqual(reasons(page), [ + 'role_denied', + 'unknown_or_disabled_key', + 'unknown_or_expired_session', + 'missing_session', + 'unknown_or_disabled_key', + 'invalid_key_format', + 'invalid_key_format', + ]); + const denial = page.recent[0]; + assert.equal(denial.kind, 'role'); + assert.equal(denial.role, 'viewer'); + assert.equal(denial.accountId, viewer.id); + assert.equal(denial.createdAt, now); + const serialized = JSON.stringify(page); + for (const secret of [account.key, viewer.key, login.token, viewerLogin.token, 'dune-admin']) + assert.equal(serialized.includes(secret), false, 'attempts never store a credential'); + // A successful login and privileged read record nothing further. + await adminLogin(store.database, viewer.key, now); + await requireAdmin(store.database, viewerLogin.token, undefined, now); + assert.equal((await readAdminAttempts(store.database, identity, now)).total, 7); + } finally { store.sqlite.close(); } +}); + +void test('attempt reading stays owner-only, bounded and best-effort when recording fails', async () => { + const { store, identity } = await owner(); + try { + for (let index = 0; index < ADMIN_ATTEMPT_LIMIT + 5; index++) + await rejects(adminLogin(store.database, `bad-${index}`, now), 401); + const page = await readAdminAttempts(store.database, identity, now); + assert.equal(page.total, ADMIN_ATTEMPT_LIMIT + 5); + assert.equal(page.recent.length, ADMIN_ATTEMPT_LIMIT); + assert.equal(page.recent[0].createdAt, now); + // Only the reporting window's counters are returned. + store.sqlite + .prepare('UPDATE admin_attempts SET created_at=?') + .run(now - 8 * 24 * 60 * 60 * 1000); + assert.equal((await readAdminAttempts(store.database, identity, now)).total, 0); + store.provision('owner', 'Remaining owner'); + store.sqlite.prepare("UPDATE admin_accounts SET role='operator' WHERE id=?").run(identity.id); + await rejects(readAdminAttempts(store.database, identity, now), 403); + } finally { store.sqlite.close(); } +}); + +void test('a missing attempts table never changes the rejected request answer', async () => { + const { store } = await owner(); + try { + store.sqlite.exec('DROP TABLE admin_attempts'); + await rejects(adminLogin(store.database, 'not-a-key', now), 401); + await rejects(requireAdmin(store.database, 'short', undefined, now), 401); + } finally { store.sqlite.close(); } +}); diff --git a/tests/admin-boundary-http.test.ts b/tests/admin-boundary-http.test.ts index 198c2bb5..d51d246b 100644 --- a/tests/admin-boundary-http.test.ts +++ b/tests/admin-boundary-http.test.ts @@ -13,7 +13,7 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden assert.ok(cookie); const room = (await created.json() as { code: string }).code; 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/accounts', '/api/admin/audit', '/api/admin/operations?integrity=1', '/api/admin/operations?stalled=1', '/api/admin/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`, `/api/admin/rooms/${room}/removal`, `/api/admin/rooms/${room}/closure`, `/api/admin/rooms/${room}/archive`, `/api/admin/rooms/${room}/seat-ai`, `/api/admin/rooms/${room}/discussion`]) { + for (const path of ['/api/admin/session', '/api/admin/accounts', '/api/admin/audit', '/api/admin/operations?integrity=1', '/api/admin/operations?stalled=1', '/api/admin/operations?attempts=1', '/api/admin/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`, `/api/admin/rooms/${room}/removal`, `/api/admin/rooms/${room}/closure`, `/api/admin/rooms/${room}/archive`, `/api/admin/rooms/${room}/seat-ai`, `/api/admin/rooms/${room}/discussion`]) { 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');