phase-6: the staff console, and a log that says who did what

Flow H, three screens behind a role check: find an account, work the
ingestion error queue, read and export the audit log. Superadmins can
change a role, never their own.

The error queue merges ingest errors and dead jobs into one table with a
cursor that pages both sources; only a job can be retried and only an
ingest row resolved, with a note that migration 003 gives it somewhere
to live.

writeAudit no longer defaults a missing subject to the actor, which had
been recording a user search as staff looking themselves up. Omitting
the subject still means acting on yourself; null now means the action
has no subject, which is what a search, a retry and an export are.

Reading the log is not audited. Exporting it is: a copy leaving the
building is a different act from looking.

e2e/global-setup.ts asks for every screen once before the suite starts,
so a dev server's first-request compile is paid before the first test
rather than by it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-09-04 21:55:59 +00:00
co-authored by Claude Opus 5
parent 6620650e9e
commit 4c39926483
39 changed files with 2767 additions and 11 deletions
+85
View File
@@ -536,3 +536,88 @@ tab, since every one of them is a place the user needs to reach.
- **Payment reminders after filing.** FLOWS.md D3 mentions scheduling one on "Ya lo
presente". The deadline sweep already covers T-2 and T-0 for the period; a separate
payment date is not in RULES.md and was not invented.
## Phase 6
### The console is a separate route group with its own shell
FLOWS.md Flow H asks for plain and dense, desktop first. `(admin)` gets a wide layout, a
text nav and no tab bar, no floating scan button and no playfulness. The audit reminder
sits above every screen in the group rather than only the user search: staff reading the
error queue are reading user data too.
### The role is checked in the layout and again in every handler
The layout calls `GET /me/session` and renders "solo para el equipo" for anyone else, which
is a convenience. Every admin handler calls `requireRole` itself, which is the rule. A
plain user who guesses the URL gets a plain page from the layout and a 403 from the API.
### SPEC-GAP: GET /me/session
CONTRACTS.md section 3 has no way to ask who you are signed in as, and a console that must
know a viewer's role before it renders needs one. It returns id, email and role, and
nothing else.
### A search has no subject, and the log now says so
`writeAudit` used to default a missing `subjectUserId` to the actor, which made a user
search read as staff looking themselves up. `undefined` still means "acting on yourself";
`null` now means "no subject", which is what a search, a retry, an error resolution and an
export are. The distinction is a fact about what happened, so the log keeps it.
### Reading the audit log is not audited, exporting it is
A row for every scroll of the audit screen would bury the accesses that matter under the
act of looking for them. Taking a copy out of the building is a different act, so
`GET /admin/audit/export.csv` writes `admin.audit_export` with the filters that produced it.
### The error queue merges two sources into one table
Ingest errors and dead jobs are one queue with a `source` on each row, filtered and paged
together by a `createdAt|id` cursor applied to both sides before the merge. Only a job row
can be retried and only an ingest row can be resolved, and a dead job never appears under
the resolved filter: retrying it takes it out of the queue instead. A manual retry resets
`attempts` to zero, so the retry gets the whole backoff schedule rather than dying on its
first stumble.
### SPEC-GAP: ingest_errors.resolution_note
Flow H resolves an error "with note" and SPEC.md section 5 gives the row nowhere to put
one. Migration 003 adds a column, because the note is what the next person reads, not
another key inside the payload the failure wrote.
### Role changes are superadmin only and never on yourself
The one thing worse than an account with too much power is the last superadmin demoting
themselves out of the console. Setting the role a user already has is a 409 rather than a
silent success. No session shuffling is needed: `getSession` reads the role off the user
row on every request, so a demotion takes effect on the demoted user's next call.
### The audit table shows the action code, not a translated phrase
It is the same token the filter takes and the same one in the CSV. An operator matching a
log wants to see what they can search for, and fourteen action names in two languages would
be copy that has to stay in step with an enum.
### Timestamps in the console are ISO, not prose
`2026-09-04 20:42` rather than "4 de septiembre". A console sorts, compares and copies
timestamps; the year is part of the fact and the format has to be unambiguous. Everywhere
the user sees a date, it is still formatted for the reader.
### CONTRACTS.md 5.5 is pinned against the API, not the browser
"Exactly one `admin.user_lookup` row per overview" is asserted in `admin.test.ts`, where it
is exact. It cannot be asserted through Playwright: React strict mode mounts a client
component twice in development, so the browser asks for the overview twice and writes two
rows. The e2e asserts the flow writes a lookup that then shows up on the audit screen.
### Seed: one dead job
CONTRACTS.md section 4 asks for two open ingest errors, which `seedDocuments` has written
since phase 3. The dead job is an addition: the queue merges two sources and the retry
action has nothing to act on without one. It also cost a test its assumption that the jobs
table starts empty, which was an assumption worth removing anyway.
### Two defects the screenshots caught
**The user summary read as the wrong pairs.** Label and value side by side across a wide
card put each value next to the following pair's label, so "4123456-1" and "Rol" read as
one field. Label above value fixed it.
**A search recorded itself against the searcher.** See above: visible only once real rows
were on screen next to each other.
### Deferred, deliberately
- **Impersonation and account freezing.** Neither is in SPEC.md or FLOWS.md. better-auth's
admin plugin ships both; leaving them off is the smaller surface.
- **Filtering the error queue by account.** The overview lists a user's own open errors and
links to the queue. CONTRACTS.md gives `/admin/errors` a stage and status filter and no
user filter, and one was not invented.