# Story: Admin Sign-In (Single-Admin Auth) **Phase:** `16_admin_auth.md` · **E2E:** `tests/e2e/test_admin_auth.py` ## Narrative As **the one admin (the owner)**, I want to **sign in with a password** so that I — and only I — can **tune how Brain answers** (Phase 15 steering notes) and **browse the entire Sources catalog**, while **anyone else stays anonymous** and can still **chat** and **open the documents the chat cites**. - **Given** I am not signed in - **When** I open the app - **Then** chat, suggestion chips, and the document viewer all work, but the tuning UI is gone (no Tune button, no Tuning panel) and the Sources page shows a "sign in to view" state; a **Sign in** link is in the header. - **When** I sign in with the admin password - **Then** the tuning UI and the full Sources page are unlocked, and the header offers **Sign out**. - Anonymous users may open **any indexed document by direct URL** (soft rule — the catalog is what is gated, not the viewer). ## Acceptance criteria 1. **Config (fail-loud):** `BOR_ADMIN_PASSWORD` (plaintext, `.env`) and `BOR_SESSION_SECRET` (random hex) — if either is unset the app **refuses to start** with a message naming the missing variable(s). 2. `POST /api/login {password}` → **204** + signed session cookie (Starlette `SessionMiddleware`, `same_site=lax`, 12h default max age); wrong password → **401** `{"detail": "invalid password"}` (constant-time compare, one generic message — no user enumeration). 3. `POST /api/logout` → **204** (session cleared, idempotent); `GET /api/whoami` → `{"authenticated": bool, "role": "admin"|"anonymous"}`. 4. **Admin-only:** `GET /api/docs` and `GET`/`POST`/`DELETE` `/api/steering` → **403** for anonymous. **Public (unchanged):** `/api/chat`, `/api/documents/content`, `/api/suggestions`, `/api/health`, all static pages. 5. **Login page** `/login.html` (no CDN, Phase-08 tokens, WCAG 2.1 AA): app-frame header, labeled password field, submit ≥44px, `role=alert` error region; on success redirects to `?next` (same-origin only, default `/sources.html`); an already-signed-in visit redirects immediately. 6. **Chat page:** header shows **Sign in** when anonymous (→ `/login.html?next=/sources.html`) or **Sign out** when admin (logout → reload); when anonymous the Tune button (including on localStorage restored messages, Phase 14) and the Tuning panel/toggle are completely hidden; the chat flow itself is unchanged. 7. **Sources page:** anonymous sees `#sources-gate` ("sign in to view the full catalog" + sign-in link) with stat cards and `#docs-table` hidden and no `/api/docs` call; admin sees the current full Sources page. 8. **Document viewer:** unchanged — anonymous can open any indexed document by direct URL. 9. Non-goals: no rate limiting / account lockout, no HTTPS enforcement (homelab HTTP), no multi-user, no per-user history, no schema change. 10. Unit + integration green, `app/` coverage >90%, story E2E green in isolation, regression suites adapted + green in isolation, one `--no-gpg-sign` commit. ## Playwright Mapping Rule **Test Scenario → `tests/e2e/test_admin_auth.py`** (the E2E app server is started with `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` set in `tests/e2e/conftest.py`; the shared `tests/e2e/auth_helpers.py::login` performs the real form login): 1. `test_anonymous_chat_without_tuning` — question → streamed answer renders; no `.tune-btn` under the brain bubble; `#steering-toggle` absent; `#sign-in-link` visible, `#sign-out-btn` not. 2. `test_anonymous_sources_gated_viewer_open` — `/sources.html`: `#sources-gate` visible, `#docs-table` hidden; then `/document.html?source=…&path=…` (seeded fixture doc) renders its content while anonymous. 3. `test_login_wrong_password_shows_error` — wrong password → `#login-error` (role=alert) visible, no redirect, still anonymous. 4. `test_admin_login_unlocks_sources_and_tuning` — correct password → redirect to `/sources.html` with stats + table visible; chat page shows `#steering-toggle` (count badge) and `.tune-btn` under the answer; `#sign-out-btn` visible, `#sign-in-link` not. 5. `test_logout_returns_to_anonymous` — Sign out → tuning hidden, `#sign-in-link` back, `/sources.html` gated again. 6. `test_login_page_a11y` — password field labeled, error announced via role=alert, targets ≥44px, focus-visible; signed-in visit to `/login.html?next=/` redirects immediately. **Regression adaptations (this phase makes their preconditions admin-only):** `test_steering.py` (log in before tuning), `test_import_documents.py` + `test_document_back_navigation.py` (log in for Sources-table assertions), `test_header_consistency.py` (header gains the auth link — extend presence assertions; heights unchanged).