# 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; `itsdangerous` is already a starlette dependency) → **zero new packages, zero new services** (A12 untouched). Cookie `bor_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-liner `python -c 'import secrets;print(secrets.token_hex(32))'`). Either empty → `create_app()` raises `RuntimeError` naming the missing `BOR_` 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_admin` dependency in `app/core/auth.py`: reads `request.session`, else **403** `{"detail": "admin only"}`. - **Gated:** `GET /api/docs` + the whole `/api/steering` router (router-level `dependencies=[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-form` with visually-hidden `