feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
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:
+88
-21
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user