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
+118
View File
@@ -0,0 +1,118 @@
"""API-token service (phase 79, task 02).
Generates, stores, looks up and revokes the admin-issued access tokens
(``api_tokens`` rows — phase 79, task 01). The trust model (owner-locked
A4): the plaintext token (``bor_`` + 32 hex chars) exists only in the 201
response of the create call, returned **exactly once**; the row carries
the SHA-256 hex digest of the **full** token string and nothing else.
Lookup security — by hash, not by compare:
:func:`find_active_by_token` hashes the presented token and performs one
``token_hash ==`` lookup — a unique-index hit (``ix_api_tokens_token_hash``).
SHA-256's pre-image resistance means there is **no token-enumeration or
timing surface beyond the DB lookup itself**: an attacker holding the table
cannot turn a stored hash back into a working token, and a wrong candidate
simply misses the index. This is the deliberate contrast with
:func:`app.core.auth.check_password`'s constant-time compare — there IS
nothing to compare in constant time here, only to *look up*; replicating a
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.
"""
from __future__ import annotations
import hashlib
import secrets
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import ApiToken
#: The plaintext token's shape: fixed prefix + 32 hex chars
#: (``secrets.token_hex(16)`` — 128 bits of entropy).
TOKEN_PREFIX = "bor_"
def generate_token() -> str:
"""One fresh plaintext token: ``bor_`` + 32 hex chars (128-bit).
The prefix is a human/parse marker only — the HASH covers the full
string, so the prefix is never the secret.
"""
return TOKEN_PREFIX + secrets.token_hex(16)
def hash_token(token: str) -> str:
"""The stored credential: the SHA-256 hex digest of the **full** token.
Hashing the full string (not the suffix) means a stripped prefix can
never collide with another token's hash. Deterministic — the same
token always yields the same 64-hex digest (the unique-index key).
"""
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def create_token(db: Session, label: str) -> tuple[ApiToken, str]:
"""Create one named token; return the row + the plaintext ONCE.
``label`` is stripped before storing; non-emptiness is the API
layer's job (the 422 boundary — the service trusts it, A4). The row
only ever carries the hash (``token_hash``); the returned ``str`` is
the one and only moment the plaintext exists outside this function
(the endpoint ships it in the 201 body).
Flushes, does not commit — the caller commits (so the row + its
server-default ``created_at`` are durable only when the endpoint's
response can be built).
"""
plaintext = generate_token()
row = ApiToken(label=label.strip(), token_hash=hash_token(plaintext))
db.add(row)
db.flush()
return row, plaintext
def find_active_by_token(db: Session, token: str) -> ApiToken | None:
"""Resolve a presented plaintext token to its ACTIVE row, or ``None``.
Hash → ``token_hash ==`` lookup → ``revoked_at IS NULL``. ANY other
shape is a miss — there is no "almost" path: the hash of a malformed
string (wrong prefix, truncated, empty, …) simply matches no row,
so every failure mode returns the same ``None`` (the caller maps
that to one generic 401 — no enumeration, A4's auth-error contract).
"""
row = (
db.execute(select(ApiToken).where(ApiToken.token_hash == hash_token(token)))
.scalars()
.first()
)
if row is None or row.revoked_at is not None:
return None
return row
def mark_used(tok: ApiToken) -> None:
"""Bump ``last_used_at`` to now (UTC) — the caller commits."""
tok.last_used_at = datetime.now(UTC)
def revoke(db: Session, token_id: uuid.UUID) -> bool:
"""Stamp ``revoked_at`` (UTC now); return False when the row is missing.
Idempotent: an already-revoked row keeps its ORIGINAL stamp (the
revocation time is the first one, never re-stamped on a second
call) and the call still returns True — the row exists and is dead.
Returns False only for a missing id (the endpoint maps that to
404 ``token not found``). Flushes, does not commit.
"""
row = db.get(ApiToken, token_id)
if row is None:
return False
if row.revoked_at is None:
row.revoked_at = datetime.now(UTC)
return True