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.
This commit is contained in:
Quintine committed 2026-10-07 19:11:45 +11:00
1 parent 3bebc68cd5
commit 8bea4c1a0c
15 files changed
+3090 -21

No files matched your search

+64
View File
@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ClientRequestError, requestJson } from '@/lib/client-request'; 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 type { AdminIntegrity, AdminOperations, AdminStalledDecision } from '@/db/admin-operations';
import '../admin.css'; import '../admin.css';
import './operations.css'; import './operations.css';
@@ -10,6 +11,20 @@ import './operations.css';
type Account = { id: string; role: 'owner' | 'operator' | 'viewer' }; type Account = { id: string; role: 'owner' | 'operator' | 'viewer' };
type Snapshot = AdminOperations & { revision: string }; type Snapshot = AdminOperations & { revision: string };
type Stalled = { observedAt: number; stallMs: number; stalled: AdminStalledDecision[] }; 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<AdminAttemptReason, string> = {
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 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`; 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 [stalledNotice, setStalledNotice] = useState('');
const [loadingStalled, setLoadingStalled] = useState(false); const [loadingStalled, setLoadingStalled] = useState(false);
const stalledEpoch = useRef(0); const stalledEpoch = useRef(0);
const [attempts, setAttempts] = useState<Attempts | null>(null);
const [attemptsNotice, setAttemptsNotice] = useState('');
const [loadingAttempts, setLoadingAttempts] = useState(false);
const attemptsEpoch = useRef(0);
useEffect(() => { useEffect(() => {
let canceled = false; let canceled = false;
const epoch = integrityEpoch; 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<Attempts>('/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 <main className="admin-shell"> return <main className="admin-shell">
<header className="admin-header"><div><a className="admin-return" href="/admin">Administration</a><h1>Operations</h1></div> <header className="admin-header"><div><a className="admin-return" href="/admin">Administration</a><h1>Operations</h1></div>
<Button variant="outline" disabled={loading} onClick={() => { <Button variant="outline" disabled={loading} onClick={() => {
integrityEpoch.current++; integrityEpoch.current++;
stalledEpoch.current++; stalledEpoch.current++;
attemptsEpoch.current++;
setAttempts(null); setAttemptsNotice(''); setLoadingAttempts(false);
setIntegrity(null); setIntegrityNotice(''); setChecking(false); setIntegrity(null); setIntegrityNotice(''); setChecking(false);
setStalled(null); setStalledNotice(''); setLoadingStalled(false); setStalled(null); setStalledNotice(''); setLoadingStalled(false);
setSnapshot(null); setLoading(true); setRefresh(value => value + 1); setSnapshot(null); setLoading(true); setRefresh(value => value + 1);
@@ -139,6 +181,28 @@ export default function OperationsPage() {
</table>} </table>}
<p>This sample changes nothing. Pause, resume, close or remove a room only through its own controls and audit.</p> <p>This sample changes nothing. Pause, resume, close or remove a room only through its own controls and audit.</p>
</section>} </section>}
<Button variant="outline" disabled={loadingAttempts} onClick={() => void checkAttempts()}>List failed attempts</Button>
<p>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.</p>
{loadingAttempts && <output>Sampling rejected requests…</output>}
{attemptsNotice && <p role="alert" className="admin-notice">{attemptsNotice}</p>}
{attempts && <section aria-label="Failed administrator attempts">
<p>Sampled {new Date(attempts.observedAt).toLocaleString()}; counters cover the last {Math.round(attempts.windowMs / 86_400_000)} days ({attempts.total} rejected requests).</p>
{attempts.byReason.length === 0 ? <p>No rejected administrator request was recorded in the window.</p>
: <ul>{attempts.byReason.map(row => <li key={row.reason}>{ATTEMPT_LABELS[row.reason]}: <strong>{row.count}</strong></li>)}</ul>}
{attempts.recent.length === 0 ? null
: <table className="admin-stalled-table">
<caption>Most recent rejected requests, newest first</caption>
<thead><tr><th scope="col">When</th><th scope="col">Kind</th><th scope="col">Reason</th><th scope="col">Role</th><th scope="col">Account</th></tr></thead>
<tbody>{attempts.recent.map((row, index) => <tr key={`${row.createdAt}-${index}`}>
<td>{new Date(row.createdAt).toLocaleString()}</td>
<td>{row.kind}</td>
<td>{ATTEMPT_LABELS[row.reason]}</td>
<td>{row.role ?? '—'}</td>
<td>{row.accountId ? `${row.accountId.slice(0, 8)}…` : '—'}</td>
</tr>)}</tbody>
</table>}
<p>This sample changes nothing and identifies no credential. Use it to notice repeated failures, not to lock out a seat or account automatically.</p>
</section>}
<Button variant="outline" disabled={checking} onClick={() => void checkIntegrity()}>Run integrity check</Button> <Button variant="outline" disabled={checking} onClick={() => void checkIntegrity()}>Run integrity check</Button>
<p>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.</p> <p>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.</p>
{checking && <output>Checking database structure and references…</output>} {checking && <output>Checking database structure and references…</output>}
+4 -1
View File
@@ -1,5 +1,6 @@
import { env } from 'cloudflare:workers'; import { env } from 'cloudflare:workers';
import { AdminError, requireAdmin } from '@/db/admin-access'; 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 { ADMIN_STALL_MS, readAdminIntegrity, readAdminOperations, readAdminStalledDecisions } from '@/db/admin-operations';
import { adminFailure, adminResponse, adminToken } from '@/lib/admin-http'; import { adminFailure, adminResponse, adminToken } from '@/lib/admin-http';
import { buildRevision } from '@/lib/build-revision'; import { buildRevision } from '@/lib/build-revision';
@@ -11,7 +12,7 @@ export async function GET(request: Request) {
if (expected && expected !== identity.id) if (expected && expected !== identity.id)
throw new AdminError('The signed-in administrator changed. Reload operations.', 401); throw new AdminError('The signed-in administrator changed. Reload operations.', 401);
const query = new URL(request.url).searchParams; 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'))) if (query.size && (query.size !== 1 || !reads.some(read => query.get(read) === '1')))
throw new AdminError('Invalid operations request.', 400); throw new AdminError('Invalid operations request.', 400);
if (query.get('integrity') === '1') if (query.get('integrity') === '1')
@@ -19,6 +20,8 @@ export async function GET(request: Request) {
if (query.get('stalled') === '1') if (query.get('stalled') === '1')
return adminResponse({ revision: buildRevision, observedAt: Date.now(), return adminResponse({ revision: buildRevision, observedAt: Date.now(),
stallMs: ADMIN_STALL_MS, stalled: await readAdminStalledDecisions(env.DB, identity) }); 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) }); return adminResponse({ revision: buildRevision, ...await readAdminOperations(env.DB, identity) });
} catch (error) { return adminFailure(error); } } catch (error) { return adminFailure(error); }
} }
+44 -16
View File
@@ -1,3 +1,6 @@
import { AdminError } from './admin-error';
import { recordAdminAttempt } from './admin-attempts';
export type AdminRole = 'owner' | 'operator' | 'viewer'; export type AdminRole = 'owner' | 'operator' | 'viewer';
export type AdminAccount = { id: string; name: string; role: AdminRole }; export type AdminAccount = { id: string; name: string; role: AdminRole };
export type AdminIdentity = AdminAccount & { sessionHash: string }; 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 KEY_PATTERN = new RegExp(`^dune-admin\\.(${UUID})\\.[0-9a-f]{64}$`);
const TOKEN_PATTERN = /^[0-9a-f]{64}$/; const TOKEN_PATTERN = /^[0-9a-f]{64}$/;
export class AdminError extends Error { export { AdminError };
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = 'AdminError';
}
}
function isRole(value: string): value is AdminRole { function isRole(value: string): value is AdminRole {
return value === 'owner' || value === 'operator' || value === 'viewer'; return value === 'owner' || value === 'operator' || value === 'viewer';
@@ -54,9 +49,15 @@ export async function adminLogin(
}> { }> {
const invalid = () => const invalid = () =>
new AdminError('Administrator access key is invalid or revoked.', 401); 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); 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 keyHash = await hash(key);
const account = await database const account = await database
.prepare( .prepare(
@@ -64,7 +65,12 @@ export async function adminLogin(
) )
.bind(match[1], keyHash) .bind(match[1], keyHash)
.first<AdminAccount & { session_generation: number }>(); .first<AdminAccount & { session_generation: number }>();
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 token = sessionToken();
const tokenHash = await hash(token); const tokenHash = await hash(token);
@@ -95,7 +101,14 @@ export async function adminLogin(
) )
.bind(crypto.randomUUID(), now, JSON.stringify({ sessionId }), tokenHash), .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. // Return current role/name, and fail if revocation committed after creation.
const { id, name, role } = await requireAdmin( const { id, name, role } = await requireAdmin(
database, database,
@@ -117,8 +130,10 @@ export async function requireAdmin(
typeof token !== 'string' || typeof token !== 'string' ||
token.length !== 64 || token.length !== 64 ||
!TOKEN_PATTERN.test(token) !TOKEN_PATTERN.test(token)
) ) {
await recordAdminAttempt(database, { kind: 'session', reason: 'missing_session' }, now);
throw signInRequired(); throw signInRequired();
}
const sessionHash = await hash(token); const sessionHash = await hash(token);
const account = await database const account = await database
.prepare( .prepare(
@@ -129,12 +144,25 @@ export async function requireAdmin(
) )
.bind(sessionHash, now) .bind(sessionHash, now)
.first<AdminAccount>(); .first<AdminAccount>();
if (!account || !isRole(account.role)) throw signInRequired(); if (!account || !isRole(account.role)) {
if (roles && !roles.includes(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( throw new AdminError(
'Your administrator role does not permit this action.', 'Your administrator role does not permit this action.',
403, 403,
); );
}
return { return {
id: account.id, id: account.id,
name: account.name, name: account.name,
+111
View File
@@ -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<void> {
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<AdminAttempts> {
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,
})),
};
}
+10
View File
@@ -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';
}
}
+26
View File
@@ -557,3 +557,29 @@ export const adminRoomBackupDownloads = sqliteTable(
}, },
(table) => [index('admin_room_backup_downloads_room').on(table.roomCode, table.createdAt)], (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')`,
),
],
);
+51
View File
@@ -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.
+10 -2
View File
@@ -9,13 +9,13 @@ recovery and persistence systems where suitable.
| Area | Required behavior | Stage | | 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 | | 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 | | 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 | | 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 | | 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 | | 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. Track each area through Missing, Prototyped, Integrated, Verified and Polished.
As implementation begins, link its controls, server actions, persistence, 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, private diagnosis, not automatic repair. It does not prove disk capacity,
backup recoverability or valid game rules. 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 [Stalled-decision diagnosis](ADMIN_STALLED_DECISIONS.md) adds **List stalled
decisions**: a bounded, read-only sample of at most ten live rooms whose public decisions**: a bounded, read-only sample of at most ten live rooms whose public
pending interaction — a decision, response, Truthtrance or phase opening — has pending interaction — a decision, response, Truthtrance or phase opening — has
+10 -1
View File
@@ -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); [TLEILAXU_AMBASSADOR_RULES.md](TLEILAXU_AMBASSADOR_RULES.md#implementation-decision-boundary);
no ordinary revival allowance, price, phase or income path changed. 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 **In progress — administrator operations.** The owner-only operations page now
also diagnoses [stalled decisions](ADMIN_STALLED_DECISIONS.md): a bounded, also diagnoses [stalled decisions](ADMIN_STALLED_DECISIONS.md): a bounded,
read-only sample of at most ten live rooms whose public pending interaction — a 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 Paused, closed, removed and archived rooms are excluded and no private game
content is selected; `GET /api/admin/operations?stalled=1` stays owner-only, 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 `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.** **Mixed E1/E2 and paired E3 Nexus — bounded Development Verified.**
+29
View File
@@ -1,5 +1,34 @@
# Dune implementation status # 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 ## 7 October 2026 — Sneak Attack composes the Bene Gesserit arrival reactions
A positive Moritani Sneak Attack now resolves its committed Terror entry first A positive Moritani Sneak Attack now resolves its committed Terror entry first
+13
View File
@@ -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`);
File diff suppressed because it is too large. Load diff
+7
View File
@@ -148,6 +148,13 @@
"when": 1790682568002, "when": 1790682568002,
"tag": "0020_admin_key_rotation", "tag": "0020_admin_key_rotation",
"breakpoints": true "breakpoints": true
},
{
"idx": 21,
"version": "6",
"when": 1791359663204,
"tag": "0021_wealthy_thunderbird",
"breakpoints": true
} }
] ]
} }
+102
View File
@@ -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<ReturnType<typeof readAdminAttempts>>) =>
page.recent.map((row) => row.reason);
const rejects = async (promise: Promise<unknown>, 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(); }
});
+1 -1
View File
@@ -13,7 +13,7 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden
assert.ok(cookie); assert.ok(cookie);
const room = (await created.json() as { code: string }).code; 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<string, string>[]) { 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<string, string>[]) {
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) }); const result = await fetch(base + path, { headers, signal: AbortSignal.timeout(15000) });
assert.equal(result.status, 401, path); assert.equal(result.status, 401, path);
assert.equal(result.headers.get('cache-control'), 'no-store'); assert.equal(result.headers.get('cache-control'), 'no-store');