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

+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 |
| --- | --- | --- |
| 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
+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);
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.**
+29
View File
@@ -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