phase: 101_tokens_page_overhaul
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:
+37
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user