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
+37 -1
View File
@@ -15,7 +15,10 @@ Routes (all under ``/api`` via the ``main`` registration):
that carries the plaintext ``token``), ``GET /api/tokens``
(``TokenList`` — newest first, secret-free), ``POST
/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
@@ -87,6 +90,7 @@ def list_tokens(
created_at=row.created_at,
last_used_at=row.last_used_at,
revoked=row.revoked_at is not None,
revoked_at=row.revoked_at, # phase 101 D5: null while active
)
for row in rows
]
@@ -110,3 +114,35 @@ def revoke_token(
raise HTTPException(status_code=404, detail="token not found")
db.commit()
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
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
@@ -116,3 +125,36 @@ def revoke(db: Session, token_id: uuid.UUID) -> bool:
if row.revoked_at is None:
row.revoked_at = datetime.now(UTC)
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
``revoked_at is not None`` (the UI renders the Active/Revoked state
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
@@ -937,6 +942,7 @@ class TokenListItem(BaseModel):
created_at: datetime
last_used_at: datetime | None
revoked: bool
revoked_at: datetime | None = None
class TokenList(BaseModel):