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)
+8 -1
View File
@@ -149,6 +149,7 @@ from sqlalchemy.orm import Session
from app.api.steering import load_steering_notes
from app.config import Settings, get_settings
from app.core.auth import require_user
from app.db import db_available, get_db
from app.models import Document, QueryLog
from app.rag.agent import (
@@ -296,10 +297,16 @@ def plan_turn(
@router.post("/chat")
async def chat(
request: ChatRequest,
_user: None = Depends(require_user), # noqa: B008 # phase 79: admin or live token
db: Session = Depends(get_db), # noqa: B008
llm: LLMClient = Depends(get_llm), # noqa: B008
):
"""One chat turn: SSE stream of ``delta`` events + a final ``done``."""
"""One chat turn: SSE stream of ``delta`` events + a final ``done``.
User-gated (phase 79): anonymous callers get 401 ``authentication
required`` before any streaming — the ONLY anonymous content is the
shared chats.
"""
if not db_available():
return JSONResponse(
status_code=503,
+17 -11
View File
@@ -19,7 +19,7 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.api.sync import _sanitize_error
from app.core.auth import require_admin
from app.core.auth import require_admin, require_user
from app.db import get_db
from app.models import Chunk, Document
from app.rag.llm import EmbeddingError, LLMClient
@@ -42,11 +42,12 @@ def list_indexed_documents(
) -> DocList:
"""All indexed documents with per-document chunk counts.
Admin-only (phase 16 — the catalog is what the sign-in gates; the
document viewer itself stays public, see below). Anonymous callers
get 403 ``admin only`` and the Sources page renders its sign-in gate
instead. An empty list means the knowledge base has not been imported
yet — the Sources page renders its designed empty state in that case.
Admin-only (phase 16 — the catalog is what the admin sign-in gates;
the document viewer below is user-gated since phase 79, the shared
chats being the only anonymous surface). Anonymous callers get 403
``admin only`` and the Sources page renders its sign-in gate instead.
An empty list means the knowledge base has not been imported yet —
the Sources page renders its designed empty state in that case.
"""
rows = db.execute(
select(
@@ -78,7 +79,10 @@ def list_indexed_documents(
@router.get("/documents/content", response_model=DocContent)
def get_document_content(
source: str, path: str, db: Session = Depends(get_db) # noqa: B008
source: str,
path: str,
db: Session = Depends(get_db), # noqa: B008
_user: None = Depends(require_user), # noqa: B008 # phase 79: admin or live token
) -> DocContent:
"""Full content of one indexed document, looked up by ``(source, path)``.
@@ -86,10 +90,12 @@ def get_document_content(
strings such as ``../../etc/passwd`` — are just non-existent rows and
map to 404 ``{detail: "document not found"}``.
Deliberately PUBLIC for anonymous callers (phase 16 soft rule, owner
decision 2026-08-22): the *catalog* (``GET /api/docs``) is what the
sign-in gates, not the viewer — chat cites documents and anyone may
open a cited document by direct URL.
User-gated (phase 79 — SUPERSEDES the phase-16 "deliberately PUBLIC
(soft rule)" note, owner decision 2026-08-22): the viewer content is
token-or-admin like the rest of the app surface — chat cites
documents and a signed-in user (admin or token holder) opens a cited
document by direct URL. The ONLY anonymous content left is the
shared chats.
"""
row = db.execute(
select(Document, func.count(Chunk.id).label("chunks"))
+3 -2
View File
@@ -10,8 +10,9 @@ into the system prompt of **every** chat turn as the ``<tuning>`` section
replacement of ``note``, ``created_at`` preserved), ``DELETE /{note_id}``.
The whole router sits behind :func:`app.core.auth.require_admin` —
anonymous callers get 403 on every steering route (the chat turn itself
reads the table in-process and stays public); the PUT route adds no auth
surface of its own, it reuses that router-level dependency.
reads the table in-process; since phase 79 that turn is user-gated
through ``require_user``); the PUT route adds no auth surface of its
own, it reuses that router-level dependency.
"""
from __future__ import annotations
+78 -4
View File
@@ -1,14 +1,88 @@
"""Suggested-question endpoint (drives the onboarding chips in the UI)."""
"""Suggested-question endpoint (drives the onboarding chips in the UI).
Phase 80: the chips are the **last 3 questions asked** — the three
most recent user questions across ALL saved chats (chats walked
newest-``updated_at`` first, each chat's messages walked newest-first,
exact de-duplicated, cap 3 — see :func:`last_questions`). A fresh
deployment — zero saved questions — gets the seed list instead
(``BOR_SUGGESTIONS`` override, or the built-in default).
Phase 79: user-gated (``require_user``) — the chips are part of the app
surface (chat, suggestions, cited documents); the ONLY anonymous
content is the shared chats (the phase-16 "everything but the admin
surface is open" stance is superseded).
The deflection "Maybe try" chips (``app.rag.suggestions.
derive_suggestions``) are a SEPARATE contract (title-derived, carried
in the chat response) and are untouched by this endpoint.
"""
from __future__ import annotations
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.core.auth import require_user
from app.db import get_db
from app.models import SavedChat
from app.schemas import SuggestionList
router = APIRouter(tags=["chat"])
def last_questions(db: Session, limit: int = 3) -> list[str]:
"""The ``limit`` most recent user questions, across all saved chats.
Chats are walked ``updated_at DESC, created_at DESC`` (the
tiebreak keeps the order deterministic when timestamps collide);
each chat's ``messages`` (a JSONB column that deserializes to a
plain Python list of ``bor.chat.v1`` dicts — NO SQL JSON ops
needed, the record shape is the ``SavedChat.messages`` model
docstring) is walked in REVERSE (conversational order is
oldest→newest), collecting the whitespace-trimmed ``text`` of
every entry with ``who == "user"``. Blank texts are skipped.
De-duplication is EXACT (case-sensitive) against the collected
window: a verbatim re-ask counts once, while a legitimately
differently-cased re-ask is kept (case-insensitive dedup would
drop it). The walk stops once ``limit`` UNIQUE texts are
collected; the result is in encounter order (newest first).
Pure-DB helper (unit-testable without the endpoint); returns
``[]`` when no saved question exists (the caller then falls back
to the seed list).
"""
result: list[str] = []
seen: set[str] = set()
for chat in db.scalars(
select(SavedChat).order_by(
SavedChat.updated_at.desc(), SavedChat.created_at.desc()
)
):
for m in reversed(chat.messages or []):
if m.get("who") != "user":
continue
question = str(m.get("text", "")).strip()
if not question or question in seen:
continue
seen.add(question)
result.append(question)
if len(result) >= limit:
return result
return result
@router.get("/suggestions", response_model=SuggestionList)
def suggestions() -> SuggestionList:
return SuggestionList(suggestions=get_settings().suggestions)
def suggestions(
_user: None = Depends(require_user), # noqa: B008 # phase 79: admin or live token
db: Session = Depends(get_db), # noqa: B008
) -> SuggestionList:
"""The onboarding chips (admin OR token user, else 401): the last 3
questions asked across saved chats — or, before any question has
ever been saved, the seed list (``BOR_SUGGESTIONS`` / the
built-in default). The deflection "Maybe try" chips are a separate
contract (``app.rag.suggestions.derive_suggestions``), untouched.
"""
qs = last_questions(db)
return SuggestionList(suggestions=qs if qs else get_settings().suggestions)
+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)