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:
@@ -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"
|
||||
```
|
||||
Reference in New Issue
Block a user