From 8bea4c1a0c927ae6bb6ff04cd54890a7ebeac4d6 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:11:45 +1100 Subject: [PATCH] Record administrator failed attempts without storing credentials 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, and 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 the owner-only recheck after demotion, and that a dropped attempts table leaves the original rejection answer unchanged. The anonymous HTTP boundary denies ?attempts=1, and the panel was verified in the real browser against a genuine recorded rejection. The shared AdminError moved to db/admin-error.ts so the recorder and the access module no longer form a value-import cycle. Offline suite 7,801/7,801 with typecheck and lint clean. No game, seat, saved state or deployment changes. --- app/admin/operations/page.tsx | 64 + app/api/admin/operations/route.ts | 5 +- db/admin-access.ts | 60 +- db/admin-attempts.ts | 111 ++ db/admin-error.ts | 10 + db/schema.ts | 26 + docs/ADMIN_ATTEMPTS.md | 51 + docs/ADMIN_PANEL.md | 12 +- docs/CURRENT_STATUS.md | 11 +- docs/IMPLEMENTATION_STATUS.md | 29 + drizzle/0021_wealthy_thunderbird.sql | 13 + drizzle/meta/0021_snapshot.json | 2608 ++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + tests/admin-attempts.test.ts | 102 + tests/admin-boundary-http.test.ts | 2 +- 15 files changed, 3090 insertions(+), 21 deletions(-) create mode 100644 db/admin-attempts.ts create mode 100644 db/admin-error.ts create mode 100644 docs/ADMIN_ATTEMPTS.md create mode 100644 drizzle/0021_wealthy_thunderbird.sql create mode 100644 drizzle/meta/0021_snapshot.json create mode 100644 tests/admin-attempts.test.ts 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');