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)
|
||||
Reference in New Issue
Block a user