refactor(agents): migrate .agent/ planning tree to .agents/

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.
This commit is contained in:
2026-09-05 10:57:07 -04:00
parent 766702c750
commit dbf2af26c6
1118 changed files with 664 additions and 664 deletions
@@ -0,0 +1,188 @@
# 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 `<label>` +
`#login-password` (`type=password`, `autocomplete="current-password"`),
submit "Sign in", `#login-error` `role=alert`. On submit →
`POST /api/login`; 204 → `location` to `?next` (same-origin `/…` only,
default `/sources.html`); 401 → announce + keep form. On load:
`GET /api/whoami` already-admin → redirect to `next` immediately.
- **Chat (`index.html`, `app.js`):** `.header-inner` gains
`#sign-in-link` (`<a>` → `/login.html?next=/sources.html`) and
`#sign-out-btn` (`<button>`, aria-label "Sign out") — exactly one
visible, decided by `/api/whoami` at load. Anonymous:
`#steering-toggle` + `#steering-panel` `hidden`, no steering notes
fetch, and **no `.tune-btn` injected on new or Phase-14-restored
messages**. Sign out → `POST /api/logout` → `location.reload()`.
- **Sources (`sources.html`, `sources.js`):** new `#sources-gate` block
(heading "Sign in to view the full catalog", copy, sign-in link →
`/login.html?next=/sources.html`, ≥44px). `sources.js` fetches whoami
**before** `/api/docs`: anonymous → show gate, hide stat cards +
`.sources-shell` table, 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
1. `app/config.py` — add `admin_password: str = ""`, `session_secret: str =
""`, `session_max_age: int = 43_200`, `session_cookie: str =
"bor_session"` (documented as auth settings).
2. `app/core/auth.py` (new) — `ensure_admin_configured(settings) -> None`
(RuntimeError naming missing `BOR_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.
3. `app/api/auth.py` (new) — `login` / `logout` / `whoami` routes;
`LoginRequest` schema in `app/schemas.py`; mount in `app/main.py`
(with the other API routers, before the static catch-all).
4. `app/main.py::create_app` — call `ensure_admin_configured(settings)`
first; `app.add_middleware(SessionMiddleware, secret_key=…, max_age=…,
same_site="lax")`.
5. `app/api/docs.py`, `app/api/steering.py` — `require_admin` on
`list_documents` and on the steering router; `get_document_content`
stays public (docstring: soft rule, Phase 16).
6. `frontend/login.html`, `frontend/assets/login.js`,
`frontend/assets/styles.css` — login page per Design (landmarks,
skip-link, label, live error, Phase-08 tokens).
7. `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.
8. `frontend/sources.html`, `frontend/assets/sources.js`,
`frontend/assets/styles.css` — `#sources-gate`; whoami-before-docs
fetch; hide stats + table for anonymous.
9. 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.
10. Test fixtures — `tests/conftest.py`: `os.environ.setdefault`
`BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` (known test values) **before**
the `app.main` import (same pattern as `BOR_RELEVANCE_THRESHOLD`);
`tests/e2e/conftest.py`: set both in `app_server`'s env + export an
`ADMIN_PASSWORD` constant 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).
11. 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; plus `create_app` raising
with `app.main.settings` monkeypatched invalid); `check_password`
(match / mismatch / empty); `require_admin` (admin session passes,
anonymous → 403); whoami payload shape; `sign_in`/`sign_out` session
semantics.
- **Integration** (`tests/integration/test_auth_api.py`): wrong password →
401 and subsequent `/api/steering` still 403; correct → 204 + cookie →
whoami admin, `/api/docs` 200, steering GET/POST/DELETE 201/200/204;
logout → 403 again; **anonymous** `/api/documents/content` still 200
(seeded doc) and `/api/chat` still streams (regression guard); `/api/whoami`
anonymous shape.
- **Coverage:** `uv run pytest --cov=app --cov-report=term-missing` **>90%**
on `app/`.
- **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 pyright` clean.
## Completion Criteria
- [ ] `uv run uvicorn app.main:app` with `BOR_ADMIN_PASSWORD` unset fails at
startup naming the missing variable(s); with both auth vars set it
serves.
- [ ] Anonymous: `POST /api/chat` streams; `GET /api/docs` → 403;
`GET /api/documents/content?source=…&path=…` → 200; `/sources.html`
shows `#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 pyright` clean.
- [ ] `uv run pytest tests/e2e/test_admin_auth.py -v --no-cov` green 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=alert` error,
centered card in the standard frame, no CDN tags.
- [ ] One `--no-gpg-sign` commit (below); `.agents/phases/todo/16_admin_auth.md`
moved to `.agents/phases/complete/`.
## Commit
```bash
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"
```