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
+88 -21
View File
@@ -1,24 +1,42 @@
"""Auth API — single-admin sign-in (phase 16; A10 revised 2026-08-22).
"""Auth API — admin sign-in (phase 16) + token login (phase 79).
* ``POST /api/login`` — 204 + signed session cookie on success; 401
* ``POST /api/login`` — 204 + signed session cookie on success; 401
``invalid password`` on any mismatch (constant-time, one generic
message, no session set).
* ``POST /api/logout`` — 204; clears the session and expires the cookie
(idempotent for anonymous callers).
* ``GET /api/whoami`` — ``{"authenticated": bool, "role":
"admin"|"anonymous"}``; the single source of truth for all UI gating.
* ``POST /api/token-auth`` — 204 + signed session cookie for a valid,
unrevoked API token (PUBLIC — it is the token holders' login route);
every failure shape (malformed / unknown / revoked / empty) is ONE
generic 401 ``invalid token`` (no enumeration, the phase-16 pattern).
* ``POST /api/logout`` — 204; clears the session and expires the
cookie (idempotent for anonymous callers — one logout wipes BOTH
roles, the session is one dict).
* ``GET /api/whoami`` — ``{"authenticated": bool, "role":
"admin"|"user"|"anonymous"}``; the single source of truth for all UI
gating (phase 79: the third role; the UI's admin-only surfaces key
off ``role === "admin"`` specifically).
The public API otherwise stays stateless (A10): chat, the document
content endpoint (soft rule — anonymous may open any document by direct
URL), suggestions, and health never require the cookie.
The rest of the public surface is the phase-79 contract: health,
config, whoami, login, token-auth, and the shared chats stay
anonymous — chat, suggestions, and the document viewer content are
user-gated (``require_user``).
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.orm import Session
from app.config import get_settings
from app.core.auth import ADMIN_SESSION_KEY, check_password, sign_in, sign_out
from app.schemas import LoginRequest, WhoamiResponse
from app.core import tokens as token_service
from app.core.auth import (
ADMIN_SESSION_KEY,
USER_SESSION_KEY,
USER_TOKEN_ID_KEY,
check_password,
sign_in,
sign_out,
)
from app.db import get_db
from app.schemas import LoginRequest, TokenAuthRequest, WhoamiResponse
router = APIRouter(tags=["auth"])
@@ -39,14 +57,48 @@ def login(payload: LoginRequest, request: Request) -> Response:
raise HTTPException(status_code=401, detail="invalid password")
@router.post("/token-auth", status_code=204)
def token_auth(
payload: TokenAuthRequest,
request: Request,
db: Session = Depends(get_db), # noqa: B008
) -> Response:
"""Sign a token holder in (phase 79 — PUBLIC: this IS the login).
A valid, unrevoked token → 204 + the signed ``bor_session`` cookie
(the same SessionMiddleware mechanism as ``/api/login``): the
session carries the ``user`` key plus the token's row id
(``user_token_id``), and ``require_user``'s live row check enforces
revocation from the holder's very next request. The token's
``last_used_at`` is stamped here (the admin's Tokens view shows the
login as the last use).
EVERY failure shape — malformed, unknown, revoked, empty — is ONE
generic 401 ``invalid token``: the lookup is by hash (the service
returns ``None`` for anything that is not an active row), so there
is no enumeration surface (the phase-16 pattern).
"""
token = payload.token.strip()
row = token_service.find_active_by_token(db, token) if token else None
if row is None:
raise HTTPException(status_code=401, detail="invalid token")
token_service.mark_used(row)
db.commit()
request.session[USER_SESSION_KEY] = True
request.session[USER_TOKEN_ID_KEY] = str(row.id)
return Response(status_code=204)
@router.post("/logout", status_code=204)
def logout(request: Request, response: Response) -> Response:
"""Sign out: clear the session AND expire the browser cookie.
``sign_out`` empties the session dict (which the middleware does not
re-persist — an empty session has nothing to sign), so this route also
sends ``delete_cookie`` to make the browser drop the signed cookie
right now. Idempotent: an anonymous logout is still a 204.
re-persist — an empty session has nothing to sign), so this route
also sends ``delete_cookie`` to make the browser drop the signed
cookie right now. One logout wipes BOTH roles (admin and token —
the session is one dict). Idempotent: an anonymous logout is still
a 204.
"""
sign_out(request.session)
response.delete_cookie(get_settings().session_cookie, path="/")
@@ -55,9 +107,24 @@ def logout(request: Request, response: Response) -> Response:
@router.get("/whoami", response_model=WhoamiResponse)
def whoami(request: Request) -> WhoamiResponse:
"""Who is the caller? Drives every UI gating decision (phase 16)."""
authenticated = bool(request.session.get(ADMIN_SESSION_KEY))
return WhoamiResponse(
authenticated=authenticated,
role="admin" if authenticated else "anonymous",
)
"""Who is the caller? Drives every UI gating decision.
Three roles (phase 79): ``admin`` (the signed-in admin — wins when
the browser holds BOTH an admin and a token session), ``user`` (a
token holder), ``anonymous``. ``authenticated`` is true for admin
AND user; the UI's admin-only surfaces key off ``role === "admin"``
specifically, not off ``authenticated``.
This endpoint only reads the session keys — it does NOT live-check
the token row. A token session whose row was just revoked still
reports ``user`` here until the next gated request: ``require_user``
then pops the dead keys, after which this endpoint reports
anonymous (the phase-79 live-enforcement contract).
"""
if request.session.get(ADMIN_SESSION_KEY):
role = "admin"
elif request.session.get(USER_SESSION_KEY):
role = "user"
else:
role = "anonymous"
return WhoamiResponse(authenticated=role != "anonymous", role=role)