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)
+5 -1
View File
@@ -351,7 +351,11 @@ class Settings(BaseSettings):
raise ValueError(f"{name} must not contain '..' (a git branch token)")
return v
# Suggested questions (onboarding + empty state).
# Onboarding-chip SEED (phase 80, TODO.md L6): shown ONLY while no
# saved chat has ever asked a question — after that,
# ``GET /api/suggestions`` serves the last 3 questions asked
# (deployment-wide, newest first). ``BOR_SUGGESTIONS`` overrides
# this seed for a new deployment.
suggestions: list[str] = [
"What documents are in the knowledge base?",
"Which source does each answer come from?",
+73 -8
View File
@@ -1,9 +1,10 @@
"""Single-admin authentication (phase 16; LOCKED A10 revised 2026-08-22).
"""Authentication (phase 16 single-admin; phase 79 token users).
One admin (the owner), one plaintext password, one signed cookie. The
mechanism is Starlette's ``SessionMiddleware`` (itsdangerous-signed cookie
— no server-side store, no new services, no DB tables): the public API
stays stateless, the cookie is the *only* session state.
One admin (the owner), one plaintext password; admin-issued access
tokens (``bor_`` + 32 hex — see :mod:`app.core.tokens`) for handed-out
users. The mechanism is Starlette's ``SessionMiddleware``
(itsdangerous-signed cookie — no server-side store, no new services):
the public API stays stateless, the cookie is the *only* session state.
Contract:
* ``ensure_admin_configured`` — fail-loud startup gate: the app must name
@@ -11,22 +12,41 @@ Contract:
* ``check_password`` — constant-time compare; exactly one generic 401
message (no user enumeration, there is no second user).
* ``require_admin`` — FastAPI dependency; anonymous callers get 403
``{"detail": "admin only"}`` (used by ``GET /api/docs`` and the whole
``/api/steering`` router).
``{"detail": "admin only"}`` (the admin-only surfaces: ``GET
/api/docs``, the whole ``/api/steering`` router, the token admin
API, …).
* ``require_user`` — FastAPI dependency (phase 79); admin OR a live
token session passes, everything else gets 401 ``authentication
required``. The token path live-checks the ``api_tokens`` row on
every request (the PK lookup IS the revocation check) and drops a
dead session (row revoked or gone) from the cookie right there.
* ``sign_in`` / ``sign_out`` — session-dict helpers for the API routes.
One session dict carries BOTH roles (an admin browser that also holds
a token reports admin); ``sign_out``'s ``session.clear()`` wipes
everything — one logout, both roles.
"""
from __future__ import annotations
import secrets
import uuid
from collections.abc import MutableMapping
from typing import Any
from fastapi import HTTPException, Request
from fastapi import Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import Settings
from app.db import get_db
from app.models import ApiToken
#: The session key the single admin is stored under.
ADMIN_SESSION_KEY = "admin"
#: The session key a token holder is stored under (phase 79).
USER_SESSION_KEY = "user"
#: The session key holding the ``api_tokens`` row id of a token session —
#: ``require_user``'s live check fetches that row on every request.
USER_TOKEN_ID_KEY = "user_token_id"
def ensure_admin_configured(settings: Settings) -> None:
@@ -70,6 +90,51 @@ def require_admin(request: Request) -> None:
raise HTTPException(status_code=403, detail="admin only")
def require_user(request: Request, db: Session = Depends(get_db)) -> None: # noqa: B008
"""FastAPI dependency: allow the admin or a LIVE token holder, else 401.
Guards the app surface (``POST /api/chat``, ``GET /api/suggestions``,
``GET /api/documents/content`` — phase 79: the ONLY anonymous content
is the shared chats). The matrix:
* admin key set → pass, token state irrelevant (an admin browser
that also holds a token still passes as admin);
* ``user`` key set → the ``api_tokens`` row for ``user_token_id`` is
fetched (a PK hit — the live revocation check, no session store):
row missing OR ``revoked_at`` set → BOTH user keys are popped from
the session (the dead session is dropped NOW — the next
``whoami`` is anonymous) and the request gets 401;
* neither key → 401.
The failure detail is ``authentication required`` on 401 — not 403:
there is no higher privilege that would unblock an anonymous caller
(phase 79 auth-error semantics), unlike ``require_admin``'s surfaces.
"""
if request.session.get(ADMIN_SESSION_KEY):
return
if request.session.get(USER_SESSION_KEY):
token_id_raw = request.session.get(USER_TOKEN_ID_KEY)
token_id: uuid.UUID | None = None
if isinstance(token_id_raw, str):
try:
token_id = uuid.UUID(token_id_raw)
except ValueError:
token_id = None # corrupt session — treat as a missing row
row = (
db.execute(select(ApiToken).where(ApiToken.id == token_id))
.scalars()
.first()
if token_id is not None
else None
)
if row is None or row.revoked_at is not None:
request.session.pop(USER_SESSION_KEY, None)
request.session.pop(USER_TOKEN_ID_KEY, None)
raise HTTPException(status_code=401, detail="authentication required")
return
raise HTTPException(status_code=401, detail="authentication required")
def sign_in(session: MutableMapping[str, Any]) -> None:
"""Mark the (cookie-backed) session as the single admin.
+1
View File
@@ -142,6 +142,7 @@ HTML_PAGES: tuple[str, ...] = (
"/tuning.html",
"/git-sources.html", # phase 35: the admin git sources page
"/history.html", # phase 50: the admin saved-chats page
"/tokens.html", # phase 79 task 06: the admin tokens page (shell route)
# phase 51: the shared page's STATIC path (the static mount serves
# shared.html at /shared.html as well as the real route serves the
# dynamic /shared/<token> — both must carry the no-cache + ?v=
+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
+7 -1
View File
@@ -39,6 +39,7 @@ from app.api.health import router as health_router
from app.api.steering import router as steering_router
from app.api.suggestions import router as suggestions_router
from app.api.sync import router as sync_router
from app.api.tokens import router as tokens_router
from app.config import get_settings
from app.core.auth import ensure_admin_configured
from app.core.caching import configure_caching
@@ -117,6 +118,9 @@ def create_app() -> FastAPI:
app.include_router(sync_router, prefix="/api")
app.include_router(chats_router, prefix="/api")
app.include_router(doc_drafts_router, prefix="/api")
# Phase 79: the admin token surface (create/list/revoke) — admin-only
# (router-wide require_admin; a token USER stays 403 here, task 03).
app.include_router(tokens_router, prefix="/api")
# Phase 51: the anonymous shared-chat read — NO admin dependency.
# /api/shared/<token> is the JSON snapshot; /shared/<token> (the
# page route below, registered without a prefix) is the page.
@@ -135,7 +139,8 @@ def create_app() -> FastAPI:
# Phase 76: the folded navbar views serve the shell — the
# router picks the view from the pathname. Task 01 landed
# Tuning; task 02 folds RAG + Sources; task 03 lands History
# (list-driven — all four non-chat navbar views are in).
# (list-driven — all four non-chat navbar views are in);
# phase 79 task 06 folds the sixth view (Tokens).
_shell_routes(
app,
static_dir,
@@ -144,6 +149,7 @@ def create_app() -> FastAPI:
"/sources.html",
"/git-sources.html",
"/history.html",
"/tokens.html", # phase 79 task 06: the Tokens view
),
)
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
+58
View File
@@ -41,6 +41,20 @@ Data model — see ``.agents/PLAN.md`` §Data Model:
``commit_sha`` recorded) when the push endpoint
commits + pushes the file to the
``BOR_DOCS_REPO`` branch (phase 59).
* ``api_tokens`` — admin-issued access tokens: one row per
generated token, so a person handed a token can
sign in to use the app (chat, suggestion chips,
cited documents) — the ONLY content that stays
anonymous is the shared chats (phase 79).
``token_hash`` is the SHA-256 hex digest of the
full ``bor_…`` token string (the stored
credential — the plaintext exists only in the 201
create response, returned exactly once);
``revoked_at`` set = dead (live-checked on the
holder's next request), ``last_used_at`` bumped
on ``POST /api/token-auth`` (task 03 — the only
request that presents the token; the in-app gate
re-sends the cached token on every page load).
"""
from __future__ import annotations
@@ -314,3 +328,47 @@ class SavedChat(Base):
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class ApiToken(Base):
"""One admin-issued access token (phase 79, task 01).
The admin generates named tokens and hands them out so people can
sign in to the app and use it (chat, suggestion chips, cited
documents) — the ONLY content that stays anonymous is the shared chats
(extending the phase-16 single-admin auth; the ``require_user``
live-check and the admin token API land in tasks 02/03).
Trust model — the plaintext token (``bor_`` + 32 hex chars) exists
only in the 201 response of the create call, returned **exactly
once**; the row never carries it. The stored credential is the
SHA-256 hex digest of the **full** token string (``token_hash``):
hashing the full string, not the suffix, so a stripped prefix can
never collide. The ``saved_chats.share_token`` /
``doc_drafts.token`` lineage — but HASHED: unlike those
unguessable ``uuid4`` link tokens these are long-lived hand-out
credentials, and a leaked database must not hand anyone working
tokens.
"""
__tablename__ = "api_tokens"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
#: The hand-out name (e.g. "alice") — display-only: no index, not
#: unique (two tokens may share a label).
label: Mapped[str] = mapped_column(String(120), nullable=False)
#: The stored credential: the SHA-256 hex digest of the full
#: ``bor_…`` token string (the ``documents.content_hash``
#: String(64) precedent). Unique — the lookup is a unique-index hit
#: (``ix_api_tokens_token_hash`` — the explicit unique-index shape
#: of ``ix_saved_chats_share_token``, phase 51).
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
#: Bumped to now() on ``POST /api/token-auth`` (task 03 calls the
#: service's ``mark_used`` and commits); NULL until the token is
#: first used.
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
#: Set on revocation (task 02) — the row is dead from that moment
#: (enforced immediately on the holder's next request); NULL while
#: active.
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+79 -2
View File
@@ -86,10 +86,13 @@ class LoginRequest(BaseModel):
class WhoamiResponse(BaseModel):
"""``GET /api/whoami`` (phase 16) — drives all UI gating."""
"""``GET /api/whoami`` (phase 16; phase 79 added the ``user`` role)
— drives all UI gating. ``authenticated`` is true for admin AND
user; the UI's admin-only surfaces key off ``role === "admin"``.
"""
authenticated: bool
role: str # "admin" | "anonymous"
role: str # "admin" | "user" | "anonymous"
class SourceRef(BaseModel):
@@ -688,3 +691,77 @@ class DocDraftPushed(BaseModel):
status: Literal["pushed"] = "pushed"
branch: str
commit_sha: str
class TokenCreateRequest(BaseModel):
"""``POST /api/tokens`` body (phase 79, task 02): one named token
to generate and hand out.
``label`` is the hand-out name (e.g. "alice") — display-only, NOT
unique (two tokens may share a label). Trimmed *before* the length
constraints run, so a whitespace-only body is a 422 and a label with
surrounding spaces is stored clean (the ``SteeringNoteIn`` house
``ValueError`` pattern — fail loud at the boundary).
"""
label: str = Field(min_length=1, max_length=120)
@field_validator("label", mode="before")
@classmethod
def _trim_label(cls, v: object) -> object:
return v.strip() if isinstance(v, str) else v
class TokenCreated(BaseModel):
"""``POST /api/tokens`` 201 response (phase 79, task 02).
The ONLY schema in the codebase that carries the plaintext ``token``
— the wire moment it exists exactly once (A4). Every other response
shape (the list row, whoami, …) exposes display fields only: the
stored credential is the hash, and the hash itself is never a wire
field either.
"""
id: uuid.UUID
label: str
token: str
created_at: datetime
class TokenListItem(BaseModel):
"""One row of ``GET /api/tokens`` (phase 79, task 02).
Deliberately secret-free: NO ``token`` field and NO ``token_hash``
field exist on this shape — the list never carries a credential in
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).
"""
id: uuid.UUID
label: str
created_at: datetime
last_used_at: datetime | None
revoked: bool
class TokenList(BaseModel):
"""``GET /api/tokens`` response: all tokens, newest first
(``created_at desc, id desc``)."""
tokens: list[TokenListItem]
class TokenAuthRequest(BaseModel):
"""``POST /api/token-auth`` body (phase 79, task 03): the plaintext
token a handed-out user presents at the in-app gate.
Deliberately NO min-length validator: an empty/whitespace token is a
MALFORMED login attempt — the endpoint 401s ``invalid token`` (the
phase-16 generic-401 pattern, one message for every failure: no
enumeration). A 422 here would hint at input-shape differences on a
credential endpoint, so the shape is just ``str`` and the endpoint
owns the ``token.strip()`` check.
"""
token: str