phase: 101_tokens_page_overhaul
Build and Push Containers / build-and-push-app (push) Successful in 1m49s
Build and Push Containers / build-and-push-db (push) Successful in 13s

All gates verified green — no defects found, nothing to fix. Final report:

## Phase 101 — final verification pass (all tasks already complete; verified, not re-implemented)

**Verified against the working tree** (task files 01–04 present in `complete/`):
- Active/revoked table split (Status column gone, section hidden while 0 revoked, per-table search with query state surviving re-renders) in `frontend/index.html` + `tokens.js`
- `generateToken()` refuses blank/whitespace names (`Give the token a name first.`, no request); `|| "token"` fallback deleted
- Atomic rotation: `regenerate_token` service + `POST /api/tokens/{id}/regenerate` (201 new-plaintext-once / 404 / 409 / router-wide 403) + `TokenListItem.revoked_at` (D5)
- Regenerate two-step confirm UI + CSS (`.token-regenerate`, neutral hover, no new hue); A4 pins intact

**Test / lint / coverage results:**
- `uv run pytest` → **2065 passed**
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90% ✓)
- `uv run ruff check . && uv run pyright` → clean (0 errors)
- `uv run pytest tests/e2e/test_tokens_page.py -v --no-cov` → **4 passed** (isolation, DB up)
- Regression, each in isolation: `test_api_tokens.py` **9 passed**, `test_admin_auth.py` **6 passed**, `test_shared_header.py` **6 passed**, `test_theme_semantic_completion.py` **8 passed** (its revoked-pill pin was correctly re-scoped to the revoked table in this phase)

**Completion criteria:** 1 ✓ split+search (E2E 1–2) · 2 ✓ required name (E2E 3 + source pin) · 3 ✓ rotation end-to-end, old token refused at gate (E2E 4 + API 404/409 pinned) · 4 ✓ A4 holds (list carries no plaintext/hashes) · 5 ✓ suite/coverage/lint green · 6 ✓ E2E + regressions green in isolation · 7 commit left to the harness per executor rules (all changes uncommitted in the working tree)

**Deviations:** none. Next pending phase: `98_sync_summary_visibility`.
This commit is contained in:
2026-09-12 15:16:02 -04:00
parent 58e9d94cff
commit 4dbac1660a
32 changed files with 2321 additions and 150 deletions
@@ -0,0 +1,20 @@
All gates verified green — no defects found, nothing to fix. Final report:
## Phase 101 — final verification pass (all tasks already complete; verified, not re-implemented)
**Verified against the working tree** (task files 01–04 present in `complete/`):
- Active/revoked table split (Status column gone, section hidden while 0 revoked, per-table search with query state surviving re-renders) in `frontend/index.html` + `tokens.js`
- `generateToken()` refuses blank/whitespace names (`Give the token a name first.`, no request); `|| "token"` fallback deleted
- Atomic rotation: `regenerate_token` service + `POST /api/tokens/{id}/regenerate` (201 new-plaintext-once / 404 / 409 / router-wide 403) + `TokenListItem.revoked_at` (D5)
- Regenerate two-step confirm UI + CSS (`.token-regenerate`, neutral hover, no new hue); A4 pins intact
**Test / lint / coverage results:**
- `uv run pytest` → **2065 passed**
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90% ✓)
- `uv run ruff check . && uv run pyright` → clean (0 errors)
- `uv run pytest tests/e2e/test_tokens_page.py -v --no-cov` → **4 passed** (isolation, DB up)
- Regression, each in isolation: `test_api_tokens.py` **9 passed**, `test_admin_auth.py` **6 passed**, `test_shared_header.py` **6 passed**, `test_theme_semantic_completion.py` **8 passed** (its revoked-pill pin was correctly re-scoped to the revoked table in this phase)
**Completion criteria:** 1 ✓ split+search (E2E 1–2) · 2 ✓ required name (E2E 3 + source pin) · 3 ✓ rotation end-to-end, old token refused at gate (E2E 4 + API 404/409 pinned) · 4 ✓ A4 holds (list carries no plaintext/hashes) · 5 ✓ suite/coverage/lint green · 6 ✓ E2E + regressions green in isolation · 7 commit left to the harness per executor rules (all changes uncommitted in the working tree)
**Deviations:** none. Next pending phase: `98_sync_summary_visibility`.
@@ -0,0 +1,96 @@
........................................................................ [ 3%]
........................................................................ [ 6%]
........................................................................ [ 10%]
........................................................................ [ 13%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 24%]
........................................................................ [ 27%]
........................................................................ [ 31%]
........................................................................ [ 34%]
........................................................................ [ 38%]
........................................................................ [ 41%]
........................................................................ [ 45%]
........................................................................ [ 48%]
........................................................................ [ 52%]
........................................................................ [ 55%]
........................................................................ [ 59%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 69%]
........................................................................ [ 73%]
........................................................................ [ 76%]
........................................................................ [ 80%]
........................................................................ [ 83%]
........................................................................ [ 87%]
........................................................................ [ 90%]
........................................................................ [ 94%]
........................................................................ [ 97%]
................................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 181 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 127 1 99%
app/api/git_sources.py 229 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 115 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 125 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 121 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 190 3 98%
app/rag/llm.py 243 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 307 0 100%
--------------------------------------------------
TOTAL 3848 15 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,19 @@
Task 01 complete — everything green.
**Implemented**
- `app/core/tokens.py`: `TokenAlreadyRevoked` exception + `regenerate_token(db, token_id)` — atomic rotation (revoke-stamp + same-label successor create in one flush-only unit; caller owns the single commit), `None` for unknown id; module docstring carries the rotation/A4 contract.
- `app/api/tokens.py`: `POST /api/tokens/{id}/regenerate` → 201 `TokenCreated` (new row's one plaintext moment), 404 `token not found`, 409 `token already revoked`; router-wide `require_admin` covers it; `list_tokens` now passes `revoked_at`.
- `app/schemas.py`: `TokenListItem.revoked_at: datetime | None = None` (D5, wire-additive, docstring pinned).
**Tests**
- Unit (`tests/unit/test_tokens.py`): rotation matrix — new row/same label/well-formed plaintext/hash round-trip, old row stamped, old plaintext dead, missing → `None`, revoked → raises, rollback undoes BOTH writes (service never commits).
- Integration (`tests/integration/test_tokens_api.py`): 201 rotation (old row revoked w/ ISO `revoked_at`, new row active + newer `created_at` via deterministic gap, A4 list stays secret-free), 404, 409 (row untouched), 403 anonymous on all routes + 403 token-user; phase-79 list pins updated in place for the new field.
**Results**
- `uv run pytest --cov=app --cov-report=term-missing` → 2060 passed, TOTAL 99% (>90%; tokens modules 100%)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
- Regression: `tests/integration/test_auth_api.py` (17) and `uv run pytest tests/e2e/test_api_tokens.py --no-cov` (9) green in isolation
**Decisions**: none beyond spec; kept house conventions (service flushes/endpoint commits, one message per 404/409, docstring-carried contracts).
**Next pending task**: `.agents/phases/todo/101_tokens_page_overhaul/02_two_tables_search_required_name.md`
@@ -0,0 +1,96 @@
........................................................................ [ 3%]
........................................................................ [ 6%]
........................................................................ [ 10%]
........................................................................ [ 13%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 24%]
........................................................................ [ 27%]
........................................................................ [ 31%]
........................................................................ [ 34%]
........................................................................ [ 38%]
........................................................................ [ 41%]
........................................................................ [ 45%]
........................................................................ [ 48%]
........................................................................ [ 52%]
........................................................................ [ 55%]
........................................................................ [ 59%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 69%]
........................................................................ [ 73%]
........................................................................ [ 76%]
........................................................................ [ 80%]
........................................................................ [ 83%]
........................................................................ [ 87%]
........................................................................ [ 90%]
........................................................................ [ 94%]
........................................................................ [ 97%]
............................................ [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 181 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 127 1 99%
app/api/git_sources.py 229 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 115 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 125 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 121 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 190 3 98%
app/rag/llm.py 243 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 307 0 100%
--------------------------------------------------
TOTAL 3848 15 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,14 @@
All green. Task 02 is complete.
**Implemented (phase 101, task 02 — two tables, per-table search, required name):**
- `frontend/index.html` `#view-tokens`: 4-column active table (Status `<th>` gone, empty row `colspan=4`), new ship-hidden `#tokens-no-match-row`, both `type=search` inputs (`#token-search-active/-revoked`, `.token-search`), and the new ship-hidden revoked section (heading + search + 4-column table with `Revoked` column) below the active wrap; create row now `aria-label="Token name"` / `e.g. alice — required`
- `frontend/assets/tokens.js`: `loadTokens` splits by `tok.revoked` (server order kept), `setRevokedSectionVisible(n)` (heading+search+wrap shown iff ≥1 revoked), `applyFilter` (case-insensitive label substring, state rows excluded, no-match copy `No tokens match "<query>".`), persistent `activeQuery`/`revokedQuery` re-applied after every render, input listeners armed in the admin branch (no fetch), D3 required-name block (`Give the token a name first.` + re-focus, no request); `confirmRevoke` now relocates the row to the revoked table via the re-entrant load (its `revoked_at` cell needs the server stamp); `|| "token"` fallback deleted
- `styles.css`: `.token-search` (house input surface, full-width, ≥44px), `.tokens-revoked-heading` (phase-97 mono voice); empty/no-match styling already class-based (no-op)
- Source pins: 3 new tests in `tests/unit/test_frontend_router.py` (scaffold, JS split/search/name, CSS) — all pre-existing phase-79 pins still pass
- Defect fixes (in place): `tests/e2e/test_api_tokens.py` revoked-row assertions moved to `#tokens-revoked-tbody` (per phase plan task 04 item 5); `test_theme_semantic_completion.py::test_revoked_pill_gray_labeled` → `test_revoked_table_state_labeled` (pill intentionally gone — D1)
**Gates:** `uv run pytest --cov=app` → 2063 passed, app 99% (>90%) · `ruff check .` clean · `pyright` 0/0/0 · E2E in isolation: `test_api_tokens.py` 9 ✓, `test_theme_semantic_completion.py` 8 ✓, `test_history_page_width`+`test_admin_auth`+`test_shared_header` 17 ✓ (34 combined); temp in-browser verification of search/re-show/revoke split passed (file deleted)
**Decisions:** state rows (empty + no-match) excluded from the filter per 00_phase "(no-match/empty rows excluded)"; no-match text quotes the original untrimmed query; no mobile CSS change needed (class-based).
**Next pending task:** `03_regenerate_ui.md` (Regenerate control + CSS + source pins)
@@ -0,0 +1,96 @@
........................................................................ [ 3%]
........................................................................ [ 6%]
........................................................................ [ 10%]
........................................................................ [ 13%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 24%]
........................................................................ [ 27%]
........................................................................ [ 31%]
........................................................................ [ 34%]
........................................................................ [ 38%]
........................................................................ [ 41%]
........................................................................ [ 45%]
........................................................................ [ 48%]
........................................................................ [ 52%]
........................................................................ [ 55%]
........................................................................ [ 59%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 69%]
........................................................................ [ 73%]
........................................................................ [ 76%]
........................................................................ [ 80%]
........................................................................ [ 83%]
........................................................................ [ 87%]
........................................................................ [ 90%]
........................................................................ [ 94%]
........................................................................ [ 97%]
............................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 181 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 127 1 99%
app/api/git_sources.py 229 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 115 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 125 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 121 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 190 3 98%
app/rag/llm.py 243 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 307 0 100%
--------------------------------------------------
TOTAL 3848 15 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,19 @@
All green. Final report:
## Task 03 complete — Regenerate control (rotation UI)
**Implemented**
- `frontend/assets/tokens.js`: `makeRegenerateControl` (structural mirror of the revoke control: `.token-regenerate` button, `aria-label: "Regenerate token: <label>"`, exact confirm text `Regenerate? The current token is revoked.`, focus-to-Yes, No/failure → `restoreRegenerate()` with focus restored) + `confirmRegenerate` (disabled-while-in-flight, `POST /api/tokens/<id>/regenerate` no body; 201 → `loadTokens()` FIRST → once-block value-only reveal → exact D2 announce line; 404 → `row.remove()` + re-fetch + house 404 line; 409 → re-fetch + same line; network/non-2xx → neutral two-line copy + restore)
- Active Actions cell now `actionsTd.append(makeRegenerateControl, makeRevokeControl)` — Regenerate first, each control owns its OWN `.tokens-actions` wrapper span (independent confirm scopes)
- Module docstring: rotation semantics + 404/409 copy sections
- `frontend/assets/styles.css`: `.token-regenerate` — the `.token-revoke` twin (44px/`--line`/radius/transparent/`--ink-soft`) with the NEUTRAL hover (`--brand-soft`/`--brand-ink`, no err hue) + revoke-consistent `:disabled`; comment notes the phase-92 monochrome invariant
- Source pins in `tests/unit/test_frontend_router.py` (the task-02 pin file): `test_tokens_js_regenerate_control` (button/aria/confirm text/focus/restore, POST path, pinned 201 sequence load→reveal→announce, 404/409 copy, retryable restore ×2, cell order, per-control wrappers) + `test_tokens_regenerate_css_pins`
**Gates**
- `uv run pytest --cov=app --cov-report=term` → 2065 passed, exit 0, TOTAL **99%** (>90%)
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings
- `node --check` (ESM) on tokens.js → OK; revoke control untouched (its pins green)
**Decisions**: 404 branch follows the task's pinned decision (`row.remove()` + re-fetch + the revoke control's existing 404 line, not a revoked re-render); no E2E file added (task 04 owns the browser proof); no git commit per harness rules.
**Next pending task**: `.agents/phases/todo/101_tokens_page_overhaul/04_e2e_tokens_page.md`
@@ -0,0 +1,96 @@
........................................................................ [ 3%]
........................................................................ [ 6%]
........................................................................ [ 10%]
........................................................................ [ 13%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 24%]
........................................................................ [ 27%]
........................................................................ [ 31%]
........................................................................ [ 34%]
........................................................................ [ 38%]
........................................................................ [ 41%]
........................................................................ [ 45%]
........................................................................ [ 48%]
........................................................................ [ 52%]
........................................................................ [ 55%]
........................................................................ [ 59%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 69%]
........................................................................ [ 73%]
........................................................................ [ 76%]
........................................................................ [ 80%]
........................................................................ [ 83%]
........................................................................ [ 87%]
........................................................................ [ 90%]
........................................................................ [ 94%]
........................................................................ [ 97%]
................................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 181 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 127 1 99%
app/api/git_sources.py 229 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 115 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 125 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 121 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 190 3 98%
app/rag/llm.py 243 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 307 0 100%
--------------------------------------------------
TOTAL 3848 15 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,11 @@
All gates green. Report:
**Task 04 complete — `101_tokens_page_overhaul` E2E + regressions**
- Implemented `tests/e2e/test_tokens_page.py` (4 tests, all per spec): revoked tokens in their own table below (4-col active thead, hidden-while-empty section, Revoked date cell ≠ created, no actions), both tables searchable (no-match row copy, queries survive a Tuning-nav re-show), nameless/whitespace generation refused (live-region line, `page.request` GET proves no row), full Regenerate rotation (two-step confirm + focus, new `bor_…` plaintext in the once-block, D2 line, successor newer + original in revoked table, NEW token signs in via fresh context, ORIGINAL refused at the gate).
- Verified `tests/e2e/test_api_tokens.py` was already updated in place (revoked-row pins relocated to `#tokens-revoked-tbody`; no stale Status/pill/colspan refs) — green; `test_theme_semantic_completion.py` re-scope (this phase's) also green.
- E2E (isolated, `--no-cov`, DB up): `uv run pytest tests/e2e/test_tokens_page.py -v --no-cov` → **4 passed**; `test_api_tokens.py` → **9 passed**; `test_admin_auth.py` → **6 passed**; `test_shared_header.py` → **6 passed**; `test_theme_semantic_completion.py` → **8 passed**.
- Full gate: `uv run pytest --cov=app --cov-report=term-missing` → **2065 passed, app/ coverage 99%** (>90%); `uv run ruff check .` → clean; `uv run pyright` → 0 errors.
- Notable: one test-side race fixed (read the once-block after the D2 live-region line — the 201 sequence's last step); date cells indexed via `tokens-date-cell` class (Created = nth(0)). No app-code defects found.
- Per harness rules: no `git add`/`commit`, no phase-dir move — all changes left in the working tree.
- Next pending task: none in `101_tokens_page_overhaul` (this was the last task); queue continues with `98_sync_summary_visibility` etc. in `todo/`.
@@ -0,0 +1,96 @@
........................................................................ [ 3%]
........................................................................ [ 6%]
........................................................................ [ 10%]
........................................................................ [ 13%]
........................................................................ [ 17%]
........................................................................ [ 20%]
........................................................................ [ 24%]
........................................................................ [ 27%]
........................................................................ [ 31%]
........................................................................ [ 34%]
........................................................................ [ 38%]
........................................................................ [ 41%]
........................................................................ [ 45%]
........................................................................ [ 48%]
........................................................................ [ 52%]
........................................................................ [ 55%]
........................................................................ [ 59%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 69%]
........................................................................ [ 73%]
........................................................................ [ 76%]
........................................................................ [ 80%]
........................................................................ [ 83%]
........................................................................ [ 87%]
........................................................................ [ 90%]
........................................................................ [ 94%]
........................................................................ [ 97%]
................................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 181 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 127 1 99%
app/api/git_sources.py 229 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 115 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 125 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 121 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 190 3 98%
app/rag/llm.py 243 1 99%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 307 0 100%
--------------------------------------------------
TOTAL 3848 15 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
+37 -1
View File
@@ -15,7 +15,10 @@ Routes (all under ``/api`` via the ``main`` registration):
that carries the plaintext ``token``), ``GET /api/tokens`` that carries the plaintext ``token``), ``GET /api/tokens``
(``TokenList`` — newest first, secret-free), ``POST (``TokenList`` — newest first, secret-free), ``POST
/api/tokens/{token_id}/revoke`` (204, idempotent; unknown id → 404 /api/tokens/{token_id}/revoke`` (204, idempotent; unknown id → 404
``token not found``). ``token not found``), and ``POST /api/tokens/{token_id}/regenerate``
(201 ``TokenCreated`` — the atomic rotation: the old row is revoked
and its successor, same label, is created in ONE transaction; already
revoked → 409, unknown id → 404).
""" """
from __future__ import annotations from __future__ import annotations
@@ -87,6 +90,7 @@ def list_tokens(
created_at=row.created_at, created_at=row.created_at,
last_used_at=row.last_used_at, last_used_at=row.last_used_at,
revoked=row.revoked_at is not None, revoked=row.revoked_at is not None,
revoked_at=row.revoked_at, # phase 101 D5: null while active
) )
for row in rows for row in rows
] ]
@@ -110,3 +114,35 @@ def revoke_token(
raise HTTPException(status_code=404, detail="token not found") raise HTTPException(status_code=404, detail="token not found")
db.commit() db.commit()
return Response(status_code=204) return Response(status_code=204)
@router.post("/{token_id}/regenerate", response_model=TokenCreated, status_code=201)
def regenerate_token(
token_id: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008
) -> TokenCreated:
"""Rotate one active token (201) — the rotation is ONE transaction.
The old row is stamped ``revoked_at`` and the successor (SAME label,
fresh hash) is created in the same commit, so the holder's old token
is dead the instant the new one is live. This 201 is the NEW token's
ONE plaintext moment (A4 — the same contract as the create endpoint;
the old plaintext was already one-shot and is never re-shown).
A dead token cannot be rotated: an already-revoked id is a 409
``token already revoked``, and an unknown id a 404 ``token not
found`` (the revoke endpoint's exact message).
"""
try:
result = token_service.regenerate_token(db, token_id)
except token_service.TokenAlreadyRevoked:
raise HTTPException(
status_code=409, detail="token already revoked"
) from None
if result is None:
raise HTTPException(status_code=404, detail="token not found")
new_row, plaintext = result
db.commit() # ONE commit: the stamp + the create are atomic
db.refresh(new_row) # pulls the server-default created_at
return TokenCreated(
id=new_row.id, label=new_row.label, token=plaintext, created_at=new_row.created_at
)
+43 -1
View File
@@ -19,7 +19,16 @@ compare would be theatre, so the contrast is documented, not replicated.
House commit convention (the ``app.rag.sources_meta`` pattern): the service House commit convention (the ``app.rag.sources_meta`` pattern): the service
functions flush but never commit — the calling endpoint owns the commit, so functions flush but never commit — the calling endpoint owns the commit, so
a failed request can never leave a half-applied token mutation. a failed request can never leave a half-applied token mutation. This holds
for the composite :func:`regenerate_token` rotation too: the revocation
stamp and the successor's insert are flushed by ONE call and covered by
ONE commit — a create failure rolls the revoke back with it.
Rotation (phase 101): :func:`regenerate_token` revokes the old row and
creates its successor (same label) atomically. A4 never weakens — the old
plaintext was already one-shot (its 201 body was the only wire moment),
and the new plaintext is returned by the service exactly once, for the
rotation's 201 body to ship.
""" """
from __future__ import annotations from __future__ import annotations
@@ -116,3 +125,36 @@ def revoke(db: Session, token_id: uuid.UUID) -> bool:
if row.revoked_at is None: if row.revoked_at is None:
row.revoked_at = datetime.now(UTC) row.revoked_at = datetime.now(UTC)
return True return True
class TokenAlreadyRevoked(Exception):
"""A revoked (dead) token cannot be rotated — the regenerate endpoint's 409."""
def regenerate_token(
db: Session, token_id: uuid.UUID
) -> tuple[ApiToken, str] | None:
"""Rotate one ACTIVE token; return the (NEW row, new plaintext) ONCE.
The rotation is ONE atomic unit: ``revoked_at`` is stamped on the old
row (the :func:`revoke` primitive — its first stamp IS the
revocation time) and the successor row is created with the SAME
label (the hand-out name persists, phase 101 D2) — both writes are
flushed here and covered by the caller's SINGLE commit, so a create
failure rolls the revoke back with it (the service flushes, never
commits — the house convention). A missing id returns ``None`` (the
endpoint's 404); an already-revoked row raises
:class:`TokenAlreadyRevoked` (a dead token cannot be rotated — the
endpoint's 409). A4 never weakens: the old plaintext was already
one-shot, and the new plaintext exists outside this function only in
this return value — the rotation's 201 body is its one wire moment.
"""
row = db.get(ApiToken, token_id)
if row is None:
return None
if row.revoked_at is not None:
raise TokenAlreadyRevoked()
revoke(db, row.id) # stamps the first (and only) revocation time
new_row, plaintext = create_token(db, row.label)
db.flush() # one unit for the caller's commit: stamp + successor
return new_row, plaintext
+6
View File
@@ -930,6 +930,11 @@ class TokenListItem(BaseModel):
either form (A4). ``revoked`` is derived server-side from either form (A4). ``revoked`` is derived server-side from
``revoked_at is not None`` (the UI renders the Active/Revoked state ``revoked_at is not None`` (the UI renders the Active/Revoked state
from the flag, not the timestamp). from the flag, not the timestamp).
``revoked_at`` (phase 101, D5 — wire-additive, defaults null): null
while the token is active; once revoked, the ISO-8601 timestamp the
revoked table renders (the revocation date). The flag stays the
client's table-split key; this field is display data only.
""" """
id: uuid.UUID id: uuid.UUID
@@ -937,6 +942,7 @@ class TokenListItem(BaseModel):
created_at: datetime created_at: datetime
last_used_at: datetime | None last_used_at: datetime | None
revoked: bool revoked: bool
revoked_at: datetime | None = None
class TokenList(BaseModel): class TokenList(BaseModel):
+54 -2
View File
@@ -3167,14 +3167,66 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
} }
.token-revoke:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); } .token-revoke:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.token-revoke:disabled { opacity: 0.5; cursor: wait; } .token-revoke:disabled { opacity: 0.5; cursor: wait; }
/* Empty-state row: the muted centered message at full table width /* Regenerate (phase 101 task 03, D2): the .token-revoke's structural
(the .history-empty-row language, inline in the table). */ twin — the SAME size / border / radius / focus, the transparent
fill + ink-soft text — but the NEUTRAL action's hover
(brand-soft / brand-ink), NOT the revoke's error hover: a
rotation is a hand-out of a successor, not a deletion (no new
hue — the phase-92 monochrome invariant). The confirm pair
reuses the .history-confirm-* classes unchanged (the pair CSS
above). :disabled mirrors the revoke button's rule (the
in-flight state, while the rotation POST is out). */
.token-regenerate {
min-height: 44px;
padding: 0.35rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
.token-regenerate:hover:not(:disabled) { background: var(--brand-soft); color: var(--brand-ink); }
.token-regenerate:disabled { opacity: 0.5; cursor: wait; }
/* Empty-state + search no-match rows (phase 101): the muted centered
message at full table width (the .history-empty-row language,
inline in the table) — CLASS-based, so both the active table's
#tokens-empty-row / #tokens-no-match-row and the revoked table's
#tokens-revoked-no-match-row share it. */
.tokens-empty-row td { .tokens-empty-row td {
padding: 2.25rem 1rem; padding: 2.25rem 1rem;
text-align: center; text-align: center;
color: var(--ink-soft); color: var(--ink-soft);
font-style: italic; font-style: italic;
} }
/* Phase 101 (task 02, D4): the per-table live label search — the
house input surface (the #token-label / archive-upload input
family: --line hairline, --surface fill, ink text — AA pairs, no
new hue), full width, the ≥44px target, the global :focus-visible
ring (no custom focus rule — the house pattern). */
.token-search {
width: 100%;
min-height: 44px;
padding: 0.55rem 0.8rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--ink);
font: inherit;
font-size: 0.9rem;
}
/* The revoked section's sub-heading (phase 101 D1): the phase-97
.kb-level h2 voice — mono, 1rem, brand-ink (the path voice, AA on
the page background). */
.tokens-revoked-heading {
margin: 1.5rem 0 0.5rem;
font-family: var(--mono);
font-size: 1rem;
color: var(--brand-ink);
}
/* ---------- Theme view (phase 91, tasks 04 + 05) ---------- /* ---------- Theme view (phase 91, tasks 04 + 05) ----------
The shell's seventh view (#view-theme): the admin palette + branding The shell's seventh view (#view-theme): the admin palette + branding
+384 -92
View File
@@ -7,9 +7,12 @@
* Wires the admin-only token endpoints (phase 79 task 02) into the * Wires the admin-only token endpoints (phase 79 task 02) into the
* view: * view:
* *
* • the create row (label + Generate): a BLANK label sends "token" * • the create row (name + Generate): a BLANK / whitespace-only
* (the placeholder documents the fallback; the API's 1–120 * name is REFUSED client-side (phase 101 D3) — the live region
* validator is satisfied). POST /api/tokens → 201 — the ONE * reads `Give the token a name first.`, the name input re-focuses,
* and NO request is sent (the server's 422 on a blank/over-long
* label is defense in depth; the old blank-label "token"
* fallback is GONE). On a named generate: POST /api/tokens → 201 — the ONE
* response that carries the plaintext (owner-locked A4) — and the * response that carries the plaintext (owner-locked A4) — and the
* plaintext appears EXACTLY ONCE: in the #token-once block's mono * plaintext appears EXACTLY ONCE: in the #token-once block's mono
* read-only field, with a Copy (the clipboard; a non-secure http * read-only field, with a Copy (the clipboard; a non-secure http
@@ -18,31 +21,68 @@
* loadTokens() / re-show (and the field is wiped with it) — the * loadTokens() / re-show (and the field is wiped with it) — the
* plaintext is NOT stored anywhere client-side (no localStorage, * plaintext is NOT stored anywhere client-side (no localStorage,
* no data attribute), so a re-render can never re-show it; * no data attribute), so a re-render can never re-show it;
* • the full-width table (AGENTS.md rule 5): Label (textContent — * • the SPLIT tables (phase 101 D1 — AGENTS.md rule 5, full width,
* admin-derived, still text) | Created (locale date+time, the full * no skinny list): the ACTIVE table on top (Label | Created |
* ISO in the title) | Last used (locale or "never") | Status — a * Last used | Actions — the two-step inline Regenerate BEFORE
* plain em-dash for Active, the rose .stale-pill for Revoked (the * the two-step inline Revoke — phase 101 task 03 / D2) and the
* stale-pill visual language; the cell carries its aria-label in * REVOKED table below
* BOTH states — WCAG 2.1 AA) | Actions — Revoke, the inline * it (Label | Created | Last used | Revoked — the revoked_at
* TWO-STEP confirm (the history-confirm-* pattern — NO native * date). The Status column is GONE from both: the table's
* confirm dialog: the first click swaps to "Revoke? [Yes] [No]", * position IS the status (an all-active table needs no status; a
* focus moves to Yes; Yes POSTs /api/tokens/<id>/revoke and the * row in the revoked table is a dead token — no actions, nothing
* row re-renders Revoked + the live region line; No or a failed * left to revoke). The revoked SECTION (heading + search + table
* request restores the Revoke button — retryable). Revoked rows * wrap) is hidden while no token is revoked (an empty table is
* carry NO action (nothing left to revoke). * noise); the active table keeps its empty-state row;
* • the per-table LIVE label search (phase 101 D4): one type=search
* input per table — a case-insensitive SUBSTRING over the
* rendered rows' label cells, applied on `input` with NO fetch
* (each data row toggles its `hidden`), and RE-APPLIED after
* every loadTokens() (the queries live in module state —
* activeQuery / revokedQuery — and survive re-renders / re-shows,
* the phase-77 contract). Zero visible matches with a non-empty
* query → the per-table no-match row (`No tokens match
* "<query>".` — textContent, the distinct-from-empty-state
* language); an empty query shows every row;
* • the one-click REGENERATE (phase 101 task 03, D2 — rotation,
* atomic): every ACTIVE row carries a Regenerate button BEFORE
* its Revoke (the primary lifecycle action — ONE button starts
* it). The control is a structural mirror of the Revoke control:
* the first click swaps the button, in place, to the house
* two-step confirm (`Regenerate? The current token is revoked.
* [Yes] [No]` — the history-confirm-* classes, focus to Yes),
* and it owns its OWN .tokens-actions wrapper span, so a confirm
* in it never clobbers the Revoke control's. Yes → POST
* /api/tokens/<id>/regenerate (JSON, NO body) — the server
* rotates in ONE transaction: the old row is stamped revoked (it
* lands in the revoked table) and the successor is created under
* the SAME label (it lands in the active table); the 201 body
* ({ id, label, token, created_at }) is the new token's ONLY
* plaintext moment (A4). On 201 the re-entrant loadTokens() runs
* FIRST (the relocation), THEN the shown-once block reveals the
* NEW plaintext — the SAME #token-once block, the value-only
* contract (the plaintext lives in the field's value, never a
* data attribute) — and the live region reads `Regenerated
* "<label>" — copy the new token now; it won't be shown again.`.
* A 404 (the row vanished — revoked AND deleted elsewhere, or a
* stale render) removes the row, re-fetches (the reconciliation),
* and reuses the revoke control's 404 line `That token was
* already revoked.`; a 409 (revoked between render and click)
* re-fetches + the same line; any other failure / network error
* restores the Regenerate button (retryable) with the neutral
* house copy ("is the app reachable?" / "try again.").
* *
* Every cell is built with the DOM APIs (textContent) — this file * Every cell is built with the DOM APIs (textContent) — this file
* never builds HTML (the XSS-safe-by-construction house rule; a full- * never builds HTML (the XSS-safe-by-construction house rule; a full-
* file source pin enforces it). * file source pin enforces it).
* *
* The whoami gate (phase 19 shared-header module, cached promise): * The whoami gate (phase 19 shared-header module, cached promise):
* • anonymous → the #tokens-gate is shown, the create row + table * • anonymous → the #tokens-gate is shown, the create row + search
* hide, and NO /api/tokens request is made at all (the router * + tables hide, and NO /api/tokens request is made at all (the
* 403s anonymous — the same request-log contract as the history * router 403s anonymous — the same request-log contract as the
* view); * history view);
* • admin → the gate hides, the create row + table reveal, and * • admin → the gate hides, the create row + search + active table
* `loadTokens()` renders the rows; a 0-row fetch (and a failed * reveal, and `loadTokens()` renders the rows; a 0-active-row
* load) reveals the empty-state row. * fetch (and a failed load) reveals the active empty-state row.
* *
* Phase 76 (task 06) — shell view module: the top-level boot is * Phase 76 (task 06) — shell view module: the top-level boot is
* `export async function mount(root)` — root is the view's * `export async function mount(root)` — root is the view's
@@ -68,6 +108,23 @@
* branch, after the whoami gate passes: anonymous shows the gate and * branch, after the whoami gate passes: anonymous shows the gate and
* never fetches. * never fetches.
* *
* Phase 101 (tasks 02 + 03) — the tokens page overhaul: the single
* table SPLIT into active + revoked (D1: the table's position IS the
* status, the Status column + the em-dash / .stale-pill are gone),
* the per-table live label search (D4: client-side, query state
* survives re-renders), the required name (D3: a blank name
* generates NOTHING), and the one-click REGENERATE (D2: the rotation
* is atomic server-side — the old row revoked, the successor created
* under the same label — the 201's re-entrant load runs BEFORE the
* once-block reveal; a 404 removes the vanished row + re-fetches, a
* 409 re-fetches — both landing the house line "That token was
* already revoked."; a retryable failure restores the button).
* loadTokens keeps its re-entrant core
* (once-block hidden + wiped, data rows dropped, the fetch, the error
* lines, the return value) and SPLITS the fetched list by
* `tok.revoked` — the server's newest-first order kept per table —
* before re-applying BOTH persistent filters.
*
* The clipboard + inline-fallback helper is tokens.js's OWN ~10-line * The clipboard + inline-fallback helper is tokens.js's OWN ~10-line
* copy (the per-page duplication house style — history.js keeps the * copy (the per-page duplication house style — history.js keeps the
* share link's, app.js the chat page's; no new shared module). * share link's, app.js the chat page's; no new shared module).
@@ -80,9 +137,23 @@ export async function mount(root) {
const tableWrap = root.querySelector("#tokens-table-wrap"); const tableWrap = root.querySelector("#tokens-table-wrap");
const tbody = root.querySelector("#tokens-tbody"); const tbody = root.querySelector("#tokens-tbody");
const emptyRow = root.querySelector("#tokens-empty-row"); const emptyRow = root.querySelector("#tokens-empty-row");
// Phase 101 (task 02, D4): the per-table search inputs + no-match
// rows (the active no-match row ships in #tokens-tbody, hidden; its
// <td> text is JS-filled).
const searchActive = root.querySelector("#token-search-active");
const noMatchRow = root.querySelector("#tokens-no-match-row");
// Phase 101 (task 02, D1): the REVOKED section — the visible
// sub-heading, the section's own search input, the table wrap, the
// tbody, and its no-match row. The section ships hidden;
// setRevokedSectionVisible(n) shows it iff n ≥ 1.
const revokedHeading = root.querySelector("#tokens-revoked-heading");
const searchRevoked = root.querySelector("#token-search-revoked");
const revokedWrap = root.querySelector("#tokens-revoked-wrap");
const revokedTbody = root.querySelector("#tokens-revoked-tbody");
const revokedNoMatchRow = root.querySelector("#tokens-revoked-no-match-row");
const gateEl = root.querySelector("#tokens-gate"); const gateEl = root.querySelector("#tokens-gate");
const statusEl = root.querySelector("#tokens-status"); const statusEl = root.querySelector("#tokens-status");
// The create row (label + Generate) — SHIPS hidden (anonymous-safe; // The create row (name + Generate) — SHIPS hidden (anonymous-safe;
// the admin branch reveals it). // the admin branch reveals it).
const createRow = root.querySelector("#token-create"); const createRow = root.querySelector("#token-create");
const labelInput = root.querySelector("#token-label"); const labelInput = root.querySelector("#token-label");
@@ -93,6 +164,13 @@ export async function mount(root) {
const onceValue = root.querySelector("#token-once-value"); const onceValue = root.querySelector("#token-once-value");
const onceCopy = root.querySelector("#token-once-copy"); const onceCopy = root.querySelector("#token-once-copy");
/* Phase 101 (task 02, D4): the per-table search queries — module
state that SURVIVES every re-render and re-show: a load never
resets them (it re-applies them), and the input listeners are
the only writers. */
let activeQuery = "";
let revokedQuery = "";
/* Action feedback — the role="status" live region (the "never stale" /* Action feedback — the role="status" live region (the "never stale"
contract: every action lands a line here, success or failure contract: every action lands a line here, success or failure
alike). */ alike). */
@@ -141,12 +219,18 @@ export async function mount(root) {
); );
} }
/* One row. The Status cell carries the cell-level aria-label in BOTH /* One row for ONE of the two tables (phase 101 D1): "active" —
states (Active em-dash / the rose Revoked pill — the stale-pill Label | Created | Last used | Actions (the two-step inline
visual language) so the marker is conveyed without the visual Regenerate BEFORE the two-step inline Revoke — phase 101
(WCAG 2.1 AA). The Actions cell carries the two-step Revoke for task 03 / D2: the rotation is the primary lifecycle action), or
active rows only — revoked rows have nothing left to revoke. */ "revoked" — Label | Created | Last used | Revoked (the
function makeRow(tok) { revoked_at date: locale date+time, full ISO on hover — the
house tokens-date-cell language). The row's very presence in the
revoked table IS the status (the em-dash / .stale-pill column is
gone from both tables); revoked rows carry NO actions. The label
cell (.tokens-label-cell) is the search filter's data source —
applyFilter reads its textContent. */
function makeRow(tok, table) {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
const labelTd = document.createElement("td"); const labelTd = document.createElement("td");
@@ -171,43 +255,93 @@ export async function mount(root) {
} }
tr.appendChild(usedTd); tr.appendChild(usedTd);
const statusTd = document.createElement("td"); if (table === "revoked") {
statusTd.className = "tokens-status-cell"; const revokedTd = document.createElement("td");
if (tok.revoked) { revokedTd.className = "tokens-date-cell";
statusTd.setAttribute( if (tok.revoked_at) {
"aria-label", revokedTd.title = tok.revoked_at; // full ISO on hover
"Revoked — this token is dead and can no longer sign in", revokedTd.textContent = fmtDate(tok.revoked_at);
); } else {
const pill = document.createElement("span"); revokedTd.textContent = "—"; // defensive: the server stamps it
pill.className = "stale-pill"; }
pill.title = "Revoked — the token can no longer sign in"; tr.appendChild(revokedTd);
pill.textContent = "Revoked"; return tr;
statusTd.appendChild(pill);
} else {
statusTd.setAttribute(
"aria-label",
"Active — this token can still be used to sign in",
);
statusTd.textContent = "—"; // the em-dash: active rows' marker
} }
tr.appendChild(statusTd);
const actionsTd = document.createElement("td"); const actionsTd = document.createElement("td");
actionsTd.className = "tokens-actions-cell"; actionsTd.className = "tokens-actions-cell";
if (!tok.revoked) { /* Phase 101 (task 03, D2): Regenerate FIRST (the primary
actionsTd.appendChild(makeRevokeControl(tok, tr)); lifecycle action) — each control owns its OWN .tokens-actions
} wrapper span, so a confirm in one never clobbers the other. */
actionsTd.append(
makeRegenerateControl(tok, tr),
makeRevokeControl(tok, tr),
);
tr.appendChild(actionsTd); tr.appendChild(actionsTd);
return tr; return tr;
} }
/* The inline two-step Regenerate (phase 101 task 03, D2 — a
structural mirror of makeRevokeControl, the D2 contract): ONE
button starts the rotation, and its first click replaces it, in
place, by the "Regenerate? The current token is revoked. [Yes]
[No]" pair (the history-confirm-* classes — a destructive
rotation deserves the same confirm weight as Revoke); focus
moves to Yes (keyboard-reachable confirm). Yes →
confirmRegenerate (POST /api/tokens/<id>/regenerate — the
atomic rotation; on 201 the re-entrant load runs FIRST, then
the shown-once block reveals the new plaintext). No or a failed
request restores the Regenerate button (retryable). The control
owns its OWN .tokens-actions wrapper span: the Actions cell
hosts two independent confirm scopes side by side (this one +
the Revoke control's) — a swap in one never clobbers the other.
The shipped state IS the Regenerate button. */
function makeRegenerateControl(tok, row) {
const cell = document.createElement("span");
cell.className = "tokens-actions";
const regenBtn = document.createElement("button");
regenBtn.type = "button";
regenBtn.className = "token-regenerate";
regenBtn.setAttribute("aria-label", `Regenerate token: ${tok.label}`);
regenBtn.textContent = "Regenerate";
function restoreRegenerate() {
cell.replaceChildren(regenBtn);
regenBtn.focus(); // focus returns to the (restored) control
}
regenBtn.addEventListener("click", () => {
const label = document.createElement("span");
label.className = "history-confirm-text";
label.textContent = "Regenerate? The current token is revoked.";
const yes = document.createElement("button");
yes.type = "button";
yes.className = "history-confirm-yes";
yes.textContent = "Yes";
const no = document.createElement("button");
no.type = "button";
no.className = "history-confirm-no";
no.textContent = "No";
yes.addEventListener("click", () =>
confirmRegenerate(tok, row, yes, restoreRegenerate));
no.addEventListener("click", restoreRegenerate);
cell.replaceChildren(label, yes, no);
yes.focus(); // the confirm pair takes over the focus
});
cell.appendChild(regenBtn); // the shipped state IS the Regenerate button
return cell;
}
/* The inline two-step Revoke (the history-confirm-* pattern — NO /* The inline two-step Revoke (the history-confirm-* pattern — NO
native confirm dialog anywhere in this file). The Revoke button is native confirm dialog anywhere in this file). The Revoke button is
replaced, in place, by the "Revoke? [Yes] [No]" pair; focus moves replaced, in place, by the "Revoke? [Yes] [No]" pair; focus moves
to Yes (keyboard-reachable confirm). Yes → POST to Yes (keyboard-reachable confirm). Yes → POST
/api/tokens/<id>/revoke → the row re-renders Revoked (+ the live /api/tokens/<id>/revoke → the re-entrant load runs (the row
region line); No or a failed request restores the Revoke button LEAVES the active table and lands in the revoked table below —
(retryable). */ D1) + the live region line; No or a failed request restores the
Revoke button (retryable). */
function makeRevokeControl(tok, row) { function makeRevokeControl(tok, row) {
const cell = document.createElement("span"); const cell = document.createElement("span");
cell.className = "tokens-actions"; cell.className = "tokens-actions";
@@ -247,12 +381,14 @@ export async function mount(root) {
} }
/* The confirmed revoke: POST /api/tokens/<id>/revoke (204 — /* The confirmed revoke: POST /api/tokens/<id>/revoke (204 —
idempotent server-side) → the row STAYS (it is not removed) and idempotent server-side) → the live region gets
re-renders Revoked, and the live region gets `Revoked "<label>".` `Revoked "<label>".` and the re-entrant loadTokens() runs: the
A 404 means the row is gone (revoked elsewhere) — re-render it row LEAVES the active table and lands in the revoked table (its
Revoked and say so. Any other failure or a network error keeps the server-stamped revoked_at renders in the Revoked cell — D1). A
row, restores the Revoke button (retryable), and lands the error 404 means the token was revoked elsewhere — the same re-load
line. */ reconciles the tables and the line says so. Any other failure or
a network error keeps the row, restores the Revoke button
(retryable), and lands the error line. */
async function confirmRevoke(tok, row, yesBtn, restoreRevoke) { async function confirmRevoke(tok, row, yesBtn, restoreRevoke) {
yesBtn.disabled = true; // no double-fire while the request is in flight yesBtn.disabled = true; // no double-fire while the request is in flight
let r; let r;
@@ -264,8 +400,8 @@ export async function mount(root) {
return; return;
} }
if (r.status === 404) { if (r.status === 404) {
row.replaceWith(makeRow({ ...tok, revoked: true }));
announce("That token was already revoked."); announce("That token was already revoked.");
await loadTokens(); // the row relocates to the revoked table
return; return;
} }
if (!r.ok) { if (!r.ok) {
@@ -273,26 +409,136 @@ export async function mount(root) {
restoreRevoke(); restoreRevoke();
return; return;
} }
row.replaceWith(makeRow({ ...tok, revoked: true }));
announce(`Revoked "${tok.label}".`); announce(`Revoked "${tok.label}".`);
await loadTokens(); // the row relocates to the revoked table (D1)
}
/* The confirmed regenerate (phase 101 task 03, D2): POST
/api/tokens/<id>/regenerate (JSON, NO body) — the server rotates
atomically in ONE transaction: the old row is stamped revoked
(it lands in the revoked table) and the successor is created
under the SAME label (it lands in the active table). The 201
body ({ id, label, token, created_at }) is the new token's ONLY
plaintext moment (A4 — the old plaintext was already one-shot
and is gone). The 201 sequence is pinned: the re-entrant
loadTokens() runs FIRST (the relocation), THEN the shown-once
block reveals the new plaintext in the field's VALUE only (never
a data attribute — the same #token-once block a create uses),
THEN the live region reads the D2 line. A 404 means the row
VANISHED (revoked AND deleted by another admin, or a stale
render) — the row is removed, the re-fetch reconciles both
tables, and the line reuses the revoke control's 404 copy (one
house message for the one common case). A 409 means the row was
revoked between render and click — the re-fetch reconciles +
the same line. Any other failure or a network error keeps the
row, restores the Regenerate button (retryable), and lands the
neutral error line. */
async function confirmRegenerate(tok, row, yesBtn, restoreRegenerate) {
yesBtn.disabled = true; // no double-fire while the request is in flight
let r;
try {
r = await fetch(`/api/tokens/${tok.id}/regenerate`, { method: "POST" });
} catch {
announce(`Couldn't regenerate "${tok.label}" — is the app reachable?`);
restoreRegenerate();
return;
}
if (r.status === 404) {
row.remove(); // the row vanished — the re-fetch reconciles both tables
await loadTokens();
announce("That token was already revoked.");
return;
}
if (r.status === 409) {
await loadTokens(); // the row was revoked between render and click
announce("That token was already revoked.");
return;
}
if (!r.ok) {
announce(`Couldn't regenerate "${tok.label}" — try again.`);
restoreRegenerate();
return;
}
const data = await r.json();
/* D2: the re-entrant load runs FIRST — the old row relocates to
the revoked table, the new row lands in the active one — then
the once-block reveals the new plaintext (value only, A4). */
await loadTokens();
if (onceValue) onceValue.value = data.token;
if (onceBlock) onceBlock.hidden = false;
announce(
`Regenerated "${tok.label}" — copy the new token now; it won't be shown again.`,
);
}
/* The revoked SECTION (heading + search input + table wrap — D1)
shows iff at least one token is revoked: an empty table is noise.
count is the revoked-row count of the last load (0 hides it). */
function setRevokedSectionVisible(count) {
const show = count > 0;
if (revokedHeading) revokedHeading.hidden = !show;
if (searchRevoked) searchRevoked.hidden = !show;
if (revokedWrap) revokedWrap.hidden = !show;
} }
/* The empty-state row reappears exactly when there is nothing else /* The empty-state row reappears exactly when there is nothing else
in the tbody (the empty row itself ships in the tbody, hidden). */ in the tbody (the empty row + the no-match row both ship in the
tbody, hidden — a failed load / 0-row fetch restores BOTH). */
function showEmptyState() { function showEmptyState() {
if (!tbody) return; if (!tbody) return;
tbody.replaceChildren(emptyRow); tbody.replaceChildren(emptyRow, noMatchRow);
if (emptyRow) emptyRow.hidden = false; if (emptyRow) emptyRow.hidden = false;
if (noMatchRow) noMatchRow.hidden = true;
}
/* One table's LIVE label filter (phase 101 D4) — pure DOM, NO
fetch. The query is trimmed + lowercased for the MATCH; the
no-match copy quotes the ORIGINAL user text. The rendered rows
are the source of truth: the filter reads each data row's label
cell (.tokens-label-cell) textContent — case-insensitive
substring — and toggles row.hidden. The state rows (the
no-match row, the empty-state row) are NEVER data rows. The
no-match row is visible ⟺ the query is non-empty AND zero data
rows are visible — its <td> textContent = `No tokens match
"<query>".` (the query inside the quotes is textContent, never
HTML). An empty query shows every row and hides the no-match
row. */
function applyFilter(targetTbody, targetNoMatchRow, rawQuery) {
if (!targetTbody) return;
const query = (rawQuery ?? "").trim().toLowerCase();
let visible = 0;
for (const tr of targetTbody.querySelectorAll("tr")) {
if (tr === targetNoMatchRow || tr === emptyRow) continue; // state rows
const labelCell = tr.querySelector(".tokens-label-cell");
const label = (labelCell ? labelCell.textContent : "").toLowerCase();
tr.hidden = query !== "" && !label.includes(query);
if (!tr.hidden) visible += 1;
}
if (!targetNoMatchRow) return;
const noMatch = query !== "" && visible === 0;
targetNoMatchRow.hidden = !noMatch;
if (noMatch) {
const td = targetNoMatchRow.querySelector("td");
if (td) td.textContent = `No tokens match "${rawQuery}".`;
}
} }
/* GET /api/tokens → render the rows (newest-first — the server's /* GET /api/tokens → render the rows (newest-first — the server's
order). A 0-row fetch shows the empty-state row. Re-entrant (the order). Re-entrant (the phase-77 re-show contract): a re-show
phase-77 re-show contract): a re-show re-run must REPLACE the re-run must REPLACE the list, not append a duplicate set — the
list, not append a duplicate set — the data rows (every <tr> data rows (every <tr> EXCEPT the hidden state rows, which the
EXCEPT the hidden #tokens-empty-row, which the load itself load itself re-hides / reveals) are dropped before the fetch —
re-hides / reveals) are dropped before the fetch — and the and the once-block HIDDEN + its field wiped (the plaintext is
once-block HIDDEN + its field wiped (the plaintext is gone: a gone: a re-render can never re-show it).
re-render can never re-show it). Phase 101 (D1): the render step SPLITS the fetched list by
`tok.revoked` — the server's newest-first order kept per table:
the active rows fill #tokens-tbody, the revoked rows fill
#tokens-revoked-tbody. The active empty-state row shows iff there
are ZERO active rows (a 0-active fetch with revoked rows shows
the empty active table AND the populated revoked section — both
honest); the revoked SECTION shows iff ≥ 1 revoked row. FINALLY
both persistent filters re-apply (D4 — a re-render never loses
the queries).
A FAILED load announces its line in the live region (the house A FAILED load announces its line in the live region (the house
copy: "is the app reachable?" / "try again.") and RETURNS the copy: "is the app reachable?" / "try again.") and RETURNS the
outcome: true when the fetch settled (a 0-row fetch is a outcome: true when the fetch settled (a 0-row fetch is a
@@ -303,49 +549,74 @@ export async function mount(root) {
if (onceValue) onceValue.value = ""; if (onceValue) onceValue.value = "";
if (tbody) { if (tbody) {
for (const tr of tbody.querySelectorAll("tr")) { for (const tr of tbody.querySelectorAll("tr")) {
if (tr !== emptyRow) tr.remove(); if (tr !== emptyRow && tr !== noMatchRow) tr.remove();
}
}
if (revokedTbody) {
for (const tr of revokedTbody.querySelectorAll("tr")) {
if (tr !== revokedNoMatchRow) tr.remove();
} }
} }
if (emptyRow) emptyRow.hidden = true; if (emptyRow) emptyRow.hidden = true;
if (noMatchRow) noMatchRow.hidden = true;
let r; let r;
try { try {
r = await fetch("/api/tokens"); r = await fetch("/api/tokens");
} catch { } catch {
announce("Couldn't load tokens — is the app reachable?"); announce("Couldn't load tokens — is the app reachable?");
showEmptyState(); showEmptyState();
setRevokedSectionVisible(0);
return false; return false;
} }
if (!r.ok) { if (!r.ok) {
announce("Couldn't load tokens — try again."); announce("Couldn't load tokens — try again.");
showEmptyState(); showEmptyState();
setRevokedSectionVisible(0);
return false; return false;
} }
const { tokens } = await r.json(); const { tokens } = await r.json();
if (!tokens.length) { /* D1: the split — the newest-first server order kept per table. */
showEmptyState(); const active = tokens.filter((t) => !t.revoked);
return true; const revoked = tokens.filter((t) => t.revoked);
for (const tok of active) {
tbody.appendChild(makeRow(tok, "active"));
} }
for (const tok of tokens) { for (const tok of revoked) {
tbody.appendChild(makeRow(tok)); if (revokedTbody) revokedTbody.appendChild(makeRow(tok, "revoked"));
} }
if (emptyRow) emptyRow.hidden = active.length !== 0;
setRevokedSectionVisible(revoked.length);
/* D4: the persistent queries survive the re-render — BOTH
filters re-apply after the load. */
applyFilter(tbody, noMatchRow, activeQuery);
applyFilter(revokedTbody, revokedNoMatchRow, revokedQuery);
return true; return true;
} }
/* Generate: the label comes from #token-label — a BLANK label sends /* Generate: the name comes from #token-label — a BLANK /
"token" (the placeholder documents the fallback; the API's 1–120 whitespace-only name is REFUSED client-side (phase 101 D3): the
validator is satisfied). The button runs the §7.4 never-stale live region reads `Give the token a name first.`, the name input
lifecycle: "Generating…" while the POST is in flight, re-enabled re-focuses, and NO request is sent (the server's 422 on a
on success AND failure (the finally — a click can never leave it blank/over-long label stands unchanged — defense in depth; the
stuck disabled). On 201 the re-entrant list load runs FIRST (it old blank-label "token" fallback is DELETED — the name is the
hides the once-block — the re-render contract) and THEN the hand-out identity, not an optional decoration). The button runs the §7.4
once-block reveals with the plaintext (the 201 body's token is never-stale lifecycle: "Generating…" while the POST is in flight,
the ONE plaintext that exists, A4 — it lives in this closure re-enabled on success AND failure (the finally — a click can
until the next loadTokens() hides the block again), the live never leave it stuck disabled). On 201 the re-entrant list load
region gets the shown-once line, and the label input clears (a runs FIRST (it hides the once-block — the re-render contract) and
new token is a new hand-out). A failed create keeps the label THEN the once-block reveals with the plaintext (the 201 body's
token is the ONE plaintext that exists, A4 — it lives in this
closure until the next loadTokens() hides the block again), the
live region gets the shown-once line, and the name input clears
(a new token is a new hand-out). A failed create keeps the label
(retryable) and lands the error line. */ (retryable) and lands the error line. */
async function generateToken() { async function generateToken() {
const label = (labelInput ? labelInput.value : "").trim() || "token"; const label = (labelInput ? labelInput.value : "").trim();
if (!label) {
announce("Give the token a name first.");
if (labelInput) labelInput.focus();
return; // refused client-side (D3) — no request is sent
}
if (generateBtn) generateBtn.disabled = true; if (generateBtn) generateBtn.disabled = true;
if (generateBtn) generateBtn.textContent = "Generating…"; if (generateBtn) generateBtn.textContent = "Generating…";
let created = null; let created = null;
@@ -381,16 +652,20 @@ export async function mount(root) {
* exactly once, via the chat module (app.js) at shell boot. The * exactly once, via the chat module (app.js) at shell boot. The
* whoami gate reads fetchIsAdmin() — the SAME cached whoami promise * whoami gate reads fetchIsAdmin() — the SAME cached whoami promise
* the header uses (zero extra requests). Anonymous: the gate in, * the header uses (zero extra requests). Anonymous: the gate in,
* the create row + table out — and NO /api/tokens request at all * the create row + search + tables out — and NO /api/tokens request
* (the router 403s anonymous, so the view must never call it). */ * at all (the router 403s anonymous, so the view must never call
* it). */
if (!(await fetchIsAdmin())) { if (!(await fetchIsAdmin())) {
if (gateEl) gateEl.hidden = false; if (gateEl) gateEl.hidden = false;
if (createRow) createRow.hidden = true; if (createRow) createRow.hidden = true;
if (searchActive) searchActive.hidden = true;
if (tableWrap) tableWrap.hidden = true; if (tableWrap) tableWrap.hidden = true;
setRevokedSectionVisible(0);
return; return;
} }
if (gateEl) gateEl.hidden = true; if (gateEl) gateEl.hidden = true;
if (createRow) createRow.hidden = false; if (createRow) createRow.hidden = false;
if (searchActive) searchActive.hidden = false;
if (tableWrap) tableWrap.hidden = false; if (tableWrap) tableWrap.hidden = false;
if (onceBlock) onceBlock.hidden = true; // ships hidden; only a 201 reveals it if (onceBlock) onceBlock.hidden = true; // ships hidden; only a 201 reveals it
/* Phase 77: a user-initiated re-show of this already-mounted view /* Phase 77: a user-initiated re-show of this already-mounted view
@@ -411,6 +686,23 @@ export async function mount(root) {
if (onceCopy) { if (onceCopy) {
onceCopy.addEventListener("click", () => void copyTokenAction()); onceCopy.addEventListener("click", () => void copyTokenAction());
} }
/* Phase 101 (task 02, D4): the per-table search listeners — armed
in the ADMIN branch only (after the whoami gate, like the refresh
listener): set the module query + applyFilter, NO fetch. The
queries persist across re-renders / re-shows (loadTokens
re-applies them). */
if (searchActive) {
searchActive.addEventListener("input", () => {
activeQuery = searchActive.value;
applyFilter(tbody, noMatchRow, activeQuery);
});
}
if (searchRevoked) {
searchRevoked.addEventListener("input", () => {
revokedQuery = searchRevoked.value;
applyFilter(revokedTbody, revokedNoMatchRow, revokedQuery);
});
}
started = true; started = true;
loadTokens(); loadTokens();
} }
+82 -22
View File
@@ -913,21 +913,22 @@
lines, nothing else. --> lines, nothing else. -->
<span class="tokens-status" id="tokens-status" role="status" aria-live="polite"></span> <span class="tokens-status" id="tokens-status" role="status" aria-live="polite"></span>
<!-- The create row: label (1–120 — the server re-validates; a <!-- The create row: name (1–120 — the server re-validates; a
BLANK label sends "token", the placeholder documents the BLANK name is REFUSED client-side — tokens.js announces
fallback) + Generate. SHIPS hidden (anonymous-safe — no "Give the token a name first." and sends NOTHING, phase
dead control beside the gate; tokens.js reveals it for 101 D3; the server's 422 is defense in depth) +
admin only). The button is type="button" (no form submit) Generate. SHIPS hidden (anonymous-safe — no dead control
and runs the §7.4 never-stale lifecycle ("Generating…" beside the gate; tokens.js reveals it for admin only).
while the POST is in flight, re-enabled on success AND The button is type="button" (no form submit) and runs the
failure). --> §7.4 never-stale lifecycle ("Generating…" while the POST
is in flight, re-enabled on success AND failure). -->
<div class="token-create" id="token-create" hidden> <div class="token-create" id="token-create" hidden>
<input <input
id="token-label" id="token-label"
type="text" type="text"
maxlength="120" maxlength="120"
placeholder="e.g. alice" placeholder="e.g. alice — required"
aria-label="Token label" aria-label="Token name"
autocomplete="off" autocomplete="off"
> >
<button type="button" class="token-generate" id="token-generate">Generate</button> <button type="button" class="token-generate" id="token-generate">Generate</button>
@@ -959,16 +960,35 @@
</div> </div>
</div> </div>
<!-- The full-width table (AGENTS.md rule 5 — no skinny list): <!-- Phase 101 (task 02, D4): the ACTIVE table's live label
Label | Created (locale date+time, full ISO on hover) | search — a client-side, case-insensitive SUBSTRING filter
Last used (locale or "never") | Status (Active em-dash vs over the rendered rows (no fetch). SHIPS hidden with the
the rose Revoked pill — the stale-pill language) | Actions create row (tokens.js reveals it in the admin branch);
(Revoke — the inline two-step confirm; Revoked rows carry the query lives in module state and is RE-APPLIED after
NO action — nothing left to revoke). tokens.js fills every loadTokens() — a re-render never loses it. -->
#tokens-tbody; #tokens-empty-row ships hidden and is <input
revealed by a 0-row fetch (and a failed load). The Actions type="search"
column header is visually-hidden — the row buttons carry id="token-search-active"
their own aria-labels (the history-table convention). --> aria-label="Search active tokens"
placeholder="Search active tokens…"
class="token-search"
hidden
>
<!-- The full-width ACTIVE table (AGENTS.md rule 5 — no skinny
list): Label | Created (locale date+time, full ISO on
hover) | Last used (locale or "never") | Actions (Revoke
— the inline two-step confirm; phase 101 task 03 adds
Regenerate before it). The Status column is GONE (phase
101 D1 — a table that is all-active needs no status; the
table's position IS the status). tokens.js fills
#tokens-tbody with the revoked === false rows only;
#tokens-empty-row ships hidden and is revealed by a
0-active-row fetch (and a failed load); #tokens-no-match-row
(its text JS-filled) is the D4 search no-match state. The
Actions column header is visually-hidden — the row
buttons carry their own aria-labels (the history-table
convention). -->
<div class="table-wrap tokens-table-wrap" id="tokens-table-wrap" role="region" aria-label="Access tokens" tabindex="0"> <div class="table-wrap tokens-table-wrap" id="tokens-table-wrap" role="region" aria-label="Access tokens" tabindex="0">
<table class="tokens-table" id="tokens-table"> <table class="tokens-table" id="tokens-table">
<caption class="visually-hidden">Access tokens issued to people who use the app — newest first</caption> <caption class="visually-hidden">Access tokens issued to people who use the app — newest first</caption>
@@ -977,13 +997,53 @@
<th scope="col">Label</th> <th scope="col">Label</th>
<th scope="col">Created</th> <th scope="col">Created</th>
<th scope="col">Last used</th> <th scope="col">Last used</th>
<th scope="col">Status</th>
<th scope="col"><span class="visually-hidden">Actions</span></th> <th scope="col"><span class="visually-hidden">Actions</span></th>
</tr> </tr>
</thead> </thead>
<tbody id="tokens-tbody"> <tbody id="tokens-tbody">
<tr class="tokens-empty-row" id="tokens-empty-row" hidden> <tr class="tokens-empty-row" id="tokens-empty-row" hidden>
<td colspan="5">No tokens yet — generate one above and hand it out.</td> <td colspan="4">No tokens yet — generate one above and hand it out.</td>
</tr>
<tr class="tokens-empty-row" id="tokens-no-match-row" hidden>
<td colspan="4"></td>
</tr>
</tbody>
</table>
</div>
<!-- Phase 101 (task 02, D1 + D4): the REVOKED section — BELOW
the active table: a visible sub-heading + its own live
label search + its own full-width table (Label | Created
| Last used | Revoked — the revoked_at date, locale
date+time, full ISO on hover). SHIPS hidden — tokens.js
shows it when ≥ 1 token is revoked (an empty table is
noise). The table IS the status: the em-dash /
.stale-pill column is gone from BOTH tables. Revoked rows
carry NO actions (nothing left to revoke or rotate —
task 03's Regenerate lives in the active table only). -->
<h2 id="tokens-revoked-heading" class="tokens-revoked-heading" hidden>Revoked tokens</h2>
<input
type="search"
id="token-search-revoked"
aria-label="Search revoked tokens"
placeholder="Search revoked tokens…"
class="token-search"
hidden
>
<div class="table-wrap tokens-table-wrap" id="tokens-revoked-wrap" role="region" aria-label="Revoked tokens" tabindex="0" hidden>
<table class="tokens-table" id="tokens-revoked-table">
<caption class="visually-hidden">Revoked tokens — newest first</caption>
<thead>
<tr>
<th scope="col">Label</th>
<th scope="col">Created</th>
<th scope="col">Last used</th>
<th scope="col">Revoked</th>
</tr>
</thead>
<tbody id="tokens-revoked-tbody">
<tr class="tokens-empty-row" id="tokens-revoked-no-match-row" hidden>
<td colspan="4"></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
+10 -4
View File
@@ -367,12 +367,12 @@ def test_admin_generates_token_in_ui(page: Page, app_url: str, db_ready: None) -
"Token created — copy it now; it won't be shown again." "Token created — copy it now; it won't be shown again."
) )
# The table shows the Active row: the em-dash status marker, no # The table shows the Active row (phase 101 D1: the Status column
# is gone — the four-column active table IS the status): no
# Revoked pill, a Revoke action. # Revoked pill, a Revoke action.
row = page.locator("#tokens-tbody tr", has_text="e2e-alice") row = page.locator("#tokens-tbody tr", has_text="e2e-alice")
expect(row).to_have_count(1) expect(row).to_have_count(1)
expect(row.locator("td.tokens-label-cell")).to_have_text("e2e-alice") expect(row.locator("td.tokens-label-cell")).to_have_text("e2e-alice")
expect(row.locator("td").nth(3)).to_have_text("—") # Active = the plain em-dash
expect(row.locator(".stale-pill")).to_have_count(0) expect(row.locator(".stale-pill")).to_have_count(0)
expect(row.locator("button.token-revoke")).to_have_count(1) expect(row.locator("button.token-revoke")).to_have_count(1)
@@ -603,8 +603,14 @@ def test_revocation_closes_the_door(
row.locator("button.token-revoke").click() row.locator("button.token-revoke").click()
expect(row.locator(".history-confirm-yes")).to_be_visible() expect(row.locator(".history-confirm-yes")).to_be_visible()
row.locator(".history-confirm-yes").click() row.locator(".history-confirm-yes").click()
revoked = page.locator("#tokens-tbody tr", has_text="e2e-revoke") # Phase 101 (D1): the row LEAVES the active table and lands in
expect(revoked.locator(".stale-pill")).to_have_text("Revoked", timeout=15_000) # the revoked table below with its Revoked date cell (the
# em-dash / .stale-pill column is gone from both tables — the
# table's position IS the status).
revoked = page.locator("#tokens-revoked-tbody tr", has_text="e2e-revoke")
expect(revoked).to_have_count(1, timeout=15_000)
expect(revoked.locator(".stale-pill")).to_have_count(0)
expect(revoked.locator("td.tokens-date-cell").last).not_to_be_empty()
expect(page.locator("#tokens-status")).to_have_text('Revoked "e2e-revoke".') expect(page.locator("#tokens-status")).to_have_text('Revoked "e2e-revoke".')
# Enforcement is IMMEDIATE on the holder's next request (the # Enforcement is IMMEDIATE on the holder's next request (the
+22 -18
View File
@@ -60,9 +60,12 @@ computed-style assertion):
out-of-generation saved chat (the phase-53 seed pattern) renders out-of-generation saved chat (the phase-53 seed pattern) renders
the "Stale" pill in History — gray err-ink on gray err-bg with the the "Stale" pill in History — gray err-ink on gray err-bg with the
gray err-line border, text intact. gray err-line border, text intact.
5. ``test_revoked_pill_gray_labeled`` — screenshot 6: a token 5. ``test_revoked_table_state_labeled`` — screenshot 6: a token
generated + revoked THROUGH the Tokens UI renders the "Revoked" generated + revoked THROUGH the Tokens UI lands in the SEPARATE
pill — the same gray err-family computed colors, text intact. revoked table (phase 101 D1 re-scoped this pin: the "Revoked"
pill is gone — the table's position IS the status) with its
Revoked date cell, text intact (B5: text + position, never color
alone).
6. ``test_local_badge_gray_labeled`` — screenshot 3: a registered 6. ``test_local_badge_gray_labeled`` — screenshot 3: a registered
local-directory source renders the "Local" badge on Git sources — local-directory source renders the "Local" badge on Git sources —
gray ok-ink on gray ok-bg, text intact. gray ok-ink on gray ok-bg, text intact.
@@ -695,12 +698,14 @@ def test_stale_pill_gray_labeled(page: Page, app_url: str, db_ready: None) -> No
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 5. Tokens: the "Revoked" pill (screenshot 6) — gray err family, text # 5. Tokens: the revoked-table state (screenshot 6) — phase 101 D1
# intact (generated + revoked THROUGH the UI two-step) # replaced the "Revoked" pill: the row's very presence in the
# revoked table below + its Revoked date cell (text, never color
# alone — B5; generated + revoked THROUGH the UI two-step)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_revoked_pill_gray_labeled(page: Page, app_url: str, db_ready: None) -> None: def test_revoked_table_state_labeled(page: Page, app_url: str, db_ready: None) -> None:
page.set_default_timeout(30_000) page.set_default_timeout(30_000)
login(page, app_url, next="/") login(page, app_url, next="/")
_seed_theme(app_url, _cookies(page)) _seed_theme(app_url, _cookies(page))
@@ -714,7 +719,7 @@ def test_revoked_pill_gray_labeled(page: Page, app_url: str, db_ready: None) ->
page.click("#token-generate") page.click("#token-generate")
row = page.locator("#tokens-tbody tr", has_text="e2e-theme93") row = page.locator("#tokens-tbody tr", has_text="e2e-theme93")
expect(row).to_have_count(1, timeout=15_000) expect(row).to_have_count(1, timeout=15_000)
expect(row.locator(".stale-pill")).to_have_count(0) # Active: the em-dash expect(row.locator(".stale-pill")).to_have_count(0) # Phase 101: no status column
# Revoke through the UI two-step (Revoke → Yes — the inline # Revoke through the UI two-step (Revoke → Yes — the inline
# confirm, no native dialog). # confirm, no native dialog).
@@ -722,17 +727,16 @@ def test_revoked_pill_gray_labeled(page: Page, app_url: str, db_ready: None) ->
expect(row.locator(".history-confirm-yes")).to_be_visible() expect(row.locator(".history-confirm-yes")).to_be_visible()
row.locator(".history-confirm-yes").click() row.locator(".history-confirm-yes").click()
# Screenshot 6: the red "Revoked" pill — now gray err-ink on gray # Screenshot 6, re-scoped by phase 101 (D1): the "Revoked" pill is
# err-bg with the gray err-line border, text intact. # gone — the table IS the status. The row LEAVES the active table
revoked = page.locator("#tokens-tbody tr", has_text="e2e-theme93") # and lands in the revoked table below with its Revoked date cell
pill = revoked.locator(".stale-pill") # (text + the table's position, never color alone — B5).
expect(pill).to_have_count(1) active = page.locator("#tokens-tbody tr", has_text="e2e-theme93")
expect(pill).to_have_text("Revoked", timeout=15_000) expect(active).to_have_count(0)
_assert_gray(pill, "color", GRAY["err_ink"], label="revoked pill text") revoked = page.locator("#tokens-revoked-tbody tr", has_text="e2e-theme93")
_assert_gray(pill, "backgroundColor", GRAY["err_bg"], label="revoked pill bg") expect(revoked).to_have_count(1, timeout=15_000)
_assert_gray( expect(revoked.locator(".stale-pill")).to_have_count(0)
pill, "borderTopColor", GRAY["err_line"], label="revoked pill border" expect(revoked.locator("td.tokens-date-cell").last).not_to_be_empty()
)
expect(page.locator("#tokens-status")).to_have_text('Revoked "e2e-theme93".') expect(page.locator("#tokens-status")).to_have_text('Revoked "e2e-theme93".')
+407
View File
@@ -0,0 +1,407 @@
"""Phase 101 E2E (Playwright): the Tokens page overhaul — the owner's
sentence, pinned in a real browser.
Owner request (chat, 2026-09-12): "The tokens page should move revoked
tokens to a separate table below the active ones. Generating a token
should not be possible without giving it a name. Both active and
revoked token tables should be searchable. I should be able to
regenerate active tokens with the click of a button."
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_tokens_page.py -v --no-cov
Test → claim mapping (every clause of the owner's sentence is pinned):
1. ``test_revoked_tokens_move_to_their_own_table`` — "move revoked
tokens to a separate table below the active ones": two UI-created
tokens, one revoked through the two-step — the active table keeps
ONLY the live row (its thead is exactly four columns — the Status
column is gone, D1), and the revoked section (visible sub-heading +
the row's Revoked date cell — non-empty, later than the created
date, NO action buttons) appears BELOW it. The section ships hidden
while nothing is revoked (an empty table is noise).
2. ``test_both_tables_are_searchable`` — "both … tables should be
searchable": each table's own search input live-filters its rows by
case-insensitive label substring (no fetch) — the no-match row
reads ``No tokens match "zzz".`` and clears with the input — and
the query SURVIVES a re-show: leave via the Tuning nav, come back,
the re-render re-applies the same filter (the phase-77 contract,
D4).
3. ``test_a_token_cannot_be_generated_without_a_name`` — "generating a
token should not be possible without giving it a name": a blank or
whitespace-only name announces ``Give the token a name first.``,
re-focuses the name input, keeps the once-block hidden, and sends
NO request (the admin's own ``GET /api/tokens`` shows no new row —
D3; the ``|| "token"`` fallback is gone).
4. ``test_regenerate_rotates_the_token`` — "regenerate active tokens
with the click of a button": one Regenerate click opens the house
two-step confirm (focus on Yes); Yes rotates atomically — the
once-block re-appears with a NEW plaintext (``bor_`` + 32 hex, ≠
the original), the live region carries the D2 line, the active
table holds exactly ONE ``e2e-rot`` row (the successor — newer
created date), and the revoked table holds the ORIGINAL (same
label, Revoked date set). The rotation is real: a FRESH context
signs in with the NEW token through the real gate, and the
ORIGINAL token is REFUSED at the gate in another fresh context
(``#auth-gate-error`` role=alert — the rotation killed it
immediately, the phase-79 revocation semantics).
DB isolation: the shared e2e Postgres keeps ``api_tokens`` rows across
suites (``test_api_tokens.py`` is the sibling suite that issues
tokens with ``e2e-`` labels — its autouse cleanup deletes them too).
An autouse fixture deletes the ``e2e-``-labeled rows before each test
(never a TRUNCATE — the shared DB may hold the owner's real tokens).
Every scenario runs in its OWN fresh browser context for token users —
no cached token (localStorage) or session cookie leaks between tests.
"""
from __future__ import annotations
import re
from datetime import datetime
import pytest
from playwright.sync_api import Browser, BrowserContext, Page, expect
from sqlalchemy import text
from app.db import SessionLocal
from e2e.auth_helpers import login, login_with_token
#: The plaintext token's shape (owner-locked A4): prefix + 32 hex.
TOKEN_RE = re.compile(r"bor_[0-9a-f]{32}")
def _cleanup_e2e_tokens() -> None:
"""Delete this suite's issued tokens (deterministic re-runs).
Label-scoped on ``e2e-`` — never a TRUNCATE: the shared e2e DB is
also the dev DB and may hold the owner's real tokens.
"""
with SessionLocal() as db:
db.execute(text("DELETE FROM api_tokens WHERE label LIKE 'e2e-%'"))
db.commit()
@pytest.fixture(autouse=True)
def _e2e_tokens_clean(db_ready: None) -> None:
"""Start every test from the same token-empty state (this file and
test_api_tokens.py are the suites that issue tokens)."""
_cleanup_e2e_tokens()
def _go_tokens(page: Page, app_url: str) -> None:
"""Sign in as the admin (the real form login) straight onto the
Tokens view — the create row is revealed for the admin."""
login(page, app_url, next="/tokens.html")
expect(page.locator("#view-tokens")).to_be_visible()
expect(page.locator("#tokens-gate")).to_be_hidden() # admin: no sign-in gate
expect(page.locator("#token-create")).to_be_visible(timeout=15_000)
def _create_via_ui(page: Page, label: str) -> str:
"""Issue one token through the UI create row (name → Generate).
Returns the plaintext from the shown-once block (the A4 contract:
it exists only in the field's value) and pins the new row in the
ACTIVE table.
"""
page.fill("#token-label", label)
page.click("#token-generate")
expect(page.locator("#token-once")).to_be_visible(timeout=15_000)
token = page.input_value("#token-once-value")
assert TOKEN_RE.fullmatch(token), f"bad token shape: {token!r}"
row = page.locator("#tokens-tbody tr", has_text=label)
expect(row).to_have_count(1)
expect(page.locator("#tokens-status")).to_have_text(
"Token created — copy it now; it won't be shown again."
)
return token
def _revoke_via_ui(page: Page, label: str) -> None:
"""Revoke one active row through the house two-step (Revoke →
Yes) and pin the relocation: gone from the active table, present
in the revoked table below."""
row = page.locator("#tokens-tbody tr", has_text=label)
expect(row).to_have_count(1, timeout=15_000)
row.locator("button.token-revoke").click()
expect(row.locator(".history-confirm-yes")).to_be_visible()
row.locator(".history-confirm-yes").click()
expect(page.locator("#tokens-tbody tr", has_text=label)).to_have_count(0)
expect(page.locator("#tokens-revoked-tbody tr", has_text=label)).to_have_count(1)
expect(page.locator("#tokens-status")).to_have_text(f'Revoked "{label}".')
def _whoami(page: Page) -> dict:
"""The page's own ``/api/whoami`` read (the context's cookies)."""
return page.evaluate("() => fetch('/api/whoami').then((r) => r.json())")
# ---------------------------------------------------------------------------
# 1. Revoked tokens live in their own table below the active ones
# (D1: the table's position IS the status)
# ---------------------------------------------------------------------------
def test_revoked_tokens_move_to_their_own_table(page: Page, app_url: str, db_ready: None) -> None:
page.set_default_timeout(30_000)
_go_tokens(page, app_url)
# The revoked section SHIPS hidden while nothing is revoked
# (an empty table is noise) — and the active table is four
# columns: Label | Created | Last used | Actions (the Status
# column is gone — the all-active table needs no status).
expect(page.locator("#tokens-revoked-heading")).to_be_hidden()
expect(page.locator("#token-search-revoked")).to_be_hidden()
expect(page.locator("#tokens-revoked-wrap")).to_be_hidden()
expect(page.locator("#tokens-table thead th")).to_have_count(4)
expect(page.locator("#tokens-table thead")).not_to_have_text("Status")
_create_via_ui(page, "e2e-act")
_create_via_ui(page, "e2e-rev")
# Revoke e2e-rev through the two-step — the row LEAVES the active
# table and lands in the revoked table below.
row = page.locator("#tokens-tbody tr", has_text="e2e-rev")
expect(row).to_have_count(1, timeout=15_000)
row.locator("button.token-revoke").click()
expect(row.locator(".history-confirm-yes")).to_be_visible()
row.locator(".history-confirm-yes").click()
# The active table shows ONLY e2e-act…
expect(page.locator("#tokens-tbody tr", has_text="e2e-act")).to_have_count(1)
expect(page.locator("#tokens-tbody tr", has_text="e2e-rev")).to_have_count(0)
# …and the revoked section is visible BELOW the active table: its
# heading, its own search input, and the row with its Revoked
# date cell (non-empty, strictly after the created date) and NO
# action buttons (a dead token has nothing left to act on).
expect(page.locator("#tokens-revoked-heading")).to_be_visible(timeout=15_000)
expect(page.locator("#tokens-revoked-heading")).to_have_text("Revoked tokens")
expect(page.locator("#token-search-revoked")).to_be_visible()
expect(page.locator("#tokens-revoked-wrap")).to_be_visible()
revoked = page.locator("#tokens-revoked-tbody tr", has_text="e2e-rev")
expect(revoked).to_have_count(1)
# (tokens-date-cell covers Created / Last used / Revoked — the label
# cell has its own class — so Created is nth(0), Revoked the last.)
created_iso = revoked.locator("td.tokens-date-cell").nth(0).get_attribute("title")
revoked_cell = revoked.locator("td.tokens-date-cell").last
expect(revoked_cell).not_to_be_empty()
revoked_iso = revoked_cell.get_attribute("title")
assert created_iso and revoked_iso, (
f"missing full-ISO hover dates: {created_iso} / {revoked_iso}"
)
assert datetime.fromisoformat(revoked_iso) > datetime.fromisoformat(created_iso), (
"the Revoked date must be strictly after the created date"
)
expect(revoked.locator("button")).to_have_count(0)
expect(page.locator("#tokens-status")).to_have_text('Revoked "e2e-rev".')
# ---------------------------------------------------------------------------
# 2. Both tables are searchable (D4: client-side, per-table, the
# queries survive re-renders / re-shows)
# ---------------------------------------------------------------------------
def test_both_tables_are_searchable(page: Page, app_url: str, db_ready: None) -> None:
page.set_default_timeout(30_000)
_go_tokens(page, app_url)
_create_via_ui(page, "e2e-a1")
_create_via_ui(page, "e2e-a2")
_create_via_ui(page, "e2e-srchrev")
_revoke_via_ui(page, "e2e-srchrev")
# Both search inputs are revealed (the active one with the create
# row, the revoked one with the revoked section).
expect(page.locator("#token-search-active")).to_be_visible()
expect(page.locator("#token-search-revoked")).to_be_visible()
# Active table: type e2e-a1 → only that row visible…
page.fill("#token-search-active", "e2e-a1")
expect(page.locator("#tokens-tbody tr", has_text="e2e-a1")).to_be_visible()
expect(page.locator("#tokens-tbody tr", has_text="e2e-a2")).not_to_be_visible()
expect(page.locator("#tokens-no-match-row")).to_be_hidden()
# …type zzz → the no-match row, the distinct-from-empty-state copy…
page.fill("#token-search-active", "zzz")
expect(page.locator("#tokens-tbody tr", has_text="e2e-a1")).not_to_be_visible()
expect(page.locator("#tokens-tbody tr", has_text="e2e-a2")).not_to_be_visible()
expect(page.locator("#tokens-no-match-row")).to_be_visible()
expect(page.locator("#tokens-no-match-row")).to_have_text('No tokens match "zzz".')
# …clear the input → both rows back, no-match hidden.
page.fill("#token-search-active", "")
expect(page.locator("#tokens-tbody tr", has_text="e2e-a1")).to_be_visible()
expect(page.locator("#tokens-tbody tr", has_text="e2e-a2")).to_be_visible()
expect(page.locator("#tokens-no-match-row")).to_be_hidden()
# The same three-beat on the revoked search.
page.fill("#token-search-revoked", "zzz")
expect(page.locator("#tokens-revoked-tbody tr", has_text="e2e-srchrev")).not_to_be_visible()
expect(page.locator("#tokens-revoked-no-match-row")).to_be_visible()
expect(page.locator("#tokens-revoked-no-match-row")).to_have_text('No tokens match "zzz".')
page.fill("#token-search-revoked", "e2e-srchrev")
expect(page.locator("#tokens-revoked-tbody tr", has_text="e2e-srchrev")).to_be_visible()
expect(page.locator("#tokens-revoked-no-match-row")).to_be_hidden()
page.fill("#token-search-revoked", "")
expect(page.locator("#tokens-revoked-tbody tr", has_text="e2e-srchrev")).to_be_visible()
# The query SURVIVES a re-show: type, leave via the Tuning nav,
# come back — the phase-77 refresh re-renders the rows, and the
# filter is still applied (the visible set is unchanged).
page.fill("#token-search-active", "e2e-a1")
expect(page.locator("#tokens-tbody tr", has_text="e2e-a2")).not_to_be_visible()
page.click("#nav-tuning")
expect(page.locator("#view-tuning")).to_be_visible()
page.click("#nav-tokens")
expect(page.locator("#view-tokens")).to_be_visible(timeout=15_000)
expect(page.locator("#tokens-tbody tr", has_text="e2e-a1")).to_be_visible()
expect(page.locator("#tokens-tbody tr", has_text="e2e-a2")).not_to_be_visible()
expect(page.locator("#tokens-no-match-row")).to_be_hidden()
# ---------------------------------------------------------------------------
# 3. A nameless token cannot be generated (D3: the live-region line,
# no request, no row)
# ---------------------------------------------------------------------------
def test_a_token_cannot_be_generated_without_a_name(
page: Page, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_go_tokens(page, app_url)
def _list_count() -> int:
"""The admin's own GET /api/tokens (page.request shares the
context's signed session cookie): the server-side row count."""
r = page.request.get(app_url + "/api/tokens")
assert r.status == 200, r.text
return len(r.json()["tokens"])
# Deterministic start (the autouse cleanup): the active table is
# in its empty state and the server holds no tokens at all.
before = _list_count()
assert before == 0
expect(page.locator("#tokens-empty-row")).to_be_visible()
# BLANK name: the refusal line, the once-block stays hidden, and
# the request NEVER happened (the server-side count is
# unchanged — the old `|| "token"` fallback is gone).
expect(page.locator("#token-label")).to_have_value("")
page.click("#token-generate")
expect(page.locator("#tokens-status")).to_have_text("Give the token a name first.")
expect(page.locator("#token-once")).to_be_hidden()
assert _list_count() == before
expect(page.locator("#tokens-empty-row")).to_be_visible()
# WHITESPACE-only name: the same refusal (trimmed client-side).
page.fill("#token-label", " ")
page.click("#token-generate")
expect(page.locator("#tokens-status")).to_have_text("Give the token a name first.")
expect(page.locator("#token-once")).to_be_hidden()
assert _list_count() == before
expect(page.locator("#tokens-empty-row")).to_be_visible()
# ---------------------------------------------------------------------------
# 4. Regenerate rotates the credential end to end (D2: the atomic
# rotation — the new token signs in, the old one is refused)
# ---------------------------------------------------------------------------
def test_regenerate_rotates_the_token(
page: Page, browser: Browser, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_go_tokens(page, app_url)
original = _create_via_ui(page, "e2e-rot")
row = page.locator("#tokens-tbody tr", has_text="e2e-rot")
expect(row).to_have_count(1)
# (Created is the first date cell — the label cell has its own class.)
old_created_iso = row.locator("td.tokens-date-cell").nth(0).get_attribute("title")
assert old_created_iso, "the created cell must carry the full ISO hover date"
# ONE click opens the house two-step confirm (focus on Yes) —
# the rotation is a primary lifecycle action, Regenerate sits
# before Revoke in the Actions cell.
row.locator("button.token-regenerate").first.click()
expect(row.locator(".history-confirm-text")).to_have_text(
"Regenerate? The current token is revoked."
)
yes = row.locator(".history-confirm-yes")
expect(yes).to_be_visible()
expect(yes).to_be_focused()
yes.click()
# Wait for the full 201 sequence (load → once-block reveal →
# announce) on the live-region line: while the request is in
# flight the once-block still holds the ORIGINAL plaintext from
# the create — the D2 line lands LAST, so it is the sync point.
expect(page.locator("#tokens-status")).to_have_text(
"Regenerated \"e2e-rot\" — copy the new token now; it won't be shown again.",
timeout=15_000,
)
# The once-block re-appeared with a NEW plaintext (≠ the original,
# well-formed) — the same shown-once block, the A4 value-only
# contract.
expect(page.locator("#token-once")).to_be_visible()
new_token = page.input_value("#token-once-value")
assert TOKEN_RE.fullmatch(new_token), f"bad new token shape: {new_token!r}"
assert new_token != original, "the rotation must mint a fresh credential"
# The active table holds exactly ONE e2e-rot row — the successor
# (its created date is strictly newer than the original's)…
new_row = page.locator("#tokens-tbody tr", has_text="e2e-rot")
expect(new_row).to_have_count(1)
new_created_iso = new_row.locator("td.tokens-date-cell").nth(0).get_attribute("title")
assert new_created_iso, "the successor's created cell must carry the full ISO hover date"
assert datetime.fromisoformat(new_created_iso) > datetime.fromisoformat(old_created_iso), (
"the successor must be a NEW row (a newer created date)"
)
# …and the REVOKED table now holds the ORIGINAL (same label — the
# hand-out name persists — its created date is the old one, its
# Revoked date is set).
expect(page.locator("#tokens-revoked-heading")).to_be_visible()
rev_row = page.locator("#tokens-revoked-tbody tr", has_text="e2e-rot")
expect(rev_row).to_have_count(1)
assert (
rev_row.locator("td.tokens-date-cell").nth(0).get_attribute("title")
== old_created_iso
), "the revoked row must be the ORIGINAL (same created date)"
rev_iso = rev_row.locator("td.tokens-date-cell").last.get_attribute("title")
assert rev_iso, "the original's Revoked date must be set"
assert datetime.fromisoformat(rev_iso) >= datetime.fromisoformat(old_created_iso)
# A FRESH token-user context signs in with the NEW token through
# the real gate (the gate closes, the app is interactive) — the
# rotation minted a working credential.
ok_ctx: BrowserContext | None = None
try:
ok_ctx = browser.new_context()
ok = ok_ctx.new_page()
ok.set_default_timeout(30_000)
login_with_token(ok, app_url, new_token)
assert _whoami(ok) == {"authenticated": True, "role": "user"}
finally:
if ok_ctx is not None:
ok_ctx.close()
# …and the ORIGINAL token is REFUSED in another fresh context
# (the rotation killed it immediately — the phase-79
# revocation semantics: the role=alert line, the gate stays, the
# visitor is anonymous).
bad_ctx: BrowserContext | None = None
try:
bad_ctx = browser.new_context()
bad = bad_ctx.new_page()
bad.set_default_timeout(30_000)
login_with_token(bad, app_url, original, expect_error=True)
expect(bad.locator("#auth-gate-error")).to_have_attribute("role", "alert")
assert _whoami(bad) == {"authenticated": False, "role": "anonymous"}
finally:
if bad_ctx is not None:
bad_ctx.close()
+161 -9
View File
@@ -4,9 +4,9 @@ The admin surface for issued access tokens, driven through the real app
(TestClient keeps the cookie jar — the house ``test_auth_api`` admin-login (TestClient keeps the cookie jar — the house ``test_auth_api`` admin-login
pattern): pattern):
* anonymous → 403 ``admin only`` on all three endpoints (router-level * anonymous → 403 ``admin only`` on every route (router-level
``require_admin``; a token USER, once task 03 lands, is 403 here too — ``require_admin``; a token USER is 403 on the admin surface too —
pinned in task 03's matrix); pinned in task 03's matrix and, for the regenerate route, here);
* create → 201 with the plaintext ``token`` (the ONE wire moment it * create → 201 with the plaintext ``token`` (the ONE wire moment it
exists, A4) — and ``GET /tokens`` NEVER exposes it: no ``token`` key, exists, A4) — and ``GET /tokens`` NEVER exposes it: no ``token`` key,
no ``token_hash`` key, and the hash string itself absent from the no ``token_hash`` key, and the hash string itself absent from the
@@ -14,9 +14,16 @@ pattern):
* labels are display-only and NOT unique (two tokens, one label); * labels are display-only and NOT unique (two tokens, one label);
* blank/over-long labels → 422 (the house ``ValueError`` pattern); * blank/over-long labels → 422 (the house ``ValueError`` pattern);
* revoke → 204, idempotent (re-revoke 204, original stamp kept), the * revoke → 204, idempotent (re-revoke 204, original stamp kept), the
list shows ``revoked: true`` and the row keeps its ``last_used_at``; list shows ``revoked: true`` + the ``revoked_at`` wire timestamp and
unknown id → 404 ``token not found``; the row keeps its ``last_used_at``; unknown id → 404 ``token not
* the list is newest-first (``created_at desc``). found``;
* regenerate (phase 101, D2) → 201 with the NEW plaintext (same label,
one wire moment, A4) — the old row is revoked in the SAME transaction
(``revoked: true`` + non-null ``revoked_at`` in the follow-up list);
already-revoked id → 409 ``token already revoked``; unknown id → 404
``token not found``;
* the list items carry ``revoked_at`` (null active / ISO-8601 revoked —
D5, wire-additive) and are newest-first (``created_at desc``).
Real Postgres (``podman compose up -d db``); no LLM involved — tokens Real Postgres (``podman compose up -d db``); no LLM involved — tokens
are plain rows, so the suite is deterministic without a fake. are plain rows, so the suite is deterministic without a fake.
@@ -36,7 +43,9 @@ from fastapi.testclient import TestClient
from sqlalchemy import select, text, update from sqlalchemy import select, text, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.main import app as fastapi_app
from app.models import ApiToken from app.models import ApiToken
from tests.conftest import ADMIN_PASSWORD
TOKEN_SHAPE = re.compile(r"^bor_[0-9a-f]{32}$") TOKEN_SHAPE = re.compile(r"^bor_[0-9a-f]{32}$")
#: A fixed "already used" stamp — the row keeps it through revocation #: A fixed "already used" stamp — the row keeps it through revocation
@@ -62,9 +71,21 @@ def _create(admin_client: TestClient, label: str = "alice") -> dict:
return r.json() return r.json()
def test_anonymous_403_on_all_three(client: TestClient) -> None: def _admin_client() -> TestClient:
"""A SEPARATE client signed in as the admin — for tests that need an
admin and a token user at the same time (the shared ``client``
fixture is the token holder in those; the ``test_auth_api``
pattern)."""
admin = TestClient(fastapi_app)
r = admin.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
return admin
def test_anonymous_403_on_all_routes(client: TestClient) -> None:
"""Router-level ``require_admin``: every route is 403 for the """Router-level ``require_admin``: every route is 403 for the
unsigned-in caller (one fixed detail — no enumeration).""" unsigned-in caller (one fixed detail — no enumeration), including
the phase-101 regenerate route."""
r = client.post("/api/tokens", json={"label": "anon"}) r = client.post("/api/tokens", json={"label": "anon"})
assert r.status_code == 403 assert r.status_code == 403
assert r.json() == {"detail": "admin only"} assert r.json() == {"detail": "admin only"}
@@ -74,6 +95,9 @@ def test_anonymous_403_on_all_three(client: TestClient) -> None:
r = client.post(f"/api/tokens/{uuid.uuid4()}/revoke") r = client.post(f"/api/tokens/{uuid.uuid4()}/revoke")
assert r.status_code == 403 assert r.status_code == 403
assert r.json() == {"detail": "admin only"} assert r.json() == {"detail": "admin only"}
r = client.post(f"/api/tokens/{uuid.uuid4()}/regenerate")
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
def test_admin_create_returns_plaintext_exactly_once( def test_admin_create_returns_plaintext_exactly_once(
@@ -103,11 +127,20 @@ def test_admin_create_returns_plaintext_exactly_once(
items = r.json()["tokens"] items = r.json()["tokens"]
assert len(items) == 1 assert len(items) == 1
item = items[0] item = items[0]
assert set(item) == {"id", "label", "created_at", "last_used_at", "revoked"} # Phase 101, D5: the wire gains ``revoked_at`` — null while active.
assert set(item) == {
"id",
"label",
"created_at",
"last_used_at",
"revoked",
"revoked_at",
}
assert item["id"] == body["id"] assert item["id"] == body["id"]
assert item["label"] == "alice" assert item["label"] == "alice"
assert item["last_used_at"] is None # not used yet assert item["last_used_at"] is None # not used yet
assert item["revoked"] is False assert item["revoked"] is False
assert item["revoked_at"] is None
serialized = r.text serialized = r.text
assert body["token"] not in serialized assert body["token"] not in serialized
assert row.token_hash not in serialized assert row.token_hash not in serialized
@@ -163,6 +196,11 @@ def test_revoke_204_idempotent_and_preserves_last_used(
).scalar_one().revoked_at ).scalar_one().revoked_at
assert original_stamp is not None assert original_stamp is not None
# Phase 101, D5: the revoked row carries the revocation timestamp on
# the wire — ISO-8601, the SAME instant as the DB stamp (the
# revoked table renders it).
assert datetime.fromisoformat(item["revoked_at"]) == original_stamp
# Re-revoke: still 204, the original stamp survives (no re-stamp). # Re-revoke: still 204, the original stamp survives (no re-stamp).
assert admin_client.post(f"/api/tokens/{token_id}/revoke").status_code == 204 assert admin_client.post(f"/api/tokens/{token_id}/revoke").status_code == 204
db.expire_all() db.expire_all()
@@ -173,6 +211,120 @@ def test_revoke_204_idempotent_and_preserves_last_used(
) == USED_STAMP ) == USED_STAMP
# ---------- phase 101, task 01: the atomic rotation ----------
def test_regenerate_201_rotates_atomically(admin_client: TestClient, db: Session) -> None:
"""201 carries the NEW plaintext (same label, one wire moment, A4);
the old row is revoked in the SAME transaction — the follow-up list
shows it ``revoked: true`` with a non-null ISO ``revoked_at``, while
the new row is active (``revoked_at`` null), same label, with a
newer ``created_at``."""
body = _create(admin_client, "dave")
token_id = uuid.UUID(body["id"])
original = body["token"]
# A deterministic created_at gap (transaction timestamps can be
# coarser than the create gap — the test_list_is_newest_first
# pattern), so "newer" is a strict comparison.
old_created = datetime.now(UTC) - timedelta(hours=1)
db.execute(
update(ApiToken)
.where(ApiToken.id == token_id)
.values(created_at=old_created)
)
db.commit()
r = admin_client.post(f"/api/tokens/{token_id}/regenerate")
assert r.status_code == 201, r.text
rotated = r.json()
# The 201 shape is TokenCreated — the NEW token's one plaintext
# moment (the successor row, same label, a fresh credential).
assert set(rotated) == {"id", "label", "token", "created_at"}
assert rotated["id"] != body["id"]
assert rotated["label"] == "dave"
assert TOKEN_SHAPE.fullmatch(rotated["token"]), rotated["token"]
assert rotated["token"] != original
assert datetime.fromisoformat(rotated["created_at"]) > old_created
# The successor row stores the sha256 of the NEW plaintext only.
row = db.execute(
select(ApiToken).where(ApiToken.id == uuid.UUID(rotated["id"]))
).scalar_one()
assert row.token_hash == hashlib.sha256(rotated["token"].encode("utf-8")).hexdigest()
assert row.revoked_at is None
# The follow-up list: newest first, the old row revoked (D5 wire
# timestamp), the new row active.
items = admin_client.get("/api/tokens").json()["tokens"]
assert [i["id"] for i in items] == [rotated["id"], body["id"]]
by_id = {i["id"]: i for i in items}
old_item, new_item = by_id[body["id"]], by_id[rotated["id"]]
assert old_item["revoked"] is True
assert old_item["revoked_at"] is not None
datetime.fromisoformat(old_item["revoked_at"]) # ISO-8601
assert new_item["revoked"] is False
assert new_item["revoked_at"] is None
assert old_item["label"] == new_item["label"] == "dave"
# A4: the list never carries either plaintext or any hash string.
serialized = admin_client.get("/api/tokens").text
assert original not in serialized
assert rotated["token"] not in serialized
def test_regenerate_unknown_id_404(admin_client: TestClient) -> None:
"""One fixed message for every unknown id (the revoke endpoint's)."""
r = admin_client.post(f"/api/tokens/{uuid.uuid4()}/regenerate")
assert r.status_code == 404
assert r.json() == {"detail": "token not found"}
def test_regenerate_already_revoked_409(admin_client: TestClient, db: Session) -> None:
"""A dead token cannot be rotated: 409 ``token already revoked``,
and the row is untouched — still ONE row, the original stamp, no
successor created."""
body = _create(admin_client, "erin")
token_id = uuid.UUID(body["id"])
assert admin_client.post(f"/api/tokens/{token_id}/revoke").status_code == 204
db.expire_all()
original_stamp = db.execute(
select(ApiToken).where(ApiToken.id == token_id)
).scalar_one().revoked_at
assert original_stamp is not None
r = admin_client.post(f"/api/tokens/{token_id}/regenerate")
assert r.status_code == 409
assert r.json() == {"detail": "token already revoked"}
db.expire_all()
assert db.execute(text("SELECT count(*) FROM api_tokens")).scalar_one() == 1
row = db.execute(select(ApiToken).where(ApiToken.id == token_id)).scalar_one()
assert row.revoked_at == original_stamp
def test_regenerate_403_for_token_user(client: TestClient) -> None:
"""Router-wide gate: a token USER (signed in via /api/token-auth)
cannot rotate a token — 403 ``admin only``, and the rotation did not
happen (the shared ``client`` is the token holder; a separate client
is the admin — the ``test_auth_api`` pattern)."""
admin = _admin_client()
body = _create(admin, "frank")
assert client.post("/api/token-auth", json={"token": body["token"]}).status_code == 204
assert client.get("/api/whoami").json() == {"authenticated": True, "role": "user"}
r = client.post(f"/api/tokens/{body['id']}/regenerate")
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
# The token is untouched: one active row, the admin's list agrees.
items = admin.get("/api/tokens").json()["tokens"]
assert len(items) == 1
assert items[0]["id"] == body["id"]
assert items[0]["revoked"] is False
def test_revoke_unknown_id_404(admin_client: TestClient) -> None: def test_revoke_unknown_id_404(admin_client: TestClient) -> None:
"""One fixed message for every unknown id (no enumeration).""" """One fixed message for every unknown id (no enumeration)."""
r = admin_client.post(f"/api/tokens/{uuid.uuid4()}/revoke") r = admin_client.post(f"/api/tokens/{uuid.uuid4()}/revoke")
+446
View File
@@ -902,6 +902,452 @@ def test_tokens_view_scaffold_in_the_shell() -> None:
assert '<th scope="col"><span class="visually-hidden">Actions</span></th>' in body assert '<th scope="col"><span class="visually-hidden">Actions</span></th>' in body
# ---------- phase 101 task 02: the tokens split (active/revoked
# tables, the per-table search, the required name) ----------
def test_tokens_view_split_scaffold_in_the_shell() -> None:
"""Phase 101 task 02 (D1/D3/D4): the #view-tokens skeleton is the
SPLIT — the active table is FOUR columns (the Status ``<th>`` is
gone, the Actions header stays visually-hidden), its empty row is
``colspan=4``, and a NEW ship-hidden #tokens-no-match-row
(``colspan=4``, empty ``<td>`` — the text is JS-filled) ships in
the same tbody; the per-table search inputs (#token-search-active
between the once-block and the active table wrap, #token-search-
revoked in the revoked section) ship hidden with ``type=search``
+ aria-label + the .token-search class; the NEW revoked section
(heading + search + the four-column table with the Revoked ``<th>``
+ the ship-hidden no-match row) ships hidden and sits AFTER the
active table's wrap; and the create row's input is the REQUIRED
name (aria-label "Token name", placeholder
"e.g. alice — required")."""
html = _html()
view = html.find('<section class="view" id="view-tokens"')
assert view != -1, "the #view-tokens section must be in the shell"
main_end = html.find("</main>", view)
body = html[view:main_end]
# The active table: EXACTLY four columns — and no Status header
# anywhere in the view (D1: the table IS the status).
table_i = body.find('<table class="tokens-table" id="tokens-table">')
assert table_i != -1, "the active #tokens-table must exist"
thead = body[table_i:body.find("</thead>", table_i)]
assert thead.count('<th scope="col">') == 4, (
"the active table is four columns (Label | Created | Last used | Actions)"
)
assert '<th scope="col">Status</th>' not in body, (
"the Status column is gone from BOTH tables (D1)"
)
assert '<th scope="col">Revoked</th>' in body, (
"the revoked table carries the Revoked column"
)
# The active tbody: the empty row (colspan=4) + the NEW no-match
# row (ship-hidden, colspan=4, its <td> text JS-filled).
empty = re.search(r'<tr[^>]*id="tokens-empty-row"[^>]*hidden>.*?</tr>', body, re.S)
assert empty and 'colspan="4"' in empty.group(0), (
"the active empty row is colspan=4"
)
no_match = re.search(r'<tr[^>]*id="tokens-no-match-row"[^>]*hidden>', body)
assert no_match, "the active no-match row ships hidden"
no_match_td = re.search(
r'<tr[^>]*id="tokens-no-match-row"[^>]*hidden>\s*<td colspan="4">\s*</td>\s*</tr>',
body,
)
assert no_match_td, "the no-match row is colspan=4 with an EMPTY <td>"
# The per-table search inputs (D4): type=search, aria-labeled, the
# house .token-search class, BOTH ship hidden.
for sid, label in (
("token-search-active", "Search active tokens"),
("token-search-revoked", "Search revoked tokens"),
):
m = re.search(rf'<input[^>]*id="{sid}"[^>]*>', body, re.S)
assert m, f"missing the #{sid} search input"
tag = m.group(0)
assert 'type="search"' in tag, f"#{sid} must be a search input"
assert f'aria-label="{label}"' in tag, f"#{sid} must be labeled"
assert 'class="token-search"' in tag, f"#{sid} must carry the house class"
assert "hidden" in tag, f"#{sid} ships hidden (anonymous-safe)"
# The create row: the name is REQUIRED (D3) — the new aria-label +
# placeholder.
label_in = re.search(r'<input[^>]*id="token-label"[^>]*>', body, re.S)
assert label_in, "the create row's name input must exist"
assert 'aria-label="Token name"' in label_in.group(0)
assert 'placeholder="e.g. alice — required"' in label_in.group(0)
# The active search sits BETWEEN the once-block and the active
# table's wrap.
assert (
body.find('id="token-once"')
< body.find('id="token-search-active"')
< body.find('id="tokens-table-wrap"')
), "the active search is between the once-block and the table wrap"
# The revoked section (D1): heading + search + wrap — ALL ship
# hidden — and it sits BELOW the active table's wrap.
heading = re.search(
r'<h2[^>]*id="tokens-revoked-heading"[^>]*>Revoked tokens</h2>', body
)
assert heading and "hidden" in heading.group(0), (
"the revoked sub-heading ships hidden with its visible text"
)
assert 'class="tokens-revoked-heading"' in heading.group(0)
wrap = re.search(r'<div[^>]*id="tokens-revoked-wrap"[^>]*>', body)
assert wrap and "hidden" in wrap.group(0), ("the revoked wrap ships hidden")
assert 'role="region"' in wrap.group(0) and 'tabindex="0"' in wrap.group(0)
assert 'aria-label="Revoked tokens"' in wrap.group(0)
assert 'id="tokens-revoked-tbody"' in body
assert "Revoked tokens — newest first" in body, "the revoked table caption"
assert re.search(
r'<tr[^>]*id="tokens-revoked-no-match-row"[^>]*hidden>', body
), "the revoked no-match row ships hidden"
assert body.find('id="tokens-table-wrap"') < body.find(
'id="tokens-revoked-heading"'
), "the revoked section sits BELOW the active table's wrap"
def test_tokens_js_split_search_and_required_name() -> None:
"""Phase 101 task 02 (D1/D3/D4) in tokens.js: the module state
carries the persistent per-table queries (initialized ""), the
scoped lookups cover the new ids, ``loadTokens`` SPLITS the
fetched list by ``tok.revoked`` (the server order kept per table),
shows the active empty row iff zero ACTIVE rows, shows the revoked
section (heading + search + wrap) iff ≥1 revoked row via the
``setRevokedSectionVisible`` helper, and RE-APPLIES both filters
after every render (a load never loses the queries); ``makeRow``
takes the table (the revoked variant renders the revoked_at date,
no actions); ``applyFilter`` is pure DOM (case-insensitive label
substring over the data rows — the state rows excluded — the
no-match copy quotes the ORIGINAL query in textContent); the input
listeners are armed in the ADMIN branch (after the whoami gate) and
set the module query + applyFilter with NO fetch; and a blank name
is refused client-side — the exact announce line, the re-focus, the
early return BEFORE any fetch, and the deleted "token" fallback."""
js = _asset("tokens.js")
# Module state: the persistent queries, initialized once.
assert re.search(r"let activeQuery = \"\";", js), (
"the active search query is module state (initialized '')"
)
assert re.search(r"let revokedQuery = \"\";", js), (
"the revoked search query is module state (initialized '')"
)
# The new scoped lookups (the phase-76 root-scoping contract).
for sid in (
"tokens-no-match-row",
"token-search-active",
"tokens-revoked-heading",
"token-search-revoked",
"tokens-revoked-wrap",
"tokens-revoked-tbody",
"tokens-revoked-no-match-row",
):
assert f'querySelector("#{sid}")' in js, f"missing the #{sid} lookup"
# makeRow: the table parameter + the revoked variant (the
# revoked_at date cell, no actions).
assert "function makeRow(tok, table)" in js, "makeRow takes the table"
assert 'table === "revoked"' in js, "the revoked variant branches on the table"
assert "revoked_at" in js, "the Revoked cell renders tok.revoked_at"
# loadTokens: the split (per-table appends, server order kept),
# the section helper call, the BOTH filters re-applied after the
# render.
load = js.find("async function loadTokens()")
assert load != -1, "loadTokens must exist"
load_body = js[load:js.find("\n }", load)]
assert 'makeRow(tok, "active")' in load_body, "active rows render into the active table"
assert 'makeRow(tok, "revoked")' in load_body, "revoked rows render into the revoked table"
split_i = load_body.find(".revoked")
assert split_i != -1, "the split keys off tok.revoked (the D5 bool)"
assert "setRevokedSectionVisible(revoked.length)" in load_body, (
"the revoked section shows iff ≥1 revoked row"
)
fetch_i = load_body.find('fetch("/api/tokens")')
active_apply = load_body.find("applyFilter(tbody, noMatchRow, activeQuery)")
revoked_apply = load_body.find(
"applyFilter(revokedTbody, revokedNoMatchRow, revokedQuery)"
)
assert 0 <= fetch_i < active_apply < revoked_apply, (
"BOTH filters re-apply after the fetch + render (D4: a re-render "
"never loses the queries)"
)
# The section show/hide helper: heading + search + wrap together.
helper = js.find("function setRevokedSectionVisible")
assert helper != -1, "the setRevokedSectionVisible(n) helper must exist"
helper_body = js[helper:js.find("\n }", helper)]
for name in ("revokedHeading.hidden", "searchRevoked.hidden", "revokedWrap.hidden"):
assert name in helper_body, f"the section helper must toggle {name}"
# applyFilter: pure DOM — case-insensitive label substring over
# the data rows (the no-match/empty state rows excluded), the
# no-match row visible ⟺ non-empty query + zero visible rows, its
# <td> textContent carries the ORIGINAL query in quotes.
f = js.find("function applyFilter(")
assert f != -1, "applyFilter must exist"
f_body = js[f:js.find("\n }", f)]
assert ".toLowerCase()" in f_body, "the match is case-insensitive"
assert "tr === targetNoMatchRow" in f_body, (
"the no-match row is never treated as a data row"
)
assert "emptyRow" in f_body, "the empty-state row is not a data row"
assert ".tokens-label-cell" in f_body, (
"the label cell is the filter's data source"
)
assert 'No tokens match "${' in f_body, (
"the no-match copy (the user's original query in quotes, textContent)"
)
assert "innerHTML" not in f_body, "applyFilter is textContent-only"
# The input listeners: armed in the ADMIN branch only (after the
# whoami gate), they set the module query + applyFilter — NO
# fetch (D4 is client-side).
gate_i = js.find("if (!(await fetchIsAdmin()))")
assert gate_i != -1
for i, var in (
(js.find('searchActive.addEventListener("input"'), "activeQuery"),
(js.find('searchRevoked.addEventListener("input"'), "revokedQuery"),
):
assert i != -1, f"the {var} input listener must be armed"
assert gate_i < i, f"the {var} listener is armed in the ADMIN branch (after the gate)"
seg = js[i:js.find("});", i)]
assert f"{var} =" in seg, f"the listener writes the {var} module state"
assert "fetch(" not in seg, "the search is client-side (no fetch)"
branch = js[gate_i:js.find("return;", gate_i)]
assert "searchActive.hidden = true" in branch, (
"the anonymous branch keeps the active search hidden"
)
assert "searchActive.hidden = false" in js, (
"the admin branch reveals the active search (with the create row)"
)
# The required name (D3): the exact line + re-focus + the early
# return BEFORE any fetch; the old fallback is GONE from the file.
gen = js.find("async function generateToken()")
assert gen != -1, "generateToken must exist"
gen_body = js[gen:]
check_i = gen_body.find("if (!label)")
fetch_i = gen_body.find('fetch("/api/tokens"')
assert 0 <= check_i < fetch_i, (
"the blank-name check runs BEFORE any request (D3: the request "
"simply doesn't happen)"
)
assert 'announce("Give the token a name first.")' in gen_body, (
"the exact D3 live-region line"
)
assert "labelInput.focus()" in gen_body, "the name input re-focuses"
assert '|| "token"' not in js, (
"the old blank-label 'token' fallback is DELETED (D3)"
)
def test_tokens_split_css_pins() -> None:
"""Phase 101 task 02: styles.css carries the .token-search surface
(full width, the ≥44px target, the house input family — --line
hairline, --surface fill, ink text — no new hue) and the
.tokens-revoked-heading sub-heading (the phase-97 .kb-level h2
voice: mono, 1rem, brand-ink); the empty/no-match rows' styling
stays CLASS-based (.tokens-empty-row — both tables covered)."""
css = _asset("styles.css")
block = re.search(r"\.token-search \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .token-search"
body = block.group(1)
assert "width: 100%" in body, "the search input is full width"
assert "min-height: 44px" in body, "the ≥44px touch target"
assert "border: 1px solid var(--line)" in body, "the house input hairline"
assert "background: var(--surface)" in body, "the house input surface"
heading = re.search(r"\.tokens-revoked-heading \{([\s\S]*?)\n\}", css)
assert heading, "styles.css must style .tokens-revoked-heading"
hbody = heading.group(1)
assert "font-family: var(--mono)" in hbody, "the .kb-level h2 voice (mono)"
assert "font-size: 1rem" in hbody
assert "color: var(--brand-ink)" in hbody, "brand-ink — AA on the page background"
assert re.search(r"\.tokens-empty-row td \{", css), (
"the empty/no-match rows' styling is CLASS-based (both tables)"
)
# ---------- phase 101 task 03: the Regenerate control (rotation) ----------
def test_tokens_js_regenerate_control() -> None:
"""Phase 101 task 03 (D2) in tokens.js: the active row's Actions
cell appends the Regenerate control BEFORE the Revoke control, and
EACH control owns its OWN .tokens-actions wrapper span (a confirm
in one never clobbers the other — the shared cell hosts two
independent confirm scopes); makeRegenerateControl is a structural
mirror of makeRevokeControl — the .token-regenerate button (label
"Regenerate", aria-label "Regenerate token: <label>"), the first
click swaps the cell to the confirm pair (the EXACT text
"Regenerate? The current token is revoked.", the
history-confirm-yes/no classes, focus to Yes), No / a failure
restore via restoreRegenerate (the button back, focus restored);
confirmRegenerate disables the Yes button while in flight, POSTs
/api/tokens/<id>/regenerate (no body), and on 201 runs
loadTokens() FIRST, THEN reveals the once-block (the value-only
contract), THEN announces the D2 line; a 404 removes the row +
re-fetches + the house 404 line; a 409 re-fetches + the same
line; any other failure / network error announces the neutral
copy and restores (retryable)."""
js = _asset("tokens.js")
# The Actions cell order: Regenerate FIRST (the primary lifecycle
# action), both controls appended in the active variant.
row_i = js.find("function makeRow(tok, table)")
assert row_i != -1, "makeRow must exist"
row_body = js[row_i:js.find("\n }", row_i)]
assert "actionsTd.append(" in row_body, (
"the Actions cell hosts BOTH controls"
)
regen_i = row_body.find("makeRegenerateControl(tok, tr)")
revoke_i = row_body.find("makeRevokeControl(tok, tr)")
assert 0 <= regen_i < revoke_i, (
"the active Actions cell appends Regenerate BEFORE Revoke (D2)"
)
# The per-control wrapper spans: EACH control owns its OWN
# .tokens-actions span (the two swap-scopes are independent).
mk_i = js.find("function makeRegenerateControl(")
assert mk_i != -1, "makeRegenerateControl must exist"
mk_body = js[mk_i:js.find("\n }", mk_i)]
assert 'cell.className = "tokens-actions"' in mk_body, (
"the Regenerate control owns its OWN .tokens-actions wrapper span"
)
mkr_i = js.find("function makeRevokeControl(")
assert mkr_i != -1, "makeRevokeControl must exist"
mkr_body = js[mkr_i:js.find("\n }", mkr_i)]
assert 'cell.className = "tokens-actions"' in mkr_body, (
"the Revoke control keeps its OWN .tokens-actions wrapper span"
)
# The button: the .token-regenerate class, the label, the
# aria-label (the row buttons carry their own aria-labels — the
# house convention).
assert 'regenBtn.className = "token-regenerate"' in mk_body, (
"the button carries the .token-regenerate class"
)
assert 'regenBtn.textContent = "Regenerate"' in mk_body, (
"the button is labeled Regenerate"
)
assert "Regenerate token: ${tok.label}" in mk_body, (
"the aria-label names the token (Regenerate token: <label>)"
)
# The two-step swap: the EXACT confirm text, the history-confirm-*
# pair, focus to Yes.
assert (
'label.textContent = "Regenerate? The current token is revoked."'
in mk_body
), "the confirm text is EXACT (the D2 copy)"
assert 'label.className = "history-confirm-text"' in mk_body
assert 'yes.className = "history-confirm-yes"' in mk_body, (
"the Yes button reuses the house confirm class"
)
assert 'no.className = "history-confirm-no"' in mk_body, (
"the No button reuses the house confirm class"
)
assert "yes.focus()" in mk_body, "focus moves to Yes (keyboard confirm)"
# The restore path: No and a failure bring the button back, focus
# restored (the revoke control's restore pattern, copied).
assert 'no.addEventListener("click", restoreRegenerate)' in mk_body, (
"No restores the Regenerate button"
)
assert "cell.replaceChildren(regenBtn)" in mk_body, (
"the restore swaps the cell back to the button"
)
assert "regenBtn.focus()" in mk_body, "the restore returns the focus"
# confirmRegenerate: the disabled-while-in-flight Yes, the POST
# path (JSON, NO body — the revoke control's request shape).
cr_i = js.find("async function confirmRegenerate(")
assert cr_i != -1, "confirmRegenerate must exist"
body = js[cr_i:js.find("\n }", cr_i)]
dis_i = body.find("yesBtn.disabled = true")
fetch_i = body.find("/regenerate")
assert 0 <= dis_i < fetch_i, (
"the Yes button disables BEFORE the request (no double-fire)"
)
assert (
'fetch(`/api/tokens/${tok.id}/regenerate`, { method: "POST" })'
in body
), "the POST path is /api/tokens/<id>/regenerate with NO body"
# The 201 sequence (pinned order): the JSON parse, the re-entrant
# loadTokens() FIRST (the relocation), the once-block reveal
# (value only — the A4 contract), THEN the D2 live-region line.
json_i = body.find("const data = await r.json()")
load_i = body.find("await loadTokens()", json_i)
reveal_i = body.find("onceValue.value = data.token", json_i)
show_i = body.find("onceBlock.hidden = false", json_i)
ann_i = body.find('Regenerated "${tok.label}"', json_i)
assert 0 <= json_i < load_i < reveal_i < show_i < ann_i, (
"on 201 the load runs FIRST, then the once-block reveal, then "
"the D2 line (D2's pinned sequence)"
)
assert (
'Regenerated "${tok.label}" — copy the new token now; '
"it won't be shown again."
) in body, "the D2 live-region line is EXACT"
# The 404: the row vanished — row.remove() + the re-fetch
# (reconciliation) + the house 404 line (the revoke control's
# existing copy — one house message for the one common case).
i404 = body.find("r.status === 404")
i409 = body.find("r.status === 409")
iok = body.find("if (!r.ok)")
assert 0 <= i404 < i409 < iok, (
"the 404/409 branches precede the generic failure"
)
seg404 = body[i404:i409]
assert "row.remove()" in seg404, "the 404 removes the vanished row"
assert "await loadTokens()" in seg404, (
"the 404 re-fetches (the reconciliation)"
)
assert 'announce("That token was already revoked.")' in seg404, (
"the 404 reuses the house line"
)
seg409 = body[i409:iok]
assert "await loadTokens()" in seg409, (
"the 409 re-fetches (the row was revoked between render and click)"
)
assert 'announce("That token was already revoked.")' in seg409, (
"the 409 lands the SAME house line"
)
# The retryable failures: the neutral two-line house copy + the
# restore (the button back) — network and non-2xx alike.
assert (
'announce(`Couldn\'t regenerate "${tok.label}" — is the app reachable?`)' in body
), "the network line is the house two-line convention"
assert (
'announce(`Couldn\'t regenerate "${tok.label}" — try again.`)' in body
), "the non-2xx line is the neutral retry copy"
assert body.count("restoreRegenerate();") == 2, (
"BOTH the network and the non-2xx failures restore the button (retryable)"
)
assert "innerHTML" not in body, "textContent only (XSS-safe by construction)"
def test_tokens_regenerate_css_pins() -> None:
"""Phase 101 task 03: styles.css carries the .token-regenerate
button — the .token-revoke's structural twin (the same ≥44px
target, --line hairline, radius, transparent fill, ink-soft text)
with the NEUTRAL action's hover (brand-soft / brand-ink — NOT the
revoke's error hover: a rotation is a hand-out, not a deletion) and
a :disabled rule consistent with the revoke button's (the dimmed
in-flight state) — no new hue (the phase-92 monochrome invariant);
the confirm pair reuses the existing .history-confirm-* rules
unchanged."""
css = _asset("styles.css")
block = re.search(r"\.token-regenerate \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .token-regenerate"
body = block.group(1)
assert "min-height: 44px" in body, (
"the ≥44px touch target (the .token-revoke twin)"
)
assert "border: 1px solid var(--line)" in body, "the house ghost hairline"
assert "border-radius: var(--radius-sm)" in body, "the house radius"
assert "background: transparent" in body, "the transparent fill"
assert "color: var(--ink-soft)" in body, "the ghost text (ink-soft)"
assert "var(--err-" not in body, "no new hue — the monochrome invariant"
hover = re.search(r"\.token-regenerate:hover:not\(:disabled\) \{([\s\S]*?)\}", css)
assert hover, "the neutral hover rule (gated like the revoke's)"
hbody = hover.group(1)
assert "var(--brand-soft)" in hbody, "the hover fill is the NEUTRAL brand-soft"
assert "var(--brand-ink)" in hbody, "the hover text is brand-ink"
assert "var(--err-" not in hbody, (
"the hover is NOT the revoke's error hover (rotation ≠ deletion)"
)
disabled = re.search(r"\.token-regenerate:disabled \{([\s\S]*?)\}", css)
assert disabled, "the :disabled state (the in-flight look)"
dbody = disabled.group(1)
assert "opacity: 0.5" in dbody and "cursor: wait" in dbody, (
"consistent with the revoke button's disabled rule"
)
# ---------- phase 91 task 04: the Theme view (skeleton) ---------- # ---------- phase 91 task 04: the Theme view (skeleton) ----------
+106 -1
View File
@@ -14,7 +14,14 @@ admin API (task 02's endpoints) and the future token-auth login (task
which is what makes the task-03 one-generic-401 safe); which is what makes the task-03 one-generic-401 safe);
* ``revoke`` — stamps ``revoked_at`` once (idempotent re-call keeps the * ``revoke`` — stamps ``revoked_at`` once (idempotent re-call keeps the
original stamp; False only for a missing id); original stamp; False only for a missing id);
* ``mark_used`` — bumps ``last_used_at``. * ``mark_used`` — bumps ``last_used_at``;
* ``regenerate_token`` (phase 101) — the atomic rotation matrix: an
active row is revoked (first stamp) and its successor (same label,
fresh well-formed plaintext, hash-only row) takes its place — the new
plaintext round-trips, the old one no longer authenticates; a missing
id returns ``None``; an already-revoked id raises
``TokenAlreadyRevoked``; and the service NEVER commits — the caller's
rollback undoes both writes.
House DB-test pattern (the ``test_sources_meta`` precedent): the service House DB-test pattern (the ``test_sources_meta`` precedent): the service
is a thin session wrapper whose contract (server-default ``created_at``, is a thin session wrapper whose contract (server-default ``created_at``,
@@ -172,3 +179,101 @@ def test_mark_used_stamps_last_used_at(db: Session) -> None:
seconds=1 seconds=1
) )
db.commit() db.commit()
# ---------- phase 101, task 01: the atomic rotation ----------
def test_regenerate_token_rotates_an_active_row(db: Session) -> None:
"""Active row → a NEW row (different id) with the SAME label + a
well-formed new plaintext (``bor_`` + 32 hex) whose sha256 IS the
stored hash; the OLD row is stamped with its first ``revoked_at``;
the new plaintext round-trips through ``find_active_by_token`` and
the old plaintext no longer authenticates (dead is dead)."""
old_row, old_plain = _create_and_commit(db, "alice")
before = datetime.now(UTC)
rotated = tok.regenerate_token(db, old_row.id)
assert rotated is not None
new_row, new_plain = rotated
db.commit() # the caller owns the commit (the endpoint's job)
db.refresh(old_row)
db.refresh(new_row)
# The successor: a different id, the SAME hand-out label, a fresh
# well-formed plaintext (never a reuse of the old one).
assert new_row.id != old_row.id
assert new_row.label == old_row.label == "alice"
assert TOKEN_SHAPE.fullmatch(new_plain), new_plain
assert new_plain != old_plain
# The successor row stores ONLY the sha256 of the NEW plaintext.
assert new_row.token_hash == tok.hash_token(new_plain)
assert new_row.token_hash != tok.hash_token(old_plain)
assert new_row.last_used_at is None
assert new_row.revoked_at is None
# The old row carries its first (and only) revocation stamp.
assert old_row.revoked_at is not None
assert old_row.revoked_at.tzinfo is not None
assert before - timedelta(seconds=1) <= old_row.revoked_at <= (
datetime.now(UTC) + timedelta(seconds=1)
)
# The rotation takes effect immediately, both directions.
hit = tok.find_active_by_token(db, new_plain)
assert hit is not None
assert hit.id == new_row.id
assert tok.find_active_by_token(db, old_plain) is None
def test_regenerate_token_missing_id_returns_none(db: Session) -> None:
"""Unknown id → ``None`` (the endpoint maps it to 404) — nothing is
created, no stamp anywhere."""
_create_and_commit(db) # the table is NOT empty — the miss is by id
assert tok.regenerate_token(db, uuid.uuid4()) is None
db.rollback()
assert db.execute(text("SELECT count(*) FROM api_tokens")).scalar_one() == 1
def test_regenerate_token_already_revoked_raises(db: Session) -> None:
"""A dead token cannot be rotated: an already-revoked id raises
``TokenAlreadyRevoked`` (the endpoint's 409) and creates NOTHING —
no successor row, the original stamp untouched."""
row, _ = _create_and_commit(db)
assert tok.revoke(db, row.id) is True
db.commit()
db.refresh(row)
original_stamp = row.revoked_at
assert original_stamp is not None
with pytest.raises(tok.TokenAlreadyRevoked):
tok.regenerate_token(db, row.id)
db.rollback()
assert db.execute(text("SELECT count(*) FROM api_tokens")).scalar_one() == 1
db.refresh(row)
assert row.revoked_at == original_stamp
def test_regenerate_token_service_never_commits(db: Session) -> None:
"""Atomicity + the house commit split: the service flushes, never
commits — the caller's rollback AFTER the rotation undoes BOTH
writes (the stamp AND the successor), so the old token stays live."""
old_row, old_plain = _create_and_commit(db, "carol")
rotated = tok.regenerate_token(db, old_row.id)
assert rotated is not None
new_row, _ = rotated
db.rollback() # the caller refuses the rotation
db.expire_all()
survivor = db.get(ApiToken, old_row.id)
assert survivor is not None
assert survivor.revoked_at is None # the stamp rolled back
assert db.get(ApiToken, new_row.id) is None # the successor rolled back
# The old credential is alive again — the rotation never happened.
hit = tok.find_active_by_token(db, old_plain)
assert hit is not None
assert hit.id == old_row.id
db.commit()