feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 13s

Single consolidated commit for four completed, validated phases (77, 78,
79, 80). The pipeline run left all work uncommitted because the harness
commits only with PHASE_COMMIT=1 while child executors are forbidden from
committing; the phases themselves all passed validation and moved to
.agents/phases/complete/.

Phase 77 — navbar view refresh
- router.js dispatches bor:view-refresh on re-show / active re-click /
  popstate (gated on wasMounted; first show and boot exempt)
- History / RAG / Sources / Tuning re-fetch on refresh (admin branch);
  Chat deliberately excluded (stream survival)
- History "Refresh" button (admin-only, in-flight disable + status line)
- New story suite tests/e2e/test_navbar_refresh.py (7 tests)

Phase 78 — static background
- Removed the animated glow layers; static 44px grid over the flat --bg
  canvas; default and reduced-motion renders byte-identical
- Updated background/theme E2E suites; removed bg-glow test pins

Phase 79 — API tokens
- api_tokens model + migration 0012; hash-only token service
- Admin tokens API + Tokens admin view; POST /api/token-auth;
  live-revoking require_user on chat / suggestions / document content
- Frontend token gate with localStorage cache; anonymous E2E suites
  migrated to token login
- New story suite tests/e2e/test_api_tokens.py (9 tests)

Phase 80 — history suggestion chips
- last_questions() endpoint with SEED fallback; startNewChat() refetch
- Seed-semantics docs (config.py, .env.example, README)
- Integration state matrix + E2E suite rewritten to the 4 chip states

Also included: phase-76 report artifacts and the repo restore-test-db
skill (previously untracked), scripts/* ruff fixes from phase 77.

Final gate state (phase 80 final pass, covers everything above):
- uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99%
- uv run ruff check . && uv run pyright → clean, 0 errors
- Per-phase story E2E suites green in isolation
This commit is contained in:
2026-09-07 12:39:01 -04:00
parent 495d042a98
commit 7fce6572d0
215 changed files with 10142 additions and 1643 deletions
+112
View File
@@ -0,0 +1,112 @@
"""Tokens admin API (phase 79, task 02).
The admin surface for the issued access tokens (owner-locked A4):
generate a named token (the plaintext is shown **exactly once**, in the
201 body), list tokens (display fields only — no plaintext, no hashes),
and revoke one (idempotent). The whole router sits behind
:func:`app.core.auth.require_admin` (router-wide ``dependencies`` — the
:mod:`app.api.doc_drafts` pattern): tokens are admin-only, so anonymous
callers get 403 on every route — and once the token-auth login lands
(task 03), a token *user* stays 403 here too (only the admin manages
tokens).
Routes (all under ``/api`` via the ``main`` registration):
``POST /api/tokens`` (201 ``TokenCreated`` — the ONE response shape
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``).
"""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core import tokens as token_service
from app.core.auth import require_admin
from app.db import get_db
from app.models import ApiToken
from app.schemas import TokenCreated, TokenCreateRequest, TokenList, TokenListItem
router = APIRouter(
prefix="/tokens",
tags=["tokens"],
dependencies=[Depends(require_admin)], # phase 79: the token admin surface is admin-only
)
@router.post("", response_model=TokenCreated, status_code=201)
def create_token(
payload: TokenCreateRequest,
db: Session = Depends(get_db), # noqa: B008
) -> TokenCreated:
"""Generate one named token (201).
The ``token`` field of this response is the ONE AND ONLY moment the
plaintext exists on the wire (A4): the row stores the SHA-256 hash
of the full token string, and no other response shape — in
particular the list — ever carries it. ``label`` is the hand-out
name, display-only and NOT unique (two tokens may share a label).
Blank/over-long labels are a 422 from the schema (the house
``ValueError`` pattern — fail loud at the boundary).
"""
row, plaintext = token_service.create_token(db, payload.label)
db.commit() # the service flushes; the endpoint owns the commit
db.refresh(row) # pulls the server-default created_at
return TokenCreated(
id=row.id, label=row.label, token=plaintext, created_at=row.created_at
)
@router.get("", response_model=TokenList)
def list_tokens(
db: Session = Depends(get_db), # noqa: B008
) -> TokenList:
"""All tokens, newest first (``created_at desc, id desc`` tiebreak).
Secret-free by construction: :class:`~app.schemas.TokenListItem` has
no ``token`` and no ``token_hash`` field — the list never carries a
credential in either form. ``revoked`` is derived from
``revoked_at is not None``; ``last_used_at`` stays null until the
token is first used (task 03 stamps it on ``POST /api/token-auth``)
"""
rows = (
db.execute(
select(ApiToken).order_by(ApiToken.created_at.desc(), ApiToken.id.desc())
)
.scalars()
.all()
)
items = [
TokenListItem(
id=row.id,
label=row.label,
created_at=row.created_at,
last_used_at=row.last_used_at,
revoked=row.revoked_at is not None,
)
for row in rows
]
return TokenList(tokens=items)
@router.post("/{token_id}/revoke", status_code=204)
def revoke_token(
token_id: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008
) -> Response:
"""Revoke one token (204) — idempotent.
Already-revoked → still 204 with NO re-stamp (the original
``revoked_at`` — the revocation time — is preserved; the service
only stamps when unset). Unknown id → 404 ``token not found`` (one
message for every unknown id). Revocation takes effect immediately:
the holder's next request is refused (the task-03 live check).
"""
if not token_service.revoke(db, token_id):
raise HTTPException(status_code=404, detail="token not found")
db.commit()
return Response(status_code=204)