Standardize on the .agents/ directory (shared with project skills): phases/, user_stories/, reports/, screenshots/, validate.sh, and phase-sessions/ + pipeline.log all move to .agents/ (git mv preserves history; runtime artifacts move alongside). Updates every reference in AGENTS.md, README.md, .gitignore, app docstrings, and test story headers. Historical KB content in data/ and the runtime pipeline.log transcript are left untouched.
11 KiB
Phase 16 — Admin Sign-In (Single-Admin Auth)
Story: .agents/user_stories/admin-auth.md
Context: owner request 2026-08-22 — "add authentication. Only the admin
user (there will be only one admin user, me) should be able to tune the
outputs and view the entire sources page. Anonymous users should only be
able to chat and view relevant documents from that chat." Owner-confirmed
choices: plaintext BOR_ADMIN_PASSWORD; soft document rule (anon
may open any document by direct URL — the catalog, not the viewer, is
gated); UX: header Sign in / Sign out, Sources shows a soft gate state
(not a redirect), tuning UI completely hidden from anonymous.
Objective
Single-admin password login backed by a signed session cookie (no new services, no new packages, no DB tables): the admin can tune (Phase 15 steering) and see the full Sources catalog; anonymous users keep chat + document viewer. Revises LOCKED A10 with owner permission (2026-08-22) — the public API stays stateless; the session cookie is the only session state.
Dependencies
- All of
01–15(complete). Specifically:15_steering_notes(the gated tuning feature),10_story_document_viewer(the anonymous document path),02_story_import_documents(Sources page being gated),08_story_dark_tech_theme(Phase-08 tokens for the login page),12_header_consistency(the auth link joins.header-inner),14_chat_persistence(restored messages must also omit Tune buttons for anonymous).
Design
- Mechanism: Starlette
SessionMiddleware(ships with FastAPI;itsdangerousis already a starlette dependency) → zero new packages, zero new services (A12 untouched). Cookiebor_session,same_site="lax",https_only=False(homelab HTTP — documented in README), max age 12 h sliding (BOR_SESSION_MAX_AGE, default 43200). - Config (fail-loud, A6 spirit):
admin_password(BOR_ADMIN_PASSWORD, plaintext in gitignored.env) +session_secret(BOR_SESSION_SECRET, random hex; README one-linerpython -c 'import secrets;print(secrets.token_hex(32))'). Either empty →create_app()raisesRuntimeErrornaming the missingBOR_variable(s) before the app serves anything. - Password check:
secrets.compare_digest(constant-time); one admin → one generic 401"invalid password"(no user enumeration). - API surface (
app/api/auth.py, new router):POST /api/login{password}→ 204 + session{"admin": true}; 401 on mismatch (no session set).POST /api/logout→ 204; clears the session (idempotent for anon).GET /api/whoami→{"authenticated": bool, "role": "admin"|"anonymous"}(drives all UI gating; trivially testable).require_admindependency inapp/core/auth.py: readsrequest.session, else 403{"detail": "admin only"}.- Gated:
GET /api/docs+ the whole/api/steeringrouter (router-leveldependencies=[Depends(require_admin)]). - Public (unchanged):
/api/chat,/api/documents/content(soft rule — note it in the docstring),/api/suggestions,/api/health, all static pages.
- UI (Phase-08 tokens, WCAG 2.1 AA, ≥44px targets, focus-visible,
contrast ≥4.5:1):
/login.html+frontend/assets/login.js: standard app frame + sticky header (brand + nav, same as other pages — Phase 12 consistency), centered card:#login-formwith visually-hidden<label>+#login-password(type=password,autocomplete="current-password"), submit "Sign in",#login-errorrole=alert. On submit →POST /api/login; 204 →locationto?next(same-origin/…only, default/sources.html); 401 → announce + keep form. On load:GET /api/whoamialready-admin → redirect tonextimmediately.- Chat (
index.html,app.js):.header-innergains#sign-in-link(<a>→/login.html?next=/sources.html) and#sign-out-btn(<button>, aria-label "Sign out") — exactly one visible, decided by/api/whoamiat load. Anonymous:#steering-toggle+#steering-panelhidden, no steering notes fetch, and no.tune-btninjected on new or Phase-14-restored messages. Sign out →POST /api/logout→location.reload(). - Sources (
sources.html,sources.js): new#sources-gateblock (heading "Sign in to view the full catalog", copy, sign-in link →/login.html?next=/sources.html, ≥44px).sources.jsfetches whoami before/api/docs: anonymous → show gate, hide stat cards +.sources-shelltable, skip the docs fetch; admin → today's behavior. - Document viewer: untouched (anonymous OK).
- Non-goals: no rate limiting / lockout, no HTTPS enforcement, no multi-user, no per-user history, no schema change / no migration.
Tasks
app/config.py— addadmin_password: str = "",session_secret: str = "",session_max_age: int = 43_200,session_cookie: str = "bor_session"(documented as auth settings).app/core/auth.py(new) —ensure_admin_configured(settings) -> None(RuntimeError naming missingBOR_ADMIN_PASSWORD/BOR_SESSION_SECRET),check_password(candidate, expected) -> bool(secrets.compare_digest),require_admin(request)FastAPI dependency (403),sign_in(session)/sign_out(session)helpers.app/api/auth.py(new) —login/logout/whoamiroutes;LoginRequestschema inapp/schemas.py; mount inapp/main.py(with the other API routers, before the static catch-all).app/main.py::create_app— callensure_admin_configured(settings)first;app.add_middleware(SessionMiddleware, secret_key=…, max_age=…, same_site="lax").app/api/docs.py,app/api/steering.py—require_adminonlist_documentsand on the steering router;get_document_contentstays public (docstring: soft rule, Phase 16).frontend/login.html,frontend/assets/login.js,frontend/assets/styles.css— login page per Design (landmarks, skip-link, label, live error, Phase-08 tokens).frontend/index.html,frontend/assets/app.js,frontend/assets/styles.css— whoami on load →isAdmin; auth link in.header-inner; gate steering toggle/panel + tune-button injection (incl. the restore path); sign-out handler.frontend/sources.html,frontend/assets/sources.js,frontend/assets/styles.css—#sources-gate; whoami-before-docs fetch; hide stats + table for anonymous.- Docs & config —
.env.example:BOR_ADMIN_PASSWORD=,BOR_SESSION_SECRET=,# BOR_SESSION_MAX_AGE=43200; README "Admin & sign-in" section (setup one-liner, fail-loud behavior, anon vs admin capability table); PLAN revisions: A10 → revised 2026-08-22 (owner permission): single-admin signed-cookie auth — public: chat / documents / suggestions / health; admin-only: docs catalog + steering; §4 API table (+/api/login,/api/logout,/api/whoami, auth column); §7.5 new ids (#sign-in-link,#sign-out-btn,#login-form,#login-password,#login-error,#sources-gate); §13 (auth hook now done — keep the multi-user/Valkey line); §12 roadmap row 16. - Test fixtures —
tests/conftest.py:os.environ.setdefaultBOR_ADMIN_PASSWORD/BOR_SESSION_SECRET(known test values) before theapp.mainimport (same pattern asBOR_RELEVANCE_THRESHOLD);tests/e2e/conftest.py: set both inapp_server's env + export anADMIN_PASSWORDconstant for tests;tests/e2e/auth_helpers.py(new):login(page, app_url, password=None, next=None)performing the real form login (wrong-password variant for the error test). - E2E regression adaptations —
tests/e2e/test_steering.py(log in before any tuning),tests/e2e/test_import_documents.py+tests/e2e/test_document_back_navigation.py(log in for Sources-table assertions),tests/e2e/test_header_consistency.py(auth link presence/consistency; height assertions unchanged).
Locked decisions
- A10 revised with owner permission (2026-08-22): single-admin auth via signed cookie; public API endpoints remain stateless. Recorded as a PLAN §2 revision (owner permission noted), not a silent deviation.
- A12 untouched — no new services (SessionMiddleware/itsdangerous ship with starlette). A13 untouched — no migration needed. A16 untouched — one new story E2E suite + adapted regressions. A11 untouched — vanilla frontend, no CDN. No other anchor changed.
Testing & Quality
- Unit (
tests/unit/test_auth.py):ensure_admin_configured(missing password / missing secret / both set; pluscreate_appraising withapp.main.settingsmonkeypatched invalid);check_password(match / mismatch / empty);require_admin(admin session passes, anonymous → 403); whoami payload shape;sign_in/sign_outsession semantics. - Integration (
tests/integration/test_auth_api.py): wrong password → 401 and subsequent/api/steeringstill 403; correct → 204 + cookie → whoami admin,/api/docs200, steering GET/POST/DELETE 201/200/204; logout → 403 again; anonymous/api/documents/contentstill 200 (seeded doc) and/api/chatstill streams (regression guard);/api/whoamianonymous shape. - Coverage:
uv run pytest --cov=app --cov-report=term-missing>90% onapp/. - E2E:
tests/e2e/test_admin_auth.py— the six scenarios in the story's Playwright Mapping Rule. - Regression (each green in isolation):
test_steering.py,test_import_documents.py,test_document_back_navigation.py,test_header_consistency.py,test_chat_rag.py,test_document_viewer.py. - Lint/types:
uv run ruff check . && uv run pyrightclean.
Completion Criteria
uv run uvicorn app.main:appwithBOR_ADMIN_PASSWORDunset fails at startup naming the missing variable(s); with both auth vars set it serves.- Anonymous:
POST /api/chatstreams;GET /api/docs→ 403;GET /api/documents/content?source=…&path=…→ 200;/sources.htmlshows#sources-gate; chat UI has no Tune button / Tuning panel; header shows Sign in. POST /api/login(correct) → 204 + cookie →/api/whoami{"authenticated": true, "role": "admin"}→ Sources + tuning work →POST /api/logout→ 403 again.uv run pytest --cov=app --cov-report=term-missing> 90%;uv run ruff check . && uv run pyrightclean.uv run pytest tests/e2e/test_admin_auth.py -v --no-covgreen in isolation; all regression suites above green in isolation.- UI Structure Check (AGENTS.md rule 5): login page — landmarks,
labeled control, contrast ≥4.5:1, focus-visible,
role=alerterror, centered card in the standard frame, no CDN tags. - One
--no-gpg-signcommit (below);.agents/phases/todo/16_admin_auth.mdmoved to.agents/phases/complete/.
Commit
git add -A .agents/ app/ frontend/ tests/ README.md .env.example && git commit --no-gpg-sign -m "feat(auth): single-admin password login (signed cookie) — gate tuning + Sources catalog, keep chat and document viewer public"