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