From cbc263a4b263f9540ea8ee6bac81a9e8e397cc26 Mon Sep 17 00:00:00 2001
From: ducoterra
Date: Sun, 23 Aug 2026 19:58:39 -0400
Subject: [PATCH] =?UTF-8?q?feat(auth):=20single-admin=20password=20login?=
=?UTF-8?q?=20(signed=20cookie)=20=E2=80=94=20gate=20tuning=20+=20Sources?=
=?UTF-8?q?=20catalog,=20keep=20chat=20and=20document=20viewer=20public?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.agent/phases/complete/01_infrastructure.md | Bin 3640 -> 0 bytes
.../complete/02_story_import_documents.md | 76 ----
.agent/phases/complete/03_story_chat_rag.md | 74 ----
.../complete/04_story_honest_deflection.md | 66 ----
.../complete/05_story_suggestion_chips.md | 59 ----
.../complete/06_story_loading_feedback.md | 64 ----
.../complete/07_story_responsive_polish.md | 57 ---
.../complete/09_story_retrieval_quality.md | 164 ---------
.agent/phases/complete/11_long_answers.md | 52 ---
.env.example | 8 +
README.md | 67 +++-
app/api/auth.py | 63 ++++
app/api/docs.py | 18 +-
app/api/steering.py | 20 +-
app/config.py | 14 +
app/core/auth.py | 89 +++++
app/main.py | 26 ++
app/schemas.py | 17 +
frontend/assets/app.js | 63 +++-
frontend/assets/login.js | 80 +++++
frontend/assets/sources.js | 27 +-
frontend/assets/styles.css | 124 +++++++
frontend/index.html | 11 +
frontend/login.html | 62 ++++
frontend/sources.html | 15 +
pyproject.toml | 4 +
tests/conftest.py | 21 ++
tests/e2e/auth_helpers.py | 38 ++
tests/e2e/conftest.py | 10 +
tests/e2e/test_admin_auth.py | 326 ++++++++++++++++++
tests/e2e/test_chat_persistence.py | 5 +-
tests/e2e/test_dark_tech_theme.py | 6 +-
tests/e2e/test_document_back_navigation.py | 3 +-
tests/e2e/test_document_viewer.py | 3 +-
tests/e2e/test_header_consistency.py | 18 +
tests/e2e/test_import_documents.py | 12 +-
tests/e2e/test_responsive_polish.py | 12 +-
tests/e2e/test_retrieval_quality.py | 14 +-
tests/e2e/test_steering.py | 8 +
tests/integration/test_api.py | 4 +
tests/integration/test_auth_api.py | 190 ++++++++++
tests/integration/test_docs_api.py | 12 +-
tests/integration/test_importer_e2e.py | 4 +-
tests/integration/test_steering.py | 76 ++--
tests/unit/test_auth.py | 153 ++++++++
uv.lock | 11 +
46 files changed, 1555 insertions(+), 691 deletions(-)
delete mode 100644 .agent/phases/complete/01_infrastructure.md
delete mode 100644 .agent/phases/complete/02_story_import_documents.md
delete mode 100644 .agent/phases/complete/03_story_chat_rag.md
delete mode 100644 .agent/phases/complete/04_story_honest_deflection.md
delete mode 100644 .agent/phases/complete/05_story_suggestion_chips.md
delete mode 100644 .agent/phases/complete/06_story_loading_feedback.md
delete mode 100644 .agent/phases/complete/07_story_responsive_polish.md
delete mode 100644 .agent/phases/complete/09_story_retrieval_quality.md
delete mode 100644 .agent/phases/complete/11_long_answers.md
create mode 100644 app/api/auth.py
create mode 100644 app/core/auth.py
create mode 100644 frontend/assets/login.js
create mode 100644 frontend/login.html
create mode 100644 tests/e2e/auth_helpers.py
create mode 100644 tests/e2e/test_admin_auth.py
create mode 100644 tests/integration/test_auth_api.py
create mode 100644 tests/unit/test_auth.py
diff --git a/.agent/phases/complete/01_infrastructure.md b/.agent/phases/complete/01_infrastructure.md
deleted file mode 100644
index 7efb6c569644734b8f76daf15b87cdfea8b80f50..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 3640
zcmaJ^-EP}P7T#+<#esuOC0Ud#yUmY`ETB3{5Hv`=ak4|x(S$zy`X73smo{_Wwz+Su
zq-UnpMdp>!NirB*cw;xy!62OMEqC8eNgHaNwB-0iA4kuO_VUK_VKK^9Qv31w%h{_@
zT?l&r`|*cg|NZ{=uZDPh6dor>^!^VLv*+_yZ!Y*ylH9vT&rMb(Gg?clmP!^wva+$J
zXmhowB<=lAN$Rz-M%VB|N;{ubl^fEzaeif`qsc=$Oq$hN=7^7MtF_WATC0piV~RS{
zl$*LSC_~#&T2QeNL#oxvhSVKpO;d#c@Q^Hw@qGPS`I5vqYt*=uS>;RIsT30Rn+87f
zN@jXUnJ&nel3Z=BB+ZZKgovabC2#m;o-LQAD%f0BS(z0Zk~hkEhb+Q1WNbpYeW@HZ
zp;d!HR}loJi-be3q?Zo0R-SrGbwjJHafIkg^u(9Q&&q47yzGxcVPEhKE>_47U6YMV
zCLq^v_4b;k@!q$X-_|K)Hp$P>pZ#ATqm$0Vly^~>m+7`
zej^82FI0~HqN#->90C#<@h&fAoo!o8JyDpv<=Qy(Q{-iDh%1Z0E@jeA#7S`zA%RVA{e^ew6=0W_d&VUg3NSzdN;a{A)$1x1fv
z2sY3|(6R{d$W7JOdh53L@WG?LKsUeKr`T+KX*8uZQnIS?Zd6tEB?e|8W2Tit?=saW
z_>lS4lmupcVFVqL2#$i!23Ln>5Fa5JLbi)HA7)J0}rVxJG07}NG6-moox&gL5)RT
zF~~wL+Euf`C}S)%U1v6s#deMn7VKpk$0ks!2F_5|QSx9!&oFtJMjLpZ7ZwArd5*VJ
zPD@oGh2E8v4KW+0JYmLye|$HXZ#aBhzE6K`vr73*lG0m^!XCnqmmMLYb6wTYbKHpS
z?RX`nGXs9WfPH+7QaD^c;S9o4%H(4G6EAI@*2+0}j;6YXf(ntiX?dxE1u`9$+(i)Q18tWq6ombfg^oIj9zma*%2#K4cihIfT=Tv^PYA
zg}lMr6$Z1ZvRsxB7zA%wu2TK!jR2?ZYauu2$2&|)*&H!UE%s5Wpo@jzjOhMM7JJ)_P15rj#!5zTFAvK%W
z-{88t^pWK9C@Kh8TxVhe(9T+UR1)Zvk`}Tu*Zm-oXW`4PQ7b05qM&p}_wN(`7q7;~
zteREo)GCngL0%dwdx{sS{x8om78Rl`gUmOl%@6$95
z0e<-HADHwY{S4=BWP8U)#}Cq@N9pkdd88jHI@Tn#8{^3dpPfg+I2lb&`s^19&w`F`
zs1!gQ<(-ZEUvZCOE`pk+W(ih-fQ->UPBPufB(DITSpe$^0~hcIiG)2sOIg4vyu@|j
zU6ls5w`n2Q>Et0~K!Rx`m%LF}u~x9NEgmA@Z3i?Mbod+$IPA}(lSO}v(4F3jus=Xw
z_KJHOy{!Mwz9I4LD|$MAIe#;!v)RSj?CJcoo4}qWIEqe<$G6nD$5Px6NT^LAD>tPe
z#X}12d5`oM-yd1etj=1`{#%`6(Lf!r2%ARgOpQbRPLVdW+Qnr7koz90{X@5<&mM4j
zc#V?+4~hkz+UDRFamteCu|8q*gA8Hmq$tQm
zTy3fi=J%GIVnvE#8buWB!}7_x=fXV(HcS+9nYEP<`j5?)e?@H3udt3=kBGtDI|_?2
zW9tCXV;C94dsf3DCT*^m3lU)IO^QFfgFxlj2KKEDU0lrR_3SwVJgU>tNgqx+v7Mrl
z$6un*${*g6+m2#~^!V|gI^{xjF>_$AG3O!f3RAcFi;j~79d&91PuPd{dWN{Ry7Z}F
z6h=!_^^Owof;JfJIR7@G1;j_vf&-S|>3;F}nm0`{OL`r4Po98h_?0(1e-Wy|6(!E>
T?k}d@jIrBZP@pZ*^^^Ys*e~a8
diff --git a/.agent/phases/complete/02_story_import_documents.md b/.agent/phases/complete/02_story_import_documents.md
deleted file mode 100644
index 8c6ef1f..0000000
--- a/.agent/phases/complete/02_story_import_documents.md
+++ /dev/null
@@ -1,76 +0,0 @@
-# Phase 02 — Story: Import Documents
-
-**Story:** `.agent/user_stories/import-documents.md`
-**Context:** `.agent/PLAN.md` §5 (data model), §9 (logging), §11 (import workflow)
-
-## Goal
-The importer (`scripts/import_docs.py`) + `GET /api/docs` + the Sources page
-rendering the indexed documents — the knowledge base becomes refreshable.
-
-## Implementation steps
-1. `app/rag/__init__.py`, `app/rag/chunker.py` — markdown-aware chunker
- (PLAN §5 policy: heading splits, 2000-char target, 200 overlap, keep
- nearest heading). Pure functions, fully unit-testable.
-2. `app/rag/llm.py` — `LLMClient` (openai async) with `embed(texts) ->
- list[list[float]]` (batched, `BOR_EMBED_BATCH_SIZE`) and a
- `embed_one`; dimension check vs `settings.embedding_dim` with a loud,
- actionable error. (Chat streaming is added in Phase 03 on this client.)
-3. `app/rag/importer.py` — the core: directory walk (exclusion list, PLAN
- A9; `*.md` only), sha256 delta vs `documents.content_hash`,
- upsert-or-skip, two-phase chunk replace (insert doc → replace chunks →
- embed → commit), `--prune` support, per-file + summary logging.
-4. `scripts/import_docs.py` — CLI wrapper (argparse): repeatable
- `--source` (default `~/Homelab` `~/Deployments`, `expanduser`),
- `--prune`, `--limit`.
-5. `app/api/docs.py` — `GET /api/docs` → `{"documents": [DocSummary]}`
- (include `chunks` count via `func.count`); mount in `app/main.py`
- **before** the static mount.
-6. `frontend/assets/sources.js` + `sources.html` polish — wire the real
- endpoint (already scaffolded to expect this shape); keep the empty state.
-7. Update `README.md` §Knowledge Base Import with the final commands +
- exclusion list + "update your docs → re-run the script" workflow.
-
-## UI Verification
-Compare `/sources.html` against the story's "UI Visualization & Structure":
-stat cards `auto-fit minmax(170px,1fr)`; full-width table (≥85% container);
-mono path column with `title` ellipsis; empty state with the exact command;
-``, `scope="col"`, scroll wrapper
-`role="region" tabindex="0"`. No CDN refs. Take a 1280px and 375px
-screenshot pass before finishing.
-
-## Testing & Quality
-- Unit: chunker (heading splits, overlap, short-doc single chunk, code
- fences kept intact), exclusion walk (temp tree with `.venv` junk),
- delta logic (unchanged/changed/pruned via tmp Postgres or in-memory fakes
- — real DB preferred since compose runs locally).
-- Integration: `GET /api/docs` empty shape + populated shape; importer
- end-to-end against `tests/fixtures/docs/` into a test schema.
-- Coverage: `uv run pytest --cov=app --cov-report=term-missing` — **>90%**
- on `app/` (importer + chunker + client are the bulk; test them hard).
-
-## Playwright Execution Phase
-Run ONLY this story's suite (DB must be up: `podman compose up -d db`):
-
-```bash
-uv run pytest tests/e2e/test_import_documents.py -v --no-cov
-```
-
-The test file implements the story's Playwright Mapping Rule (seed via the
-import function against `tests/fixtures/docs/` with the mock LLM; assert
-Sources page rows, layout width, and the empty state).
-
-## Success criteria
-- [ ] `uv run python -m scripts.import_docs` (fixtures) imports all 3 docs,
- re-run reports `unchanged`
-- [ ] `GET /api/docs` + Sources page show the docs (real run: `~/Homelab`
- + `~/Deployments` counts logged)
-- [ ] unit + integration green, coverage >90%
-- [ ] UI verification passed (screenshots attached to the phase record)
-- [ ] story E2E green in isolation
-- [ ] README import section updated
-- [ ] committed
-
-## Commit
-```bash
-git add -A && git commit --no-gpg-sign -m "feat(kb): markdown importer with sha256 deltas, chunking, batched embeddings, and Sources page"
-```
diff --git a/.agent/phases/complete/03_story_chat_rag.md b/.agent/phases/complete/03_story_chat_rag.md
deleted file mode 100644
index 1d9bb80..0000000
--- a/.agent/phases/complete/03_story_chat_rag.md
+++ /dev/null
@@ -1,74 +0,0 @@
-# Phase 03 — Story: Chat RAG Answer (happy path)
-
-**Story:** `.agent/user_stories/chat-rag-answer.md`
-**Context:** `.agent/PLAN.md` §3 (data flow), §4 (SSE contract), §6 (persona), §9 (logging)
-
-## Goal
-The core product loop: question → embed → cosine top-4 → full top-2
-documents → `turbo` (streamed) → chippy grounded answer with source chips.
-
-## Implementation steps
-1. `app/rag/retriever.py` — `retrieve(db, question_embedding) ->
- list[RetrievedChunk]` (score = 1 − distance, `ORDER BY embedding <=> $1
- LIMIT BOR_TOP_K_CHUNKS`) + `select_documents(chunks, n) -> list[Document]`
- (distinct by `document_id`, ranked by best chunk score, cap content at
- `BOR_MAX_CONTEXT_CHARS` with `[…truncated…]`).
-2. `app/rag/prompts.py` — locked persona + HONESTY GATE prompt builder
- (PLAN §6 verbatim, `HIGH|LOW `, ``
- block; LOW mode includes the `DEFLECT_MODE` marker + weak-hit titles).
-3. `app/rag/llm.py` — add `chat_stream(messages) -> AsyncIterator[str]`
- (openai async, `stream=True`, `model=turbo`, temperature 0.4,
- max_tokens ~700).
-4. `app/api/chat.py` — `POST /api/chat` (ChatRequest) → `StreamingResponse`
- (SSE): emit `delta` events from the stream, then the `done` event
- (deflected, sources, suggestions); insert `query_log` row (deflected=
- false this phase); per-turn log line (PLAN §9); structured error events
- (`{"type":"error","detail":…}`) on LLM/DB failure.
-5. `frontend/assets/app.js` — replace the placeholder handler: `fetch` +
- `ReadableStream` SSE parser; render deltas live into a brain bubble
- (reuse the typing-indicator → streaming handoff); on `done`, append
- `.source-chip`s under the bubble; on error, show the banner (full
- state machine is Phase 06 — keep it simple-correct here).
-6. Tune `settings.suggestions` if the real Homelab import revealed better
- defaults (optional here; Phase 05 owns the chips).
-
-## UI Verification
-Against the story's "UI Visualization & Structure": bubbles right/left
-(brand vs surface, ≥4.5:1 text), avatar 🧠, source chips mono/brand-soft
-with `source/path` and ellipsis, safe markdown (paste an answer containing
-`` from the mock to prove it's escaped). Chat
-column 46rem centered. 1280px + 375px screenshot pass.
-
-## Testing & Quality
-- Unit: retriever ordering/dedup/cap (fake rows), prompt builder (HIGH
- contains documents + `HIGH`, LOW contains `DEFLECT_MODE` + titles only,
- persona rules present verbatim), SSE event serialization.
-- Integration: `/api/chat` against the mock LLM with a seeded temp schema —
- assert SSE delta sequence, `done` payload (sources non-empty,
- deflected false), `query_log` row, error event when LLM unreachable.
-- Coverage: `uv run pytest --cov=app --cov-report=term-missing` — **>90%**.
-
-## Playwright Execution Phase
-Run ONLY this story's suite:
-
-```bash
-uv run pytest tests/e2e/test_chat_rag.py -v --no-cov
-```
-
-Implements the story mapping: streamed grounded answer + `kubernetes.md`
-source chip + button recovery; DB `query_log` assertion; raw SSE shape
-check via `httpx`.
-
-## Success criteria
-- [ ] end-to-end: question → streamed chippy answer citing `kubernetes.md`
-- [ ] `query_log` row per turn; per-turn log line in stdout
-- [ ] LLM-down path shows error banner, no stuck button
-- [ ] unit + integration green, coverage >90%
-- [ ] UI verification passed
-- [ ] story E2E green in isolation
-- [ ] committed
-
-## Commit
-```bash
-git add -A && git commit --no-gpg-sign -m "feat(rag): stream grounded chat answers via pgvector cosine retrieval with source citations"
-```
diff --git a/.agent/phases/complete/04_story_honest_deflection.md b/.agent/phases/complete/04_story_honest_deflection.md
deleted file mode 100644
index 2162edd..0000000
--- a/.agent/phases/complete/04_story_honest_deflection.md
+++ /dev/null
@@ -1,66 +0,0 @@
-# Phase 04 — Story: Honest Deflection
-
-**Story:** `.agent/user_stories/honest-deflection.md`
-**Context:** `.agent/PLAN.md` §4, §6 (honesty gate), §9
-
-## Goal
-When retrieval finds nothing relevant, Brain says so — plainly, chippily —
-and offers real alternatives. No hallucinated confidence.
-
-## Implementation steps
-1. `app/api/chat.py` — apply the gate: `best_score < settings.relevance_
- threshold` ⇒ build LOW prompt (`DEFLECT_MODE`, weak-hit titles only),
- else HIGH prompt. Set `deflected` on the `done` event + `query_log`.
-2. Deflection `suggestions[]`: ask `turbo` (same stream) to include 2–3
- alternative questions; simplest robust approach — have the LLM emit them
- inline in the answer AND have the server derive 2–3 chips from the
- weak-hit document titles (deterministic fallback if the model doesn't
- produce a parsable list). Ship the deterministic title-derived chips as
- the v1 behavior; model-generated list is a bonus if trivially parseable.
-3. `frontend/assets/app.js` — on `done.deflected`: add `.is-deflected`
- class to the bubble, render "Maybe try:" chips below it (same
- `.suggestion-chip` component; clicking fills the input — full submit
- behavior lands with Phase 05's chip component; wire what exists).
-4. `README.md` — document `BOR_RELEVANCE_THRESHOLD` tuning + the
- deflection behavior in Troubleshooting.
-
-## UI Verification
-Against the story: amber bubble (`#fff7e8` bg / `#f59e0b` border) distinct
-from normal answers; "Maybe try:" chips ≥44px, brand-soft/brand-ink;
-contrast pairs verified (ink on accent-bg ≥ 9:1, accent-ink ≥ 8:1);
-chip group has an accessible name; mobile wraps cleanly.
-
-## Testing & Quality
-- Unit: gate boundary with a fake retriever — score exactly 0.30 → HIGH;
- 0.2999 → LOW; LOW prompt contains `DEFLECT_MODE` + titles, no full docs;
- HIGH unaffected. Suggestions derivation (2–3, non-empty, derived from
- titles).
-- Integration: mock LLM — off-topic question ("sourdough") ⇒ `done`
- `deflected: true`, `query_log.deflected=true`, weak `top_score` stored;
- on-topic question ⇒ `deflected: false`.
-- Coverage: **>90%** on `app/`.
-
-## Playwright Execution Phase
-Run ONLY this story's suite:
-
-```bash
-uv run pytest tests/e2e/test_honest_deflection.py -v --no-cov
-```
-
-Implements the story mapping: off-topic question ⇒ `.is-deflected` bubble
-matching /haven't done anything like that/i + ≥2 "Maybe try:" chips; chip
-click behavior; (unit boundary test lives in pytest, not here).
-
-## Success criteria
-- [ ] off-topic question never gets a confident fake answer
-- [ ] deflected bubble visually distinct + alternative chips render
-- [ ] `query_log.deflected` accurate; threshold env-tunable
-- [ ] unit + integration green, coverage >90%
-- [ ] UI verification passed
-- [ ] story E2E green in isolation
-- [ ] committed
-
-## Commit
-```bash
-git add -A && git commit --no-gpg-sign -m "feat(rag): honest deflection gate with amber UI state and alternative-question chips"
-```
diff --git a/.agent/phases/complete/05_story_suggestion_chips.md b/.agent/phases/complete/05_story_suggestion_chips.md
deleted file mode 100644
index cc8a05d..0000000
--- a/.agent/phases/complete/05_story_suggestion_chips.md
+++ /dev/null
@@ -1,59 +0,0 @@
-# Phase 05 — Story: Suggestion Chips
-
-**Story:** `.agent/user_stories/suggestion-chips.md`
-**Context:** `.agent/PLAN.md` §7 (UI/UX), story file for chip spec
-
-## Goal
-Zero-friction onboarding: 3–4 real example questions on first load,
-clickable → filled → submitted, keyboard-first, mobile-scrollable.
-
-## Implementation steps
-1. `app/config.py` — confirm `suggestions` is env-overridable
- (`BOR_SUGGESTIONS` as JSON list via pydantic-settings) and tune the
- defaults against the *actually imported* Homelab/Deployments topics
- (read a sample of `documents` titles; pick questions real answers
- exist for).
-2. `app.js` — extract a `renderChips(container, items, {onSelect})` helper;
- real ``
- inside `#suggestions[role="list"]`; onboarding `onSelect` = fill
- `#message-input` + focus + `composer.requestSubmit()`. Reuse the same
- helper for deflection chips (Phase 04) with the same submit behavior.
-3. Empty-state lifecycle: first user message hides `#empty-state` (already
- done in `addMessage`) — verify chips don't linger in the conversation.
-4. Mobile CSS check: chip row `nowrap + overflow-x auto` at ≤640px (tokens
- already exist — verify, don't duplicate).
-
-## UI Verification
-Against the story: pills 999px radius, ≥44px, brand-soft/brand-ink (≥6:1),
-hover/active states; desktop centered wrap vs mobile single scroll row;
-Tab order reaches chips before the composer input is required; screen
-reader: group labeled "Suggested questions". Screenshot pass 1280px + 375px.
-
-## Testing & Quality
-- Unit/integration: `GET /api/suggestions` honors `BOR_SUGGESTIONS` env
- override (JSON list); default list has ≥3 non-empty strings.
-- Coverage: **>90%** on `app/` (JS is covered by E2E).
-
-## Playwright Execution Phase
-Run ONLY this story's suite:
-
-```bash
-uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov
-```
-
-Implements the story mapping: chips render (≥3, role=list); chip click
-submits (user bubble with exact chip text + mock reply); keyboard Tab+Enter
-activates; 375px chip row is a horizontal scroll row.
-
-## Success criteria
-- [ ] onboarding chips render from the API; click = one-tap question
-- [ ] keyboard + SR usable; mobile scroll row
-- [ ] deflection chips share the component + submit behavior
-- [ ] unit + integration green, coverage >90%
-- [ ] story E2E green in isolation
-- [ ] committed
-
-## Commit
-```bash
-git add -A && git commit --no-gpg-sign -m "feat(ui): onboarding suggestion chips with one-tap submit, keyboard access, and mobile scroll row"
-```
diff --git a/.agent/phases/complete/06_story_loading_feedback.md b/.agent/phases/complete/06_story_loading_feedback.md
deleted file mode 100644
index 722f480..0000000
--- a/.agent/phases/complete/06_story_loading_feedback.md
+++ /dev/null
@@ -1,64 +0,0 @@
-# Phase 06 — Story: Loading Feedback & Progress
-
-**Story:** `.agent/user_stories/loading-feedback.md`
-**Context:** `.agent/PLAN.md` §7.4 ("never stale" contract), §9
-
-## Goal
-An unambiguous state machine — `idle → thinking → streaming → done |
-error → idle` — so the user always knows what's happening, and a stale
-Send button is impossible.
-
-## Implementation steps
-1. `app.js` — formalize the state machine (single `setUiState(state)`
- function driving: typing indicator, send button disabled/spinner/label,
- `#send-status` live text). Replace ad-hoc busy handling from Phase 03.
-2. Pre-token: typing indicator (`role="status"`,
- `aria-label="Brain of Reese is thinking"`); after 10s pre-token, update
- the label with elapsed seconds (setInterval, cleared on state change).
-3. Streaming: first `delta` removes the typing indicator and starts
- appending to the answer bubble; button stays busy.
-4. Error paths: `{"type":"error"}` SSE event, non-2xx response, or
- **120s client-side timeout** (clear on first delta) → red banner
- `role="alert"` ("Try again — if this persists, check the LLM is
- reachable") + state → idle.
-5. `prefers-reduced-motion`: CSS already slows animations — verify; add a
- static fallback for the dots if needed.
-6. Server: confirm the per-turn log line includes `embed_ms` and
- `total_ms` (add if Phase 03 omitted it).
-
-## UI Verification
-Walk the full state machine by hand (dev server + mock LLM slow path):
-submit → indicator + "Thinking…" disabled button → live tokens → done
-(enabled, focused input). Kill the mock mid-stream → banner + recovery.
-Contrast of disabled button + spinner OK; reduced-motion pass.
-
-## Testing & Quality
-- Unit/integration: SSE error event serialization; timeout constant
- exported/testable; (JS logic is E2E-covered).
-- Coverage: **>90%** on `app/`.
-
-## Playwright Execution Phase
-Run ONLY this story's suite:
-
-```bash
-uv run pytest tests/e2e/test_loading_feedback.py -v --no-cov
-```
-
-Implements the story mapping (mock LLM's 3s "pretend to think slowly"
-warm-up + a fixture that stops the mock): typing indicator visible during
-pre-token and gone by answer; button disabled→"Thinking…"→enabled "Send";
-streaming appends (two-timestamp length check); LLM-down ⇒ `role=alert`
-banner + button recovered.
-
-## Success criteria
-- [ ] every in-flight state has a visible indicator; button never zombies
-- [ ] error + 120s timeout paths both recover cleanly
-- [ ] reduced-motion respected
-- [ ] unit + integration green, coverage >90%
-- [ ] story E2E green in isolation
-- [ ] committed
-
-## Commit
-```bash
-git add -A && git commit --no-gpg-sign -m "feat(ui): explicit chat state machine — typing indicator, streaming progress, timeout and error recovery"
-```
diff --git a/.agent/phases/complete/07_story_responsive_polish.md b/.agent/phases/complete/07_story_responsive_polish.md
deleted file mode 100644
index f2535bf..0000000
--- a/.agent/phases/complete/07_story_responsive_polish.md
+++ /dev/null
@@ -1,57 +0,0 @@
-# Phase 07 — Story: Responsive, Polished, Accessible UI
-
-**Story:** `.agent/user_stories/responsive-polish.md`
-**Context:** `.agent/PLAN.md` §7 (the whole UI/UX strategy)
-
-## Goal
-The final visual + accessibility audit pass across chat and Sources. No
-new features — enforce PLAN §7 end-to-end and fix every deviation.
-
-## Implementation steps
-1. Viewport sweep (360 / 375 / 768 / 1280 / 1600) on both pages: fix
- overflow, pinched columns, dead whitespace. Chat column stays ≤46rem
- centered; Sources table full-width with horizontal scroll <640px.
-2. A11y sweep (both pages): landmarks, skip link, labels on every input,
- `aria-label` on every icon-only control, `:focus-visible` outline on
- every focusable, `aria-live` regions intact, no contrast <4.5:1
- (compute, don't eyeball — use the E2E helper).
-3. Reduced-motion + long-content pass (60-char paths, long answers).
-4. No-CDN re-verification on **both** pages (extend the integration test
- to `/sources.html` if it only covers `/`).
-5. Final README polish pass: screenshots section (optional), quickstart
- sanity, "Update your documents" workflow prominent.
-
-## UI Verification
-This phase IS the verification: the E2E below is the acceptance test.
-Additionally, manual screenshot pass at 1280px + 375px for both pages,
-reviewed against PLAN §7.1–7.4 before committing.
-
-## Testing & Quality
-- Integration: no-CDN check extended to Sources page; health unchanged.
-- Coverage: **>90%** on `app/` (final state of the whole app).
-- Whole suite green: `uv run pytest` (unit+integration) — the entire
- repo must be green at this phase.
-
-## Playwright Execution Phase
-Run ONLY this story's suite:
-
-```bash
-uv run pytest tests/e2e/test_responsive_polish.py -v --no-cov
-```
-
-Implements the story mapping: no horizontal overflow at 5 viewports (both
-pages); chat column capped + centered at 1600px; Sources table ≥80%
-container at 1280px; landmarks/labels/skip-link sweep; WCAG contrast
-pairs ≥4.5:1 (computed); reduced-motion honored.
-
-## Success criteria
-- [ ] all six mapping tests pass at every viewport
-- [ ] zero known a11y deviations against PLAN §7.2
-- [ ] whole pytest suite green + coverage >90%
-- [ ] README polished
-- [ ] committed (this commit marks v1.0 feature-complete)
-
-## Commit
-```bash
-git add -A && git commit --no-gpg-sign -m "feat(ui): responsive + WCAG AA polish pass across chat and sources — v1 feature complete"
-```
diff --git a/.agent/phases/complete/09_story_retrieval_quality.md b/.agent/phases/complete/09_story_retrieval_quality.md
deleted file mode 100644
index 1631ada..0000000
--- a/.agent/phases/complete/09_story_retrieval_quality.md
+++ /dev/null
@@ -1,164 +0,0 @@
-# Phase 09 — Story: Retrieval Quality — Multi-Format Ingestion + Hybrid Search
-
-**Story:** `.agent/user_stories/retrieval-quality.md`
-**Context:** `.agent/PLAN.md` §3 (data flow), §5 (data model), §6 (retrieval), §11 (import)
-
-## Goal
-Fix "RAG retrieval is terrible": ingest the full text-format set (not
-just `.md`), purge vendored-cache junk from the index, and replace
-pure-cosine top-4 with hybrid (vector + Postgres FTS, RRF-fused)
-retrieval so name-your-tool questions find the right document.
-
-## Owner permission (recorded per phase protocol)
-> "I'm giving you explicit permission to update the locked decisions and
-> proceed with writing all 3 of these phases" — Reese, 2026-08-21.
-
-This phase revises anchors **A9** (content scope: `*.md` only →
-`md, markdown, txt, yaml, yml, json, py` + hidden-dir skip), **A7**
-(pure-cosine top-4 → hybrid RRF retrieval; the whole-document context
-contract is preserved), **A8** (gate: LOW only when best cosine <
-threshold **and** zero FTS hits; threshold re-tuned 0.30 → 0.62 default).
-`PLAN.md` anchors were updated 2026-08-21 under this permission.
-
-## Evidence (measured 2026-08-21 against the live KB + `embed` model)
-- "How did I install gitlab?": the best `gitlab.md` chunk ranks **7th**
- (cosine 0.804) — outside the top-4 window. Ranks 1–6: a vendored-cache
- README (`.esphome/.espressif/…/esp-tflite-micro/README.md`, 0.838) and
- generic templates (`project_readme_template.md`, `templates/…/foobar.
- md`, 0.81–0.82). The LLM therefore answered from junk docs and honestly
- reported "no notes on gitlab".
-- Corpus cosine range: **0.41–0.84** — the old 0.30 gate never
- discriminated.
-- FTS: `plainto_tsquery('english','gitlab')` matches **exactly**
- gitlab.md's 4 chunks and nothing else.
-- ~470 of 672 indexed docs live under dot-prefixed path components
- (vendored caches) that A9's exclusion list doesn't cover.
-
-## Dependencies
-Phases 01–07 (02 importer, 03 retriever, 04 gate especially).
-Independent of 08 (backend + E2E only). Phase 10 builds on the new
-multi-format corpus.
-
-## Implementation steps
-1. **Config** (`app/config.py`): `import_extensions` (csv, default
- `md,markdown,txt,yaml,yml,json,py`; env `BOR_IMPORT_EXTENSIONS`),
- `hybrid_vector_candidates` (30, `BOR_HYBRID_VECTOR_CANDIDATES`),
- `hybrid_lexical_candidates` (30, `BOR_HYBRID_LEXICAL_CANDIDATES`),
- `rrf_k` (60, `BOR_RRF_K`), `relevance_threshold` default **0.62**
- (re-tuned; env override stays). In `tests/e2e/conftest.py`'s
- `app_server` fixture set `BOR_RELEVANCE_THRESHOLD=0.30` — the mock's
- token-overlap embeddings need their own calibration; this keeps
- stories 02–07's E2E suites green.
-2. **Chunker** (`app/rag/chunker.py`): add a `chunk_document(content,
- path)` dispatcher by lowercased suffix + per-format functions —
- **stdlib only, no new dependencies**:
- - `yaml`/`yml`: split on `---` document separators and top-level keys
- (indent-0 `key:` lines); every chunk keeps its key line as anchor.
- - `json`: `json.dumps(obj, indent=2)` then split at top-level keys
- (track brace depth); unparseable JSON → paragraph packing.
- - `py`: stdlib `ast` top-level node line ranges → split at
- defs/classes; oversized functions fall back to line packing.
- - `txt`: paragraph packing (reuse `_paragraph_blocks`/`_pack_blocks`).
- - All formats honor `HARD_MAX_CHARS` (1200 — the aipi ~1024-token
- request cap) and the target/overlap settings; the `md` path stays
- byte-for-byte unchanged (existing chunker tests must stay green).
-3. **Importer** (`app/rag/importer.py`, `scripts/import_docs.py`):
- extension filter (case-insensitive, config-driven); **skip any path
- containing a dot-prefixed component** (hidden dirs); chunker dispatch
- by suffix; `--prune` now also drops docs whose files **no longer
- match the filter** (this is how the ~470 junk docs leave the index);
- summary log gains per-format counts
- (`formats=md:203,yaml:267,…`).
-4. **Migration `0002_hybrid_retrieval.py`** (alembic):
- - `chunks.tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('english',
- content)) STORED` + `CREATE INDEX … USING gin (chunks.tsv)`.
- - `query_log.fts_hits INT` (nullable; pre-existing rows stay NULL).
-5. **Retriever** (`app/rag/retriever.py`) — hybrid path:
- - `retrieve(db, question, question_embedding)`: vector top-N (cosine,
- as today) ∪ lexical top-N — `to_tsquery('english', )` (skip pure-stopword/no-token
- questions → empty lexical list), ordered by `ts_rank` — fused with
- RRF: `score = Σ 1/(k + rank)` over the lists a chunk appears in
- (single-list chunks get one term; k from config).
- - `RetrievedChunk` gains `cosine` (for the gate) and `fts_hit: bool`
- alongside `score` (now the fused score, used for ranking);
- `select_documents` / `weak_hit_titles` keep working off `score`.
- - Deterministic tie-break: `(−fused, −cosine, document.path,
- chunk.position)`.
-6. **Chat flow + gate** (`app/api/chat.py`): pass the raw question into
- `retrieve`; **LOW only when `best_cosine < threshold and fts_hits ==
- 0`** (`fts_hits` = count of lexical candidates matched); per-turn log
- line gains `fts_hits=…` (PLAN §9); `query_log` row stores `fts_hits`.
-7. **Eval script** `scripts/eval_retrieval.py`:
- `uv run python -m scripts.eval_retrieval "q1" "q2" …` (or
- `--from-file questions.txt`) — embeds via aipi, runs the hybrid
- search, prints top-5 docs per question with cosine/fts/fused scores +
- the gate verdict. Requires `AIPI_KEY` in the environment (same
- convention as `llm_probe.py`).
-8. **Re-import the live KB** (one-time; expect ~15–40 min of embedding
- batches — the importer logs per file):
- `uv run python -m scripts.import_docs --prune`. Expect ~470
- hidden-dir docs pruned and ~500 docs indexed (md + new formats). Then
- verify with the eval script:
- - "How did I install gitlab?" → top doc `active/container_gitlab/
- gitlab.md` (the compose yaml should land in the top-2).
- - "How is my Kubernetes cluster set up?" → kubernetes docs.
- - "sourdough starter" → LOW (deflect).
- If the gitlab case isn't #1, iterate the **fusion** (k, candidate
- counts, token handling) — not the threshold — until it is, and record
- the final numbers in the phase report.
-9. **E2E fixtures** (`tests/fixtures/docs/`): add
- `homelab/container_gitlab/gitlab.md` (H1 "Gitlab", docker install
- steps, "gitlab" repeated), `homelab/container_gitlab/
- gitlab-compose.yaml` (`services: gitlab: …`), a `.py` note, a `.json`
- note, a `.txt` note, and `.hidden/junk.md` (must never be imported).
- Follow the existing in-process seeding pattern from
- `tests/e2e/test_import_documents.py`.
-10. **README**: import workflow section — supported formats, hidden-dir
- skip, `scripts/eval_retrieval.py`, threshold tuning; note that the
- Sources count drops after the prune (intended cleanup).
-
-## Testing & Quality
-- **Unit:** chunker per format (yaml top-level + `---` split, json
- top-level keys + pretty-print + unparseable fallback, py ast split +
- oversized-func fallback, txt paragraphs, dispatch, 1200-cap) with md
- output unchanged; importer (hidden-dir skip, extension filter,
- prune-when-filtered-out, per-format summary); retriever (RRF math:
- both-lists / one-list / tie-break; OR tsquery construction incl.
- no-token and stopword-only questions; gate: `cosine ≥ T` → HIGH;
- `cosine < T` + `fts>0` → HIGH; `cosine < T` + `fts=0` → LOW; boundary
- exactly `T` → HIGH).
-- **Integration:** `/api/chat` hybrid against a seeded temp schema —
- keyword question grounded + `fts_hits` in `query_log`; off-topic
- deflected with `fts_hits=0`; migration up clean.
-- **Coverage:** `uv run pytest --cov=app --cov-report=term-missing` —
- **>90%** on `app/`.
-- **No regressions:** existing story E2E suites (02–07) green in
- isolation after the change (the conftest threshold override is what
- keeps them green — verify each one).
-
-## Playwright Execution Phase
-Run ONLY this story's suite:
-
-```bash
-uv run pytest tests/e2e/test_retrieval_quality.py -v --no-cov
-```
-
-Implements the story mapping: multi-format fixture import (hidden doc
-excluded, `/api/docs` counts); "How did I install gitlab?" → grounded,
-not deflected, gitlab chip, `query_log` row; keyword-only question beats
-vector ranking (FTS-OR gate end to end); "sourdough" → deflected bubble +
-≥2 chips.
-
-## Success criteria
-- [ ] live eval: "How did I install gitlab?" → `gitlab.md` is the top doc
-- [ ] zero dot-prefixed path components in `documents` after re-import
-- [ ] off-topic still deflects; on-topic still grounds (new + existing E2E)
-- [ ] unit + integration green, coverage >90%, ruff + pyright green
-- [ ] README documents formats / hidden-dir skip / eval / tuning
-- [ ] committed
-
-## Commit
-```bash
-git add -A && git commit --no-gpg-sign -m "feat(rag): hybrid FTS+vector retrieval and multi-format ingestion — name-your-tool questions find the right document"
-```
diff --git a/.agent/phases/complete/11_long_answers.md b/.agent/phases/complete/11_long_answers.md
deleted file mode 100644
index b38af3f..0000000
--- a/.agent/phases/complete/11_long_answers.md
+++ /dev/null
@@ -1,52 +0,0 @@
-# Phase 11 — Long Answers (No Truncation)
-
-**Story:** `.agent/user_stories/long-answers.md`
-**Context:** owner report 2026-08-22 — "responses keep getting cut off.
-It should be allowed to respond up to 32768 tokens."
-
-## Goal
-Remove the hard 700-token output cap on chat answers; the model may
-respond up to **32 768** tokens (`BOR_MAX_OUTPUT_TOKENS`, default
-32 768).
-
-## Diagnosis
-`app/rag/llm.py::chat_stream` calls `chat.completions.create(...,
-max_tokens=700, ...)`. Long answers die mid-sentence at ~700 tokens.
-
-## Implementation steps
-1. **Config** (`app/config.py`): `max_output_tokens: int = 32_768` in the
- RAG-tuning section (env `BOR_MAX_OUTPUT_TOKENS`); document in
- `.env.example`.
-2. **LLM client** (`app/rag/llm.py`): `chat_stream` passes
- `max_tokens=self.settings.max_output_tokens`.
-3. **E2E mock** (`tests/e2e/mock_llm.py`):
- - Honor `max_tokens` deterministically: token ≈ whitespace word; if
- the composed answer is longer, truncate to the first N words.
- (With the old 700 cap a long answer loses its tail — the mock now
- behaves like the real endpoint.)
- - New trigger: user message containing `write a long answer` →
- deterministic ~4 000-word numbered answer ending in a unique final
- line (`LONG-ANSWER-END`).
- - No behavior change for existing (short) answers: they fit under any
- sane cap.
-4. **Tests:**
- - Unit: settings default + env override (`test_config.py`);
- `chat_stream` forwards the configured `max_tokens` (fake client in
- `test_llm_client.py`).
- - E2E: `tests/e2e/test_long_answers.py` per the story mapping.
-
-## Locked decisions
-None touched. A5 (aipi endpoint) unchanged; `turbo` accepts the larger
-cap per owner instruction.
-
-## Testing & Quality
-- Unit + integration green; `uv run pytest --cov=app --cov-report=term-missing`
- **>90%**; E2E in isolation:
- `uv run pytest tests/e2e/test_long_answers.py -v --no-cov`.
-- No regressions: `test_chat_rag.py` + `test_chat_api.py` green
- (short answers unaffected by the mock's new `max_tokens` honoring).
-
-## Commit
-```bash
-git add -A .agent/ app/ tests/ .env.example && git commit --no-gpg-sign -m "fix(rag): lift chat output cap to 32768 tokens — long answers no longer cut off"
-```
diff --git a/.env.example b/.env.example
index 0b35777..dcc5b3f 100644
--- a/.env.example
+++ b/.env.example
@@ -36,6 +36,14 @@ BOR_RRF_K=60 # Reciprocal Rank Fusion damping constant
# BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py
# BOR_SUGGESTIONS=["How is my Kubernetes cluster set up?"] # JSON list of onboarding chips
+# --- Admin & sign-in (single-admin password login; BOTH required) ---
+# The app refuses to start while either is empty (names the missing
+# variable(s) — README "Admin & sign-in"). Generate the secret with:
+# python -c 'import secrets;print(secrets.token_hex(32))'
+BOR_ADMIN_PASSWORD=
+BOR_SESSION_SECRET=
+# BOR_SESSION_MAX_AGE=43200 # signed-cookie lifetime, seconds (default 12 h, sliding)
+
# --- Debugging (0/1 — 1 enables attach-on-demand debugpy on port 5678) ---
DEBUGPY=0
# DEBUGPY_PORT=5678
diff --git a/README.md b/README.md
index d0f223d..1d95fd6 100644
--- a/README.md
+++ b/README.md
@@ -83,10 +83,72 @@ uv run uvicorn app.main:app --reload
is shown as escaped monospace text. Unknown documents get a designed
not-found state with a link back to the index.
- **Sources** (`/sources.html`) — the indexed document list; the *Path*
- column links each document to the viewer in a new tab.
+ column links each document to the viewer in a new tab. **Admin-only** —
+ anonymous visitors see a sign-in gate instead (the catalog is what the
+ login locks; the document viewer itself stays open to everyone).
+
+## Admin & sign-in
+
+Brain of Reese has exactly **one account: the admin (you)**. Signing in
+unlocks the **full Sources catalog** and the **answer-tuning** controls;
+everyone else stays anonymous and keeps **chat** and the **document
+viewer** (any document an answer cites can be opened by its direct URL —
+the catalog is gated, not the viewer).
+
+### Setup (one-time)
+
+```bash
+python -c 'import secrets;print(secrets.token_hex(32))' # → paste into .env
+```
+
+```env
+BOR_ADMIN_PASSWORD=your-password # plaintext — homelab scope, by design
+BOR_SESSION_SECRET= # signs the session cookie
+```
+
+**Fail-loud:** while either variable is empty the app refuses to start,
+naming the missing one(s):
+
+```
+RuntimeError: Brain of Reese cannot start: admin auth is not configured.
+Set the missing variable(s): BOR_ADMIN_PASSWORD, BOR_SESSION_SECRET …
+```
+
+### How it works
+
+- `POST /api/login {"password": …}` → `204` + signed `bor_session` cookie
+ (Starlette `SessionMiddleware` — an itsdangerous-signed cookie, no
+ server-side store, no new service, no DB table); any mismatch → `401`
+ `{"detail": "invalid password"}` (constant-time compare, one generic
+ message — no user enumeration, there is only one user).
+- `POST /api/logout` → `204` (session cleared and cookie expired;
+ idempotent for anonymous callers).
+- `GET /api/whoami` → `{"authenticated": bool, "role": "admin"|"anonymous"}`
+ — the single source of truth for every UI gating decision.
+- Cookie flags: `same_site="lax"`, `https_only` off — **no HTTPS
+ enforcement on purpose** (homelab HTTP; the cookie is single-admin
+ convenience, not a cloud boundary). Max age `BOR_SESSION_MAX_AGE`
+ (default `43200` = 12 h, refreshed while active).
+- Sign in from the chat header (**Sign in**) or `/login.html` directly;
+ the header then offers **Sign out** (logout + reload).
+
+### Who can do what
+
+| Capability | Anonymous | Admin (signed in) |
+|---|---|---|
+| Chat (`/`) + suggestion chips | yes | yes |
+| Document viewer (`/document.html?source=…&path=…`) | yes — any indexed doc by direct URL | yes |
+| Sources catalog (`/sources.html`, `GET /api/docs`) | sign-in gate | full catalog |
+| Tuning (Tune button, Tuning panel, `/api/steering`) | UI hidden | full |
+
+The public API endpoints stay stateless — the signed cookie is the only
+session state in the system.
## Tuning your answers
+*Admin-only* — sign in first (see **Admin & sign-in** above); anonymous
+visitors never see the Tune button or the Tuning panel.
+
If an answer isn't quite right — too chatty, wrong assumption, missing
context — **tune** Brain right there:
@@ -300,6 +362,9 @@ served locally (no CDN), `BOR_ENVIRONMENT=production`.
| `BOR_MAX_CONTEXT_CHARS` | `24000` | cap on total document text sent to the LLM |
| `BOR_STEERING_MAX_CHARS` | `8000` | char budget for the `` (steering notes) prompt section |
| `BOR_SUGGESTIONS` | built-in list | JSON list of onboarding chips |
+| `BOR_ADMIN_PASSWORD` | *(required)* | the single admin's password (plaintext, `.env`); app refuses to start when empty |
+| `BOR_SESSION_SECRET` | *(required)* | signing key for the `bor_session` cookie; `python -c 'import secrets;print(secrets.token_hex(32))'` |
+| `BOR_SESSION_MAX_AGE` | `43200` | session-cookie lifetime in seconds (12 h, sliding) |
| `DEBUGPY` | `0` | `1` ⇒ attach-on-demand debugpy on `DEBUGPY_PORT` (default 5678) |
| `BOR_LOG_LEVEL` | `INFO` | app log level |
diff --git a/app/api/auth.py b/app/api/auth.py
new file mode 100644
index 0000000..8c8ef24
--- /dev/null
+++ b/app/api/auth.py
@@ -0,0 +1,63 @@
+"""Auth API — single-admin sign-in (phase 16; A10 revised 2026-08-22).
+
+* ``POST /api/login`` — 204 + signed session cookie on success; 401
+ ``invalid password`` on any mismatch (constant-time, one generic
+ message, no session set).
+* ``POST /api/logout`` — 204; clears the session and expires the cookie
+ (idempotent for anonymous callers).
+* ``GET /api/whoami`` — ``{"authenticated": bool, "role":
+ "admin"|"anonymous"}``; the single source of truth for all UI gating.
+
+The public API otherwise stays stateless (A10): chat, the document
+content endpoint (soft rule — anonymous may open any document by direct
+URL), suggestions, and health never require the cookie.
+"""
+from __future__ import annotations
+
+from fastapi import APIRouter, HTTPException, Request, Response
+
+from app.config import get_settings
+from app.core.auth import ADMIN_SESSION_KEY, check_password, sign_in, sign_out
+from app.schemas import LoginRequest, WhoamiResponse
+
+router = APIRouter(tags=["auth"])
+
+
+@router.post("/login", status_code=204)
+def login(payload: LoginRequest, request: Request) -> Response:
+ """Sign in the single admin.
+
+ Success: 204 + the signed ``bor_session`` cookie (``same_site=lax``,
+ 12 h default lifetime). Failure: one generic 401 — the admin count is
+ one, so there is nothing else to leak, and a wrong password must not
+ set any session state.
+ """
+ settings = get_settings()
+ if check_password(payload.password, settings.admin_password):
+ sign_in(request.session)
+ return Response(status_code=204)
+ raise HTTPException(status_code=401, detail="invalid password")
+
+
+@router.post("/logout", status_code=204)
+def logout(request: Request, response: Response) -> Response:
+ """Sign out: clear the session AND expire the browser cookie.
+
+ ``sign_out`` empties the session dict (which the middleware does not
+ re-persist — an empty session has nothing to sign), so this route also
+ sends ``delete_cookie`` to make the browser drop the signed cookie
+ right now. Idempotent: an anonymous logout is still a 204.
+ """
+ sign_out(request.session)
+ response.delete_cookie(get_settings().session_cookie, path="/")
+ return Response(status_code=204)
+
+
+@router.get("/whoami", response_model=WhoamiResponse)
+def whoami(request: Request) -> WhoamiResponse:
+ """Who is the caller? Drives every UI gating decision (phase 16)."""
+ authenticated = bool(request.session.get(ADMIN_SESSION_KEY))
+ return WhoamiResponse(
+ authenticated=authenticated,
+ role="admin" if authenticated else "anonymous",
+ )
diff --git a/app/api/docs.py b/app/api/docs.py
index 74ba818..44cea24 100644
--- a/app/api/docs.py
+++ b/app/api/docs.py
@@ -13,6 +13,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.orm import Session
+from app.core.auth import require_admin
from app.db import get_db
from app.models import Chunk, Document
from app.schemas import DocContent, DocList, DocSummary
@@ -28,11 +29,17 @@ def doc_format(path: str) -> str:
@router.get("/docs", response_model=DocList)
-def list_documents(db: Session = Depends(get_db)) -> DocList: # noqa: B008
+def list_documents(
+ db: Session = Depends(get_db), # noqa: B008
+ _admin: None = Depends(require_admin), # noqa: B008
+) -> DocList:
"""All indexed documents with per-document chunk counts.
- An empty list means the knowledge base has not been imported yet —
- the Sources page renders its designed empty state in that case.
+ Admin-only (phase 16 — the catalog is what the sign-in gates; the
+ document viewer itself stays public, see below). Anonymous callers
+ get 403 ``admin only`` and the Sources page renders its sign-in gate
+ instead. An empty list means the knowledge base has not been imported
+ yet — the Sources page renders its designed empty state in that case.
"""
rows = db.execute(
select(
@@ -71,6 +78,11 @@ def get_document_content(
Stateless (A10) and database-only: unknown pairs — including traversal
strings such as ``../../etc/passwd`` — are just non-existent rows and
map to 404 ``{detail: "document not found"}``.
+
+ Deliberately PUBLIC for anonymous callers (phase 16 soft rule, owner
+ decision 2026-08-22): the *catalog* (``GET /api/docs``) is what the
+ sign-in gates, not the viewer — chat cites documents and anyone may
+ open a cited document by direct URL.
"""
row = db.execute(
select(Document, func.count(Chunk.id).label("chunks"))
diff --git a/app/api/steering.py b/app/api/steering.py
index 02ceec5..68adab7 100644
--- a/app/api/steering.py
+++ b/app/api/steering.py
@@ -1,11 +1,14 @@
"""Steering notes API — tune how Brain answers (phase 15, story
``steering-notes``).
-Stateless CRUD under ``/api/steering`` (A10): notes are owner instructions
-stored in Postgres (``steering_notes``) and read into the system prompt of
-**every** chat turn as the ```` section (see
-:func:`app.rag.prompts.build_steering_section` and
-:func:`app.api.chat.chat`).
+Admin-only CRUD under ``/api/steering`` (phase 16, A10 revised): notes
+are owner instructions stored in Postgres (``steering_notes``) and read
+into the system prompt of **every** chat turn as the ```` section
+(see :func:`app.rag.prompts.build_steering_section` and
+:func:`app.api.chat.chat`). The whole router sits behind
+:func:`app.core.auth.require_admin` — anonymous callers get 403 on every
+steering route (the chat turn itself reads the table in-process and
+stays public).
"""
from __future__ import annotations
@@ -15,12 +18,17 @@ from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy import select
from sqlalchemy.orm import Session
+from app.core.auth import require_admin
from app.db import get_db
from app.models import SteeringNote
from app.schemas import SteeringNote as SteeringNoteOut
from app.schemas import SteeringNoteIn, SteeringNoteList
-router = APIRouter(prefix="/steering", tags=["steering"])
+router = APIRouter(
+ prefix="/steering",
+ tags=["steering"],
+ dependencies=[Depends(require_admin)], # phase 16: tuning is admin-only
+)
def load_steering_notes(db: Session) -> list[str]:
diff --git a/app/config.py b/app/config.py
index 2f1aa29..b93fe21 100644
--- a/app/config.py
+++ b/app/config.py
@@ -79,6 +79,20 @@ class Settings(BaseSettings):
hybrid_lexical_candidates: int = 30
rrf_k: int = 60
+ # --- Admin & sign-in (phase 16; A10 revised 2026-08-22) ---
+ # Single-admin auth via a signed session cookie (Starlette
+ # SessionMiddleware — no new services, no DB tables). Both secrets are
+ # REQUIRED at startup: ``create_app()`` refuses to boot when either is
+ # empty (``app.core.auth.ensure_admin_configured``). The password is
+ # plaintext on purpose (homelab scope, owner decision 2026-08-22);
+ # the session secret signs the cookie (``secrets.token_hex(32)``).
+ admin_password: str = ""
+ session_secret: str = ""
+ #: Signed-cookie lifetime in seconds (default 12 h, refreshed on
+ #: session writes — sliding for an active admin).
+ session_max_age: int = 43_200
+ session_cookie: str = "bor_session"
+
# --- Import scope (A9, revised 2026-08-21) ---
# Comma-separated list of lowercased file extensions (no dot) imported
# by ``scripts/import_docs.py``. Hidden (dot) path components are always
diff --git a/app/core/auth.py b/app/core/auth.py
new file mode 100644
index 0000000..c448b0b
--- /dev/null
+++ b/app/core/auth.py
@@ -0,0 +1,89 @@
+"""Single-admin authentication (phase 16; LOCKED A10 revised 2026-08-22).
+
+One admin (the owner), one plaintext password, one signed cookie. The
+mechanism is Starlette's ``SessionMiddleware`` (itsdangerous-signed cookie
+— no server-side store, no new services, no DB tables): the public API
+stays stateless, the cookie is the *only* session state.
+
+Contract:
+* ``ensure_admin_configured`` — fail-loud startup gate: the app must name
+ the missing ``BOR_`` variable(s) instead of serving anything.
+* ``check_password`` — constant-time compare; exactly one generic 401
+ message (no user enumeration, there is no second user).
+* ``require_admin`` — FastAPI dependency; anonymous callers get 403
+ ``{"detail": "admin only"}`` (used by ``GET /api/docs`` and the whole
+ ``/api/steering`` router).
+* ``sign_in`` / ``sign_out`` — session-dict helpers for the API routes.
+"""
+from __future__ import annotations
+
+import secrets
+from collections.abc import MutableMapping
+from typing import Any
+
+from fastapi import HTTPException, Request
+
+from app.config import Settings
+
+#: The session key the single admin is stored under.
+ADMIN_SESSION_KEY = "admin"
+
+
+def ensure_admin_configured(settings: Settings) -> None:
+ """Refuse to boot without admin auth (fail-loud, A6 spirit).
+
+ Raises :class:`RuntimeError` naming every missing ``BOR_`` variable so
+ the startup log tells the operator exactly what to set.
+ """
+ missing = [
+ env_name
+ for env_name, value in (
+ ("BOR_ADMIN_PASSWORD", settings.admin_password),
+ ("BOR_SESSION_SECRET", settings.session_secret),
+ )
+ if not value.strip()
+ ]
+ if missing:
+ raise RuntimeError(
+ "Brain of Reese cannot start: admin auth is not configured. "
+ f"Set the missing variable(s): {', '.join(missing)} "
+ "(see .env.example and the README 'Admin & sign-in' section)."
+ )
+
+
+def check_password(candidate: str, expected: str) -> bool:
+ """Constant-time password check (``secrets.compare_digest``).
+
+ One admin → one generic 401 on any mismatch: the response never reveals
+ whether the password was *close*, empty, or not (no user enumeration).
+ """
+ return secrets.compare_digest(candidate.encode("utf-8"), expected.encode("utf-8"))
+
+
+def require_admin(request: Request) -> None:
+ """FastAPI dependency: allow the signed-in admin, else 403.
+
+ Reads the cookie-backed session installed by ``SessionMiddleware``;
+ a request without a valid admin session gets 403 ``admin only``.
+ """
+ if not request.session.get(ADMIN_SESSION_KEY):
+ raise HTTPException(status_code=403, detail="admin only")
+
+
+def sign_in(session: MutableMapping[str, Any]) -> None:
+ """Mark the (cookie-backed) session as the single admin.
+
+ Writing the key marks the session modified, so the middleware emits
+ the signed ``bor_session`` cookie with the configured Max-Age.
+ """
+ session[ADMIN_SESSION_KEY] = True
+
+
+def sign_out(session: MutableMapping[str, Any]) -> None:
+ """Clear the session state (the route additionally expires the cookie).
+
+ An emptied dict is not re-persisted by the middleware, so the API
+ route pairs this with ``response.delete_cookie`` to make the browser
+ drop the signed cookie immediately.
+ """
+ session.clear()
diff --git a/app/main.py b/app/main.py
index d8ef342..cd1af95 100644
--- a/app/main.py
+++ b/app/main.py
@@ -4,6 +4,11 @@ Boots logging + conditional debugpy, then creates the FastAPI app:
API routes first (so they win over the catch-all), and the static frontend
mounted last. No CDN: everything the browser needs is served by this
process from local files (see PLAN §UI/UX — No External Dependencies).
+
+Phase 16 (A10 revised): before anything is served, admin auth must be
+configured (fail-loud), and the app wraps every route in Starlette's
+SessionMiddleware — a signed ``bor_session`` cookie is the only session
+state in the system.
"""
from __future__ import annotations
@@ -12,13 +17,16 @@ from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
+from starlette.middleware.sessions import SessionMiddleware
+from app.api.auth import router as auth_router
from app.api.chat import router as chat_router
from app.api.docs import router as docs_router
from app.api.health import router as health_router
from app.api.steering import router as steering_router
from app.api.suggestions import router as suggestions_router
from app.config import get_settings
+from app.core.auth import ensure_admin_configured
from app.core.debugging import configure_debugging
from app.core.logging import configure_logging
@@ -30,10 +38,28 @@ logger = logging.getLogger("app")
def create_app() -> FastAPI:
+ # Fail loud BEFORE serving anything (phase 16): missing
+ # BOR_ADMIN_PASSWORD / BOR_SESSION_SECRET raises at boot, naming the
+ # variable(s) — the app never starts in a half-authenticated state.
+ ensure_admin_configured(settings)
+
app = FastAPI(title=settings.app_name, version=settings.app_version)
+ # Signed single-admin session cookie (Starlette middleware, itsdangerous
+ # signer — no server-side store, no new services). Homelab HTTP: same_site
+ # is "lax" and https_only stays off (documented in the README).
+ app.add_middleware(
+ SessionMiddleware,
+ secret_key=settings.session_secret,
+ session_cookie=settings.session_cookie,
+ max_age=settings.session_max_age,
+ same_site="lax",
+ https_only=False,
+ )
+
# API routes first so they take precedence over the catch-all static mount.
app.include_router(health_router, prefix="/api")
+ app.include_router(auth_router, prefix="/api")
app.include_router(suggestions_router, prefix="/api")
app.include_router(docs_router, prefix="/api")
app.include_router(chat_router, prefix="/api")
diff --git a/app/schemas.py b/app/schemas.py
index 8e404a8..00ccb9b 100644
--- a/app/schemas.py
+++ b/app/schemas.py
@@ -22,6 +22,23 @@ class ChatRequest(BaseModel):
message: str = Field(min_length=1, max_length=4000)
+class LoginRequest(BaseModel):
+ """``POST /api/login`` body (phase 16): the single admin's password.
+
+ An empty or wrong password is a 401 with one generic detail — never a
+ 422 that would hint at input-shape differences.
+ """
+
+ password: str = ""
+
+
+class WhoamiResponse(BaseModel):
+ """``GET /api/whoami`` (phase 16) — drives all UI gating."""
+
+ authenticated: bool
+ role: str # "admin" | "anonymous"
+
+
class SourceRef(BaseModel):
source: str
path: str
diff --git a/frontend/assets/app.js b/frontend/assets/app.js
index 9a7f670..e47c1c8 100644
--- a/frontend/assets/app.js
+++ b/frontend/assets/app.js
@@ -126,8 +126,11 @@ function announceSteering(message) {
/* "Tune" button in the meta row of a completed brain bubble. Reuses the
sources' .msg-meta row when it exists (role=list → the button joins as
- a listitem so ARIA stays valid); otherwise creates a plain meta row. */
+ a listitem so ARIA stays valid); otherwise creates a plain meta row.
+ Phase 16: anonymous visitors never get the button — this single guard
+ covers both fresh turns and the phase-14 restore path. */
function appendTuneButton(wrap) {
+ if (!isAdmin) return; // phase 16: tuning is admin-only
const body = wrap.querySelector(".msg-body");
if (!body) return;
let meta = body.querySelector(".msg-meta");
@@ -656,6 +659,50 @@ function rememberBrainTurn(rawText, meta) {
* empty state (suggestions included). Ignored while a turn is in flight —
* a live stream must not be hijacked. Confirmation reuses the existing
* #send-status live region (aria-live=polite). */
+/* ---------- single-admin auth (phase 16, A10 revised) ----------
+ *
+ * /api/whoami decides the header: anonymous → the Sign in link and NO
+ * tuning surface at all — the Tuning toggle + panel are removed from the
+ * DOM (the story says "absent", not just hidden), /api/steering is never
+ * fetched, and appendTuneButton injects nothing (new or restored
+ * messages). Admin → Sign out (POST /api/logout + reload) + the full
+ * phase-15 UI. Whoami is awaited BEFORE the phase-14 restore, so restored
+ * brain bubbles never flash a Tune button that should not be there.
+ */
+const signInLink = document.querySelector("#sign-in-link");
+const signOutBtn = document.querySelector("#sign-out-btn");
+let isAdmin = false;
+
+function applyAuthState() {
+ if (signInLink) signInLink.hidden = isAdmin;
+ if (signOutBtn) signOutBtn.hidden = !isAdmin;
+ if (!isAdmin && steeringToggle) {
+ steeringToggle.remove();
+ steeringPanel?.remove();
+ }
+}
+
+async function loadAuthState() {
+ try {
+ const r = await fetch("/api/whoami");
+ if (r.ok) isAdmin = (await r.json()).authenticated === true;
+ } catch {
+ isAdmin = false; // API unreachable: anonymous-safe defaults
+ }
+ applyAuthState();
+ return isAdmin;
+}
+
+if (signOutBtn) {
+ signOutBtn.addEventListener("click", async () => {
+ signOutBtn.disabled = true;
+ try {
+ await fetch("/api/logout", { method: "POST" });
+ } catch { /* the reload resets the UI either way */ }
+ window.location.reload();
+ });
+}
+
const newChatBtn = document.querySelector("#new-chat-btn");
function startNewChat() {
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
@@ -799,7 +846,13 @@ input.addEventListener("keydown", (e) => {
});
composer.addEventListener("submit", handleSend);
-restoreConversation(); // phase 14: the conversation comes back as left
-loadSuggestions();
-loadHealth();
-loadSteering(); // phase 15: tuning notes (panel + count badge)
+/* Boot: auth state FIRST — it decides whether the restored conversation
+ gets Tune buttons and whether the steering UI exists at all (phase 16).
+ Phase 14: the conversation then comes back exactly as left. */
+(async () => {
+ await loadAuthState();
+ restoreConversation();
+ loadSuggestions();
+ loadHealth();
+ if (isAdmin) loadSteering(); // phase 15: panel + count badge (admin only)
+})();
diff --git a/frontend/assets/login.js b/frontend/assets/login.js
new file mode 100644
index 0000000..b71745e
--- /dev/null
+++ b/frontend/assets/login.js
@@ -0,0 +1,80 @@
+/* Brain of Reese — admin sign-in (phase 16, A10 revised).
+ *
+ * One admin, one password. On submit → POST /api/login: 204 sets the
+ * signed session cookie and we redirect to `?next` (same-origin relative
+ * URLs only — "/…" but never "//host" or an absolute URL; default
+ * /sources.html). A 401 keeps the form and announces through the
+ * role=alert error region. On load, /api/whoami already says admin →
+ * straight to `next`, no form.
+ *
+ * No CDN, no state in this file: the signed cookie is the whole session.
+ * All DOM ids match frontend/login.html.
+ */
+
+const form = document.querySelector("#login-form");
+const passwordInput = document.querySelector("#login-password");
+const submitBtn = document.querySelector("#login-submit");
+const errorEl = document.querySelector("#login-error");
+
+const DEFAULT_NEXT = "/sources.html";
+
+/* Same-origin relative URLs only: honor `?next=/…`, reject anything that
+ would leave the origin (protocol-relative "//…" or absolute). */
+function safeNext() {
+ const next = new URLSearchParams(window.location.search).get("next") || DEFAULT_NEXT;
+ return next.startsWith("/") && !next.startsWith("//") ? next : DEFAULT_NEXT;
+}
+
+function showError(message) {
+ errorEl.textContent = message;
+ errorEl.hidden = false;
+ submitBtn.disabled = false;
+ passwordInput.focus();
+ passwordInput.select();
+}
+
+async function alreadySignedIn() {
+ try {
+ const r = await fetch("/api/whoami");
+ if (!r.ok) return false;
+ return (await r.json()).authenticated === true;
+ } catch {
+ return false; // API unreachable: stay on the form — submit will explain
+ }
+}
+
+form.addEventListener("submit", async (e) => {
+ e.preventDefault();
+ errorEl.hidden = true;
+ errorEl.textContent = "";
+ submitBtn.disabled = true;
+ try {
+ const r = await fetch("/api/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ password: passwordInput.value }),
+ });
+ if (r.status === 204) {
+ // Session cookie set — off to the requested page.
+ window.location.replace(safeNext());
+ return;
+ }
+ // One generic failure (401); anything else is a server-side surprise.
+ const detail =
+ r.status === 401
+ ? "Invalid password — try again."
+ : `Sign-in failed (HTTP ${r.status}) — try again.`;
+ showError(detail);
+ } catch {
+ showError("Could not reach the server — try again.");
+ }
+});
+
+/* Already the admin? Skip the form and go straight to the target. */
+(async () => {
+ if (await alreadySignedIn()) {
+ window.location.replace(safeNext());
+ return;
+ }
+ passwordInput.focus();
+})();
diff --git a/frontend/assets/sources.js b/frontend/assets/sources.js
index 2e4cbc3..6ce2302 100644
--- a/frontend/assets/sources.js
+++ b/frontend/assets/sources.js
@@ -9,10 +9,24 @@
const tbody = document.querySelector("#docs-tbody");
const emptyEl = document.querySelector("#sources-empty");
const tableWrap = document.querySelector(".table-wrap");
+const statCards = document.querySelector("#stat-cards");
+const gateEl = document.querySelector("#sources-gate");
const statDocs = document.querySelector("#stat-docs");
const statChunks = document.querySelector("#stat-chunks");
const statLast = document.querySelector("#stat-last");
+/* Phase 16: whoami BEFORE the docs fetch. Anonymous visitors get the
+ * sign-in gate (stat cards + table hidden) and NO /api/docs call — the
+ * catalog is admin-only. The document viewer itself stays public (the
+ * soft rule), so the gate copy points at what keeps working. */
+async function isAdmin() {
+ try {
+ const r = await fetch("/api/whoami");
+ if (r.ok) return (await r.json()).authenticated === true;
+ } catch { /* API unreachable: anonymous-safe gate */ }
+ return false;
+}
+
function fmtDate(iso) {
try {
return new Date(iso).toLocaleString();
@@ -97,4 +111,15 @@ function showEmpty() {
if (tableWrap) tableWrap.hidden = true;
}
-loadDocs();
+(async () => {
+ if (!(await isAdmin())) {
+ // Anonymous: gate in, catalog out, and no /api/docs request at all.
+ if (statCards) statCards.hidden = true;
+ if (tableWrap) tableWrap.hidden = true;
+ if (emptyEl) emptyEl.hidden = true;
+ if (gateEl) gateEl.hidden = false;
+ return;
+ }
+ if (gateEl) gateEl.hidden = true;
+ loadDocs();
+})();
diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css
index 894ba0b..007674c 100644
--- a/frontend/assets/styles.css
+++ b/frontend/assets/styles.css
@@ -241,6 +241,33 @@ body::after {
whole control below 640px. */
.new-chat-btn svg { width: 16px; height: 16px; display: none; }
+/* Phase 16: header auth controls (Sign in link / Sign out button) — the
+ same ghost pill as New chat, so the bar keeps one visual language.
+ ink-soft on surface ≈6.9:1; hover pair brand-ink/brand-soft ≈6.9:1.
+ Icon-only below 640px (aria-labels/labels keep the accessible names);
+ ≥44px touch target at every width. Exactly one is ever visible. */
+.auth-link {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.4rem;
+ min-height: 44px;
+ padding: 0.5rem 0.9rem;
+ border-radius: 999px;
+ border: 1px solid var(--line);
+ background: transparent;
+ color: var(--ink-soft);
+ font: inherit;
+ font-weight: 600;
+ font-size: 0.95rem;
+ white-space: nowrap;
+ text-decoration: none;
+ cursor: pointer;
+}
+.auth-link:hover { background: var(--brand-soft); color: var(--brand-ink); }
+.auth-link:disabled { opacity: 0.6; cursor: wait; }
+.auth-link svg { width: 16px; height: 16px; display: none; }
+
/* "Tuning" toggle (phase 15): ghost pill like New chat + a mono count
badge (brand-ink on brand-soft ≈6.9:1). The label is visually-hidden
(not removed) below 640px so the accessible name keeps the word.
@@ -724,6 +751,65 @@ body::after {
.kb-banner.is-error { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.kb-banner svg { width: 18px; height: 18px; flex: 0 0 auto; display: block; }
+/* ---------- Login page (phase 16) ---------- */
+/* Centered card in the standard frame: one admin, one password. */
+.login-shell {
+ display: flex;
+ justify-content: center;
+ flex: 1;
+}
+.login-card {
+ width: 100%;
+ max-width: 26rem;
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow);
+ padding: 2rem 2rem 2.25rem;
+}
+.login-card h1 { margin: 0 0 0.4rem; font-size: 1.6rem; }
+.login-sub { margin: 0 0 1.5rem; color: var(--ink-soft); }
+#login-form { display: flex; flex-direction: column; gap: 0.75rem; }
+#login-password {
+ font: inherit;
+ font-size: 1rem;
+ color: var(--ink);
+ background: #0d1120;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ padding: 0.55rem 0.75rem;
+ min-height: 44px;
+}
+#login-password:focus-visible { border-color: var(--brand); }
+/* Brand button: dark ink on brand 5.2:1 (never white on brand). */
+.login-submit {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 44px;
+ border: 0;
+ border-radius: var(--radius-sm);
+ background: var(--brand);
+ color: var(--bg);
+ font: inherit;
+ font-weight: 700;
+ cursor: pointer;
+ padding-inline: 1rem;
+}
+.login-submit:hover:not(:disabled) { background: #7d88f5; }
+.login-submit:disabled { opacity: 0.6; cursor: wait; }
+/* Login failure (role=alert): err pair ≈9.1:1. */
+.login-error {
+ margin: 0.9rem 0 0;
+ background: var(--err-bg);
+ color: var(--err-ink);
+ border: 1px solid var(--err-line);
+ border-radius: var(--radius-sm);
+ padding: 0.5rem 0.8rem;
+ font-size: 0.9rem;
+ font-weight: 600;
+}
+
/* ---------- Sources page ---------- */
.sources-shell {
display: flex;
@@ -755,6 +841,39 @@ body::after {
.stat-value-sm { font-size: 1.15rem; font-weight: 700; }
.stat-label { color: var(--ink-soft); font-size: 0.88rem; font-weight: 600; }
+/* Phase 16: anonymous sign-in gate — the designed replacement for the
+ catalog (stat cards + table) until the admin signs in. */
+.sources-gate {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ gap: 0.4rem;
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow);
+ padding: 2.5rem 1.75rem;
+}
+.sources-gate-glyph { color: var(--brand-ink); width: 44px; height: 44px; }
+.sources-gate-glyph svg { width: 44px; height: 44px; display: block; }
+.sources-gate h2 { margin: 0.6rem 0 0.3rem; font-size: 1.4rem; }
+.sources-gate-sub { margin: 0 auto; max-width: 30rem; color: var(--ink-soft); }
+.sources-gate-link {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 44px;
+ margin-top: 0.75rem;
+ padding: 0.5rem 1.4rem;
+ border-radius: 999px;
+ background: var(--brand);
+ color: var(--bg); /* dark ink on brand: 5.2:1 */
+ font-weight: 700;
+ text-decoration: none;
+}
+.sources-gate-link:hover { background: #7d88f5; }
+
.table-wrap {
background: var(--surface);
border: 1px solid var(--line);
@@ -1005,6 +1124,11 @@ body::after {
.new-chat-btn { padding: 0.4rem 0.55rem; }
.new-chat-label { display: none; }
.new-chat-btn svg { display: block; }
+ /* Phase 16: the auth pill goes icon-only like New chat — brand text
+ ellipsizes as the designated squeeze target, no bar overflow. */
+ .auth-link { padding: 0.4rem 0.55rem; }
+ .auth-label { display: none; }
+ .auth-link svg { display: block; }
.steering-toggle { padding: 0.4rem 0.55rem; }
/* Visually hidden, NOT display:none — the accessible name keeps the
word "Tuning" next to the count badge. */
diff --git a/frontend/index.html b/frontend/index.html
index 631262d..4be0c36 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -34,6 +34,17 @@
New chat
+
+
+
+ Sign in
+
+
+
+ Sign out
+
diff --git a/frontend/login.html b/frontend/login.html
new file mode 100644
index 0000000..2ecd1d3
--- /dev/null
+++ b/frontend/login.html
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+ Sign in · Brain of Reese
+
+
+
+
+ Skip to content
+
+
+
+
+
+
+
+
+ Sign in
+
+ One admin account, one password. Signing in unlocks the full
+ Sources catalog and the answer-tuning controls — chat stays open
+ to everyone either way.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/sources.html b/frontend/sources.html
index c55cf3d..c1c665a 100644
--- a/frontend/sources.html
+++ b/frontend/sources.html
@@ -34,6 +34,21 @@
+
+
+
+ Sign in to view the full catalog
+
+ The complete list of indexed documents is admin-only. Chat — and
+ any document an answer cites — stays open to everyone.
+
+ Sign in
+
+
–
diff --git a/pyproject.toml b/pyproject.toml
index 2185c4b..c44a696 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,6 +19,10 @@ dependencies = [
# --- LLM client (OpenAI-compatible, self-hosted "aipi") ---
"httpx>=0.27,<1.0",
"openai>=1.40,<3.0",
+ # Phase 16: starlette's SessionMiddleware signs the session cookie with
+ # itsdangerous — an OPTIONAL starlette extra ("full") since starlette 1.x,
+ # so the app declares it directly (narrower than starlette[full]).
+ "itsdangerous>=2.2,<3.0",
]
[dependency-groups]
diff --git a/tests/conftest.py b/tests/conftest.py
index b2644d9..a7fe4f4 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -15,6 +15,14 @@ from sqlalchemy.orm import Session
# The production default stays 0.62 (app/config.py, A8 revised).
os.environ.setdefault("BOR_RELEVANCE_THRESHOLD", "0.30")
+# Phase 16: single-admin auth is fail-loud — create_app() refuses to boot
+# without both vars, and app.main (imported below) builds the app at
+# import time. Set known test values first, same pattern as the threshold.
+ADMIN_PASSWORD = "test-admin-password"
+SESSION_SECRET = "test-session-secret-0123456789abcdef0123456789abcdef"
+os.environ.setdefault("BOR_ADMIN_PASSWORD", ADMIN_PASSWORD)
+os.environ.setdefault("BOR_SESSION_SECRET", SESSION_SECRET)
+
from app.db import SessionLocal, db_available # noqa: E402
from app.main import app as fastapi_app # noqa: E402
@@ -24,6 +32,19 @@ def client() -> TestClient:
return TestClient(fastapi_app)
+@pytest.fixture()
+def admin_client(client: TestClient) -> TestClient:
+ """A client signed in as the single admin (phase 16).
+
+ TestClient keeps its cookie jar across requests, so one login covers
+ every subsequent request of the test. Use it for the admin-only
+ surface (``GET /api/docs``, ``/api/steering``).
+ """
+ r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
+ assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
+ return client
+
+
@pytest.fixture()
def db() -> Iterator[Session]:
"""Real Postgres session (``podman compose up -d db``).
diff --git a/tests/e2e/auth_helpers.py b/tests/e2e/auth_helpers.py
new file mode 100644
index 0000000..65db3ea
--- /dev/null
+++ b/tests/e2e/auth_helpers.py
@@ -0,0 +1,38 @@
+"""Shared Playwright auth helper (phase 16).
+
+``login`` drives the REAL form login on /login.html (fill → submit →
+redirect) so every story that needs the admin does exactly what a human
+would — no cookie surgery. ``password=None`` uses the shared E2E admin
+password (success path); pass a wrong value to drive the error state
+(no redirect, ``#login-error`` role=alert visible, still anonymous).
+"""
+from __future__ import annotations
+
+from playwright.sync_api import Page, expect
+
+from e2e.conftest import ADMIN_PASSWORD # noqa: F401 (re-exported for tests)
+
+DEFAULT_NEXT = "/sources.html"
+
+
+def login(page: Page, app_url: str, password: str | None = None, next: str | None = None) -> None:
+ """Perform the real form login and wait for its outcome.
+
+ * correct password (or ``password=None`` → the shared admin password)
+ → redirects to ``next`` (default ``/sources.html``);
+ * wrong password → ``#login-error`` (role=alert) is visible, the URL
+ never changes, and the visitor is still anonymous.
+ """
+ attempt = ADMIN_PASSWORD if password is None else password
+ url = f"{app_url}/login.html"
+ if next is not None:
+ url += f"?next={next}"
+ page.goto(url)
+ expect(page.locator("#login-password")).to_be_visible()
+ page.fill("#login-password", attempt)
+ page.click("#login-form button[type=submit]")
+ if attempt != ADMIN_PASSWORD:
+ expect(page.locator("#login-error")).to_be_visible(timeout=15_000)
+ expect(page).to_have_url(url) # no redirect on failure
+ return
+ expect(page).to_have_url(app_url + (next or DEFAULT_NEXT), timeout=30_000)
diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py
index 9463256..78b6fb9 100644
--- a/tests/e2e/conftest.py
+++ b/tests/e2e/conftest.py
@@ -31,6 +31,13 @@ MOCK_PORT = int(os.environ.get("E2E_MOCK_PORT", "8901"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
USE_REAL_LLM = os.environ.get("E2E_REAL_LLM") == "1"
+# Phase 16: the app under test boots with single-admin auth configured
+# (fail-loud otherwise). Known E2E values — the shared form-login helper
+# (tests/e2e/auth_helpers.py) uses ADMIN_PASSWORD; the secret is fixed so
+# session cookies stay valid across a session-scoped app restart.
+ADMIN_PASSWORD = "e2e-admin-password"
+SESSION_SECRET = "e2e-session-secret-0123456789abcdef0123456789abcdef"
+
def _wait_http(url: str, timeout: float = 40.0) -> None:
deadline = time.monotonic() + timeout
@@ -89,6 +96,9 @@ def app_server(mock_llm: int) -> Iterator[str]:
# `embed` model's 0.41–0.84 cosine range, PLAN A8).
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env.setdefault("BOR_DATABASE_URL", "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese")
+ # Phase 16: admin auth must be set or create_app() refuses to boot.
+ env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
+ env["BOR_SESSION_SECRET"] = SESSION_SECRET
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
diff --git a/tests/e2e/test_admin_auth.py b/tests/e2e/test_admin_auth.py
new file mode 100644
index 0000000..8493305
--- /dev/null
+++ b/tests/e2e/test_admin_auth.py
@@ -0,0 +1,326 @@
+"""Phase 16 E2E (Playwright): single-admin sign-in (A10 revised).
+
+Story: ``.agent/user_stories/admin-auth.md``
+Run in isolation (DB must be up: ``podman compose up -d db``):
+
+ uv run pytest tests/e2e/test_admin_auth.py -v --no-cov
+
+The E2E app server boots with ``BOR_ADMIN_PASSWORD``/``BOR_SESSION_SECRET``
+set (``tests/e2e/conftest.py``); the shared ``tests/e2e/auth_helpers.py::login``
+performs the real form login on /login.html.
+
+Test → story mapping (Playwright Mapping Rule):
+1. ``test_anonymous_chat_without_tuning``
+2. ``test_anonymous_sources_gated_viewer_open``
+3. ``test_login_wrong_password_shows_error``
+4. ``test_admin_login_unlocks_sources_and_tuning``
+5. ``test_logout_returns_to_anonymous``
+6. ``test_login_page_a11y``
+"""
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+from threading import Thread
+from typing import Any
+
+from playwright.sync_api import Page, expect
+from sqlalchemy import text
+
+from app.config import Settings
+from app.db import SessionLocal
+from app.rag.importer import ImportSummary, import_sources
+from app.rag.llm import LLMClient
+from e2e.auth_helpers import ADMIN_PASSWORD, login
+
+REPO = Path(__file__).resolve().parents[2]
+FIXTURES = REPO / "tests" / "fixtures" / "docs"
+QUESTION = "How is my Kubernetes cluster set up?"
+MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
+DOC_TITLE = "Kubernetes Homelab Cluster"
+DOC_VIEWER_URL = "/document.html?source=docs&path=homelab%2Fkubernetes.md"
+
+
+async def _import_fixtures(mock_port: int) -> ImportSummary:
+ kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
+ settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
+ return await import_sources([FIXTURES], LLMClient(settings))
+
+
+def _run_in_thread(coro: Any) -> Any:
+ """Run a coroutine on a worker thread (Playwright owns the test loop)."""
+ box: dict[str, Any] = {}
+
+ def runner() -> None:
+ try:
+ box["value"] = asyncio.run(coro)
+ except BaseException as e: # noqa: BLE001 — re-raised on the test thread
+ box["error"] = e
+
+ t = Thread(target=runner)
+ t.start()
+ t.join()
+ if "error" in box:
+ raise box["error"]
+ return box["value"]
+
+
+def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
+ """Truncate the KB (and query log + steering notes), optionally re-seed."""
+ with SessionLocal() as db:
+ db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
+ db.commit()
+ if not seed:
+ return None
+ return _run_in_thread(_import_fixtures(mock_port))
+
+
+def _ask(page: Page, question: str) -> None:
+ """Send one turn and wait until the grounded answer has fully landed."""
+ page.fill("#message-input", question)
+ page.click("#send-btn")
+ expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
+ expect(page.locator(".msg.brain .bubble").last).to_contain_text(
+ MOCK_ANSWER_MARKER, timeout=30_000
+ )
+ expect(page.locator("#send-btn")).to_be_enabled()
+ expect(page.locator("#send-label")).to_have_text("Send")
+
+
+# ---------------------------------------------------------------------------
+# 1. Anonymous: chat works, the tuning UI is gone, Sign in is offered
+# ---------------------------------------------------------------------------
+
+
+def test_anonymous_chat_without_tuning(
+ page: Page, app_url: str, mock_llm: int, db_ready: None
+) -> None:
+ _reset_db(mock_llm, seed=True)
+ page.set_default_timeout(30_000)
+ page.goto(app_url)
+
+ # Header: Sign in offered, Sign out not.
+ expect(page.locator("#sign-in-link")).to_be_visible()
+ expect(page.locator("#sign-in-link")).to_have_attribute(
+ "href", "/login.html?next=/sources.html"
+ )
+ expect(page.locator("#sign-out-btn")).to_be_hidden()
+
+ # Chat still streams a grounded answer (with source chips) for
+ # anonymous visitors…
+ _ask(page, QUESTION)
+ expect(page.locator(".msg.brain .source-chip", has_text="kubernetes.md")).to_have_count(1)
+
+ # …but the tuning UI is completely gone: no Tune button (new or
+ # restored), no Tuning toggle or panel in the DOM at all.
+ expect(page.locator(".msg.brain .tune-btn")).to_have_count(0)
+ expect(page.locator("#steering-toggle")).to_have_count(0)
+ expect(page.locator("#steering-panel")).to_have_count(0)
+
+ # A reload (the phase-14 restore path) must not bring it back.
+ page.reload()
+ expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
+ expect(page.locator(".msg.brain .tune-btn")).to_have_count(0)
+ expect(page.locator("#steering-toggle")).to_have_count(0)
+ expect(page.locator("#sign-in-link")).to_be_visible()
+
+
+# ---------------------------------------------------------------------------
+# 2. Anonymous: Sources gated, the document viewer stays open (soft rule)
+# ---------------------------------------------------------------------------
+
+
+def test_anonymous_sources_gated_viewer_open(
+ page: Page, app_url: str, mock_llm: int, db_ready: None
+) -> None:
+ _reset_db(mock_llm, seed=True)
+
+ api_docs_calls: list[str] = []
+ page.on(
+ "request",
+ lambda r: api_docs_calls.append(r.url) if "/api/docs" in r.url else None,
+ )
+
+ page.goto(f"{app_url}/sources.html")
+ # The gate, with its sign-in link (≥44px) — not a redirect.
+ gate = page.locator("#sources-gate")
+ expect(gate).to_be_visible()
+ expect(gate).to_contain_text("Sign in to view the full catalog")
+ link = gate.locator("a[href='/login.html?next=/sources.html']")
+ expect(link).to_have_count(1)
+ box = link.bounding_box()
+ assert box is not None and box["height"] >= 44
+
+ # Stat cards + table hidden…
+ expect(page.locator("#stat-cards")).to_be_hidden()
+ expect(page.locator("#docs-table")).to_be_hidden()
+ expect(page.locator("#sources-empty")).to_be_hidden()
+ # …and NO /api/docs call was ever made.
+ assert api_docs_calls == [], f"anonymous sources page called /api/docs: {api_docs_calls}"
+
+ # The soft rule: any seeded document still opens by direct URL.
+ page.goto(app_url + DOC_VIEWER_URL)
+ expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
+ expect(page.locator("#doc-content")).not_to_be_empty()
+
+
+# ---------------------------------------------------------------------------
+# 3. Wrong password → role=alert error, no redirect, still anonymous
+# ---------------------------------------------------------------------------
+
+
+def test_login_wrong_password_shows_error(page: Page, app_url: str, db_ready: None) -> None:
+ _reset_db(mock_port=0, seed=False)
+ page.set_default_timeout(30_000)
+
+ login(page, app_url, password="definitely-not-the-password")
+
+ error = page.locator("#login-error")
+ expect(error).to_be_visible()
+ assert error.get_attribute("role") == "alert"
+ expect(error).not_to_be_empty()
+ # No redirect happened…
+ expect(page).to_have_url(app_url + "/login.html")
+ # …and the server agrees: still anonymous, no session cookie set.
+ who = page.evaluate("() => fetch('/api/whoami').then((r) => r.json())")
+ assert who == {"authenticated": False, "role": "anonymous"}
+
+ # The form stays usable: the correct password now succeeds.
+ page.fill("#login-password", ADMIN_PASSWORD)
+ page.click("#login-form button[type=submit]")
+ expect(page).to_have_url(app_url + "/sources.html", timeout=30_000)
+
+
+# ---------------------------------------------------------------------------
+# 4. Correct password → Sources + tuning unlocked, Sign out offered
+# ---------------------------------------------------------------------------
+
+
+def test_admin_login_unlocks_sources_and_tuning(
+ page: Page, app_url: str, mock_llm: int, db_ready: None
+) -> None:
+ _reset_db(mock_llm, seed=True)
+ page.set_default_timeout(30_000)
+
+ # Real form login (default password + next) lands on the catalog.
+ login(page, app_url)
+ expect(page).to_have_url(app_url + "/sources.html")
+ expect(page.locator("#sources-gate")).to_be_hidden()
+ expect(page.locator("#stat-docs")).to_have_text("8")
+ expect(page.locator("#stat-chunks")).not_to_have_text("–")
+ expect(page.locator("#docs-table")).to_be_visible()
+ expect(page.locator("#docs-tbody tr")).to_have_count(8)
+
+ # Chat: the tuning UI is back — header toggle with count badge,
+ # Sign out instead of Sign in, Tune under the answer.
+ page.goto(app_url)
+ expect(page.locator("#sign-out-btn")).to_be_visible()
+ expect(page.locator("#sign-in-link")).to_be_hidden()
+ toggle = page.locator("#steering-toggle")
+ expect(toggle).to_be_visible()
+ expect(page.locator("#steering-count")).to_have_text("0")
+
+ _ask(page, QUESTION)
+ tune = page.locator(".msg.brain .tune-btn").last
+ expect(tune).to_be_visible()
+ box = tune.bounding_box()
+ assert box is not None and box["height"] >= 44
+
+ # The API agrees: admin, and the gated endpoints answer now.
+ who = page.evaluate("() => fetch('/api/whoami').then((r) => r.json())")
+ assert who == {"authenticated": True, "role": "admin"}
+ docs_status = page.evaluate("() => fetch('/api/docs').then((r) => r.status)")
+ assert docs_status == 200
+
+
+# ---------------------------------------------------------------------------
+# 5. Sign out → anonymous again (gate back, tuning gone, restore untunable)
+# ---------------------------------------------------------------------------
+
+
+def test_logout_returns_to_anonymous(
+ page: Page, app_url: str, mock_llm: int, db_ready: None
+) -> None:
+ _reset_db(mock_llm, seed=True)
+ page.set_default_timeout(30_000)
+
+ login(page, app_url, next="/") # straight into the chat
+ expect(page).to_have_url(app_url + "/")
+ expect(page.locator("#sign-out-btn")).to_be_visible()
+ expect(page.locator("#steering-toggle")).to_be_visible()
+
+ # One grounded turn as admin (persisted to localStorage by phase 14).
+ _ask(page, QUESTION)
+ expect(page.locator(".msg.brain .tune-btn").last).to_be_visible()
+
+ # Sign out: POST /api/logout + reload → anonymous again.
+ page.click("#sign-out-btn")
+ expect(page.locator("#sign-in-link")).to_be_visible(timeout=30_000)
+ expect(page.locator("#sign-out-btn")).to_be_hidden()
+ expect(page.locator("#steering-toggle")).to_have_count(0)
+ expect(page.locator("#steering-panel")).to_have_count(0)
+
+ # The restored conversation came back… without any Tune button.
+ expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
+ expect(page.locator(".msg.brain .tune-btn")).to_have_count(0)
+
+ # The server agrees, and Sources is gated again.
+ who = page.evaluate("() => fetch('/api/whoami').then((r) => r.json())")
+ assert who == {"authenticated": False, "role": "anonymous"}
+ page.goto(f"{app_url}/sources.html")
+ expect(page.locator("#sources-gate")).to_be_visible()
+ expect(page.locator("#docs-table")).to_be_hidden()
+
+
+# ---------------------------------------------------------------------------
+# 6. Login page accessibility (WCAG 2.1 AA basics)
+# ---------------------------------------------------------------------------
+
+
+def test_login_page_a11y(page: Page, app_url: str, db_ready: None) -> None:
+ _reset_db(mock_port=0, seed=False)
+ page.set_default_timeout(30_000)
+
+ page.goto(f"{app_url}/login.html")
+
+ # Standard app frame: landmarks + skip link, no CDN tags.
+ expect(page.locator("header.app-header")).to_have_count(1)
+ expect(page.locator("nav[aria-label='Primary']")).to_have_count(1)
+ expect(page.locator("main#main")).to_have_count(1)
+ expect(page.locator("footer.app-footer")).to_have_count(1)
+ expect(page.locator(".skip-link")).to_have_count(1)
+ html = page.content()
+ assert 'src="https://' not in html and 'href="https://' not in html
+
+ # The password field is labeled (visually-hidden ).
+ pw = page.get_by_label("Admin password")
+ expect(pw).to_have_count(1)
+ expect(pw.first).to_have_attribute("type", "password")
+ expect(pw.first).to_have_attribute("autocomplete", "current-password")
+
+ # Touch targets ≥44px (field + submit).
+ for el in (pw.first, page.locator("#login-form button[type=submit]")):
+ box = el.bounding_box()
+ assert box is not None and box["height"] >= 44, f"target too small: {box}"
+
+ # Keyboard focus draws the 3px focus-visible outline.
+ page.focus("#login-password")
+ outline = page.evaluate(
+ "() => getComputedStyle(document.querySelector('#login-password')).outlineWidth"
+ )
+ assert outline == "3px", f"focus-visible outline missing: {outline!r}"
+
+ # Errors are announced through the role=alert region.
+ error = page.locator("#login-error")
+ assert error.get_attribute("role") == "alert"
+ expect(error).to_be_hidden()
+ page.fill("#login-password", "wrong")
+ page.click("#login-form button[type=submit]")
+ expect(error).to_be_visible(timeout=15_000)
+
+ # A signed-in visit to /login.html?next=/ redirects immediately.
+ page.fill("#login-password", ADMIN_PASSWORD)
+ page.click("#login-form button[type=submit]")
+ expect(page).to_have_url(app_url + "/sources.html", timeout=30_000)
+ page.goto(f"{app_url}/login.html?next=/")
+ expect(page).to_have_url(app_url + "/", timeout=30_000)
diff --git a/tests/e2e/test_chat_persistence.py b/tests/e2e/test_chat_persistence.py
index f710f4e..a2259e4 100644
--- a/tests/e2e/test_chat_persistence.py
+++ b/tests/e2e/test_chat_persistence.py
@@ -33,6 +33,7 @@ from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
+from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
@@ -271,7 +272,9 @@ def test_persists_across_page_navigation(
_ask_deflected(page, OFF_TOPIC) # mixed conversation: grounded + deflected
# A trip to Sources — the New chat control is chat-page-only.
- page.goto(app_url + "/sources.html")
+ # (Phase 16: the catalog is admin-only — the trip starts with a
+ # real form login.)
+ login(page, app_url, next="/sources.html")
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
expect(page.locator("#new-chat-btn")).to_have_count(0)
diff --git a/tests/e2e/test_dark_tech_theme.py b/tests/e2e/test_dark_tech_theme.py
index 1aa8cc3..cb91180 100644
--- a/tests/e2e/test_dark_tech_theme.py
+++ b/tests/e2e/test_dark_tech_theme.py
@@ -42,6 +42,7 @@ from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
+from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
@@ -212,8 +213,9 @@ def test_dark_palette_and_contrast(
_assert_aa(pairs["button"], "dark ink on brand (send button)")
_assert_aa(pairs["chip"], "chip ink on chip bg (chat)")
- # Sources page pairs.
- page.goto(f"{app_url}/sources.html")
+ # Sources page pairs. (Phase 16: the stat cards are admin-only —
+ # a real form login first.)
+ login(page, app_url, next="/sources.html")
page.locator(".stat-card").first.wait_for(state="visible", timeout=10_000)
pairs = page.evaluate(
"""() => {
diff --git a/tests/e2e/test_document_back_navigation.py b/tests/e2e/test_document_back_navigation.py
index 55d64b4..1e0762c 100644
--- a/tests/e2e/test_document_back_navigation.py
+++ b/tests/e2e/test_document_back_navigation.py
@@ -40,6 +40,7 @@ from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
+from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
@@ -140,7 +141,7 @@ def test_back_from_sources_returns_to_sources(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
- page.goto(f"{app_url}/sources.html")
+ login(page, app_url) # phase 16: the Sources table is admin-only
row = page.locator("#docs-tbody tr", has_text="kubernetes.md")
expect(row).to_have_count(1)
diff --git a/tests/e2e/test_document_viewer.py b/tests/e2e/test_document_viewer.py
index da0499e..5ca2f62 100644
--- a/tests/e2e/test_document_viewer.py
+++ b/tests/e2e/test_document_viewer.py
@@ -38,6 +38,7 @@ from app.db import SessionLocal
from app.models import Document
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
+from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
@@ -139,7 +140,7 @@ def test_sources_row_links_to_viewer(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
- page.goto(f"{app_url}/sources.html")
+ login(page, app_url) # phase 16: the Sources catalog is admin-only
row = page.locator("#docs-tbody tr", has_text="gitlab-compose.yaml")
expect(row).to_have_count(1)
diff --git a/tests/e2e/test_header_consistency.py b/tests/e2e/test_header_consistency.py
index 28b5db9..76dbff6 100644
--- a/tests/e2e/test_header_consistency.py
+++ b/tests/e2e/test_header_consistency.py
@@ -13,6 +13,11 @@ Test → story mapping (Playwright Mapping Rule):
1. ``test_header_height_identical_across_pages_desktop``
2. ``test_header_height_identical_across_pages_mobile``
3. ``test_viewer_header_content_still_fits`` (phase-10 regression guard)
+
+Phase 16 adaptation: the auth control (Sign in / Sign out) joins the chat
+header's ``.header-inner`` — the desktop test verifies its presence in
+both auth states without the bar's height moving (height assertions
+unchanged).
"""
from __future__ import annotations
@@ -28,6 +33,7 @@ from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
+from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
@@ -111,6 +117,18 @@ def test_header_height_identical_across_pages_desktop(
f"document header {heights['document']}px (was content-sized)"
)
+ # Phase 16: the auth control lives in the same bar — anonymous sees
+ # "Sign in", signed-in sees "Sign out", and neither state moves the
+ # height.
+ page.goto(app_url + "/")
+ expect(page.locator("#sign-in-link")).to_be_visible()
+ expect(page.locator("#sign-out-btn")).to_be_hidden()
+ assert _box_height(page, ".app-header") == DESKTOP_HEADER_H
+ login(page, app_url, next="/")
+ expect(page.locator("#sign-out-btn")).to_be_visible()
+ expect(page.locator("#sign-in-link")).to_be_hidden()
+ assert _box_height(page, ".app-header") == DESKTOP_HEADER_H
+
# ---------------------------------------------------------------------------
# 2. Mobile (≤640px): all three pages, one identical 58px bar
diff --git a/tests/e2e/test_import_documents.py b/tests/e2e/test_import_documents.py
index 291129b..7475246 100644
--- a/tests/e2e/test_import_documents.py
+++ b/tests/e2e/test_import_documents.py
@@ -8,6 +8,9 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
Seeding runs the real import function in-process against
``tests/fixtures/docs/`` with the deterministic mock embeddings — it is a
fixture, not the subject of the tests.
+
+Phase 16 adaptation: the Sources catalog is admin-only — every test
+performs the real form login (``e2e.auth_helpers.login``) first.
"""
from __future__ import annotations
@@ -23,6 +26,7 @@ from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
+from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
@@ -86,7 +90,7 @@ def test_sources_page_lists_indexed_docs(
assert summary is not None and summary.added == 8
assert summary.formats == {"md": 4, "yaml": 1, "json": 1, "py": 1, "txt": 1}
- page.goto(f"{app_url}/sources.html")
+ login(page, app_url) # phase 16: the catalog is admin-only
expect(page.locator("#stat-docs")).to_have_text("8")
expect(page.locator("#stat-chunks")).to_have_text(str(summary.chunks))
expect(page.locator("#stat-last")).not_to_have_text("–")
@@ -105,7 +109,7 @@ def test_sources_table_layout(
page: Page, browser: Browser, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
- page.goto(f"{app_url}/sources.html")
+ login(page, app_url) # phase 16: the catalog is admin-only
page.locator("#docs-tbody tr").first.wait_for(state="visible")
wrap = page.locator(".table-wrap")
@@ -124,7 +128,7 @@ def test_sources_table_layout(
# scrolls horizontally instead of squeezing into a hairline.
mobile = browser.new_page(viewport={"width": 375, "height": 812})
try:
- mobile.goto(f"{app_url}/sources.html")
+ login(mobile, app_url) # phase 16: the catalog is admin-only
mobile.locator("#docs-tbody tr").first.wait_for(state="visible")
scroll_width, client_width = mobile.evaluate(
"() => { const el = document.querySelector('.table-wrap');"
@@ -138,7 +142,7 @@ def test_sources_table_layout(
def test_empty_state_when_no_docs(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None:
_reset_db(mock_llm, seed=False)
- page.goto(f"{app_url}/sources.html")
+ login(page, app_url) # phase 16: the (empty-state) catalog is admin-only
expect(page.locator("#sources-empty")).to_be_visible()
expect(page.locator("#sources-empty")).to_contain_text("Nothing indexed yet")
expect(page.locator("#sources-empty code")).to_have_text(
diff --git a/tests/e2e/test_responsive_polish.py b/tests/e2e/test_responsive_polish.py
index f785f7a..0ab7abe 100644
--- a/tests/e2e/test_responsive_polish.py
+++ b/tests/e2e/test_responsive_polish.py
@@ -45,6 +45,7 @@ from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
+from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
@@ -206,7 +207,7 @@ def test_no_horizontal_overflow_at_viewports(
)
_assert_no_doc_overflow(page, f"chat @ {width}px")
- page.goto(f"{app_url}/sources.html")
+ login(page, app_url, next="/sources.html") # phase 16: admin-only
page.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
_assert_no_doc_overflow(page, f"sources @ {width}px")
finally:
@@ -254,7 +255,7 @@ def test_sources_table_full_width(
_reset_db(mock_llm, seed=True)
page = browser.new_page(viewport={"width": 1280, "height": 800})
try:
- page.goto(f"{app_url}/sources.html")
+ login(page, app_url, next="/sources.html") # phase 16: admin-only
page.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
wrap_box = page.locator(".table-wrap").bounding_box()
shell_box = page.locator(".sources-shell").bounding_box()
@@ -268,7 +269,7 @@ def test_sources_table_full_width(
mobile = browser.new_page(viewport={"width": 375, "height": 812})
try:
- mobile.goto(f"{app_url}/sources.html")
+ login(mobile, app_url, next="/sources.html") # phase 16: admin-only
mobile.locator("#docs-tbody tr").first.wait_for(state="visible", timeout=10_000)
scroll, client = mobile.evaluate(
"() => { const el = document.querySelector('.table-wrap');"
@@ -389,7 +390,8 @@ def test_contrast_pairs_pass_aa(
_assert_aa(pairs["deflection"], "deflection ink on deflection bg")
# Sources page: ink-soft/surface, white/brand (active nav).
- page.goto(f"{app_url}/sources.html")
+ # (Phase 16: the stat cards are admin-only — sign in first.)
+ login(page, app_url, next="/sources.html")
page.locator(".stat-card").first.wait_for(state="visible", timeout=10_000)
pairs = page.evaluate(
"""() => {
@@ -498,7 +500,7 @@ def test_long_content_wraps_without_overflow(
# Sources @ 360px: the long path ellipsizes, full path stays in `title`.
phone = browser.new_page(viewport={"width": 360, "height": 740})
try:
- phone.goto(f"{app_url}/sources.html")
+ login(phone, app_url, next="/sources.html") # phase 16: admin-only
row = phone.locator("#docs-tbody tr", has_text="backup_rotation").first
row.wait_for(state="visible", timeout=10_000)
cell = row.get_by_role("cell").nth(1)
diff --git a/tests/e2e/test_retrieval_quality.py b/tests/e2e/test_retrieval_quality.py
index d3437b2..6a0af2d 100644
--- a/tests/e2e/test_retrieval_quality.py
+++ b/tests/e2e/test_retrieval_quality.py
@@ -32,6 +32,7 @@ from app.db import SessionLocal
from app.models import QueryLog
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
+from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
@@ -90,7 +91,15 @@ def test_multi_format_import_hidden_doc_excluded(
assert summary.added == 8
assert summary.formats == {"md": 4, "yaml": 1, "json": 1, "py": 1, "txt": 1}
- r = httpx.get(f"{app_url}/api/docs", timeout=10)
+ # Phase 16: the catalog is admin-only — perform the real form login,
+ # then call the API with the signed cookie the browser now holds.
+ login(page, app_url, next="/sources.html")
+ cookies = {
+ c["name"]: c["value"]
+ for c in page.context.cookies()
+ if "name" in c and "value" in c
+ }
+ r = httpx.get(f"{app_url}/api/docs", timeout=10, cookies=cookies)
assert r.status_code == 200
docs = r.json()["documents"]
assert len(docs) == 8
@@ -103,8 +112,7 @@ def test_multi_format_import_hidden_doc_excluded(
"homelab/ssh/ssh_aliases.txt",
}
- # The Sources page reflects the same set.
- page.goto(f"{app_url}/sources.html")
+ # The Sources page (we're already on it, signed in) reflects the set.
expect(page.locator("#stat-docs")).to_have_text("8")
expect(page.locator("#docs-tbody tr", has_text=".hidden")).to_have_count(0)
diff --git a/tests/e2e/test_steering.py b/tests/e2e/test_steering.py
index a5e3db7..5d4cd22 100644
--- a/tests/e2e/test_steering.py
+++ b/tests/e2e/test_steering.py
@@ -1,5 +1,8 @@
"""Phase 15 E2E (Playwright): tune how Brain answers (steering notes).
+Phase 16 adaptation: tuning is admin-only — every test performs the real
+form login (``e2e.auth_helpers.login``) before touching the tuning UI.
+
Story: ``.agent/user_stories/steering-notes.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
@@ -34,6 +37,7 @@ from app.db import SessionLocal
from app.models import SteeringNote
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
+from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
@@ -125,6 +129,7 @@ def test_tune_under_answer_persists_and_steers(
assert summary is not None and summary.added == 8 # A9 formats
page.set_default_timeout(30_000)
page.goto(app_url)
+ login(page, app_url, next="/") # phase 16: tuning is admin-only
_ask(page, QUESTION)
# The Tune control: ghost button in the answer's meta row, ≥44px.
@@ -165,6 +170,7 @@ def test_delete_note_stops_steering(
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
+ login(page, app_url, next="/") # phase 16: tuning is admin-only
_ask(page, QUESTION)
_tune_and_save(page, NOTE)
@@ -202,6 +208,7 @@ def test_note_rendered_as_text_xss_safe(
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
+ login(page, app_url, next="/") # phase 16: tuning is admin-only
dialogs: list[str] = []
@@ -234,6 +241,7 @@ def test_tuning_panel_a11y(page: Page, app_url: str, db_ready: None) -> None:
_reset_db(mock_port=0, seed=False) # no KB seeding needed for the panel a11y
page.set_default_timeout(30_000)
page.goto(app_url)
+ login(page, app_url, next="/") # phase 16: the panel is admin-only
toggle = page.locator("#steering-toggle")
panel = page.locator("#steering-panel")
diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py
index 516f8ec..3c759a3 100644
--- a/tests/integration/test_api.py
+++ b/tests/integration/test_api.py
@@ -56,6 +56,7 @@ def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None:
("/", "Brain of Reese"),
("/sources.html", "Knowledge base"),
("/document.html", "Brain of Reese"), # phase 10: viewer page
+ ("/login.html", "Sign in"), # phase 16: admin sign-in page
],
)
def test_html_pages_served_locally_no_cdn(client, path: str, marker: str) -> None:
@@ -75,6 +76,7 @@ def test_styles_and_js_served(client) -> None:
assert client.get("/assets/sources.js").status_code == 200
assert client.get("/assets/markdown.js").status_code == 200 # phase 10: shared renderer
assert client.get("/assets/document.js").status_code == 200 # phase 10: viewer page
+ assert client.get("/assets/login.js").status_code == 200 # phase 16: login page
# Emoji code points banned from UI chrome (phase 08): the pictograph
@@ -104,10 +106,12 @@ def _find_emoji(text: str) -> list[str]:
"/",
"/sources.html",
"/document.html",
+ "/login.html", # phase 16
"/assets/app.js",
"/assets/sources.js",
"/assets/markdown.js",
"/assets/document.js",
+ "/assets/login.js", # phase 16
"/assets/styles.css",
],
)
diff --git a/tests/integration/test_auth_api.py b/tests/integration/test_auth_api.py
new file mode 100644
index 0000000..0ebf4d5
--- /dev/null
+++ b/tests/integration/test_auth_api.py
@@ -0,0 +1,190 @@
+"""Integration: the auth surface (phase 16) + the public-API regression
+guards.
+
+Covers the full login/logout lifecycle against the real app (TestClient
+keeps the cookie jar): wrong password → 401 + still-gated; correct →
+204 + cookie → admin everywhere gated; logout → 403 again. And the
+**anonymous** guarantees that phase 16 must not break: the document
+viewer stays public (soft rule) and ``POST /api/chat`` still streams.
+
+Requires: podman compose up -d db
+"""
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Iterator
+from pathlib import Path
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy import text
+from test_chat_api import FakeRagLLM, _stream_chat
+
+from app.api import chat as chat_api
+from app.main import app as fastapi_app
+from app.models import Document
+from app.rag.importer import import_sources
+from tests.conftest import ADMIN_PASSWORD
+
+FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "docs"
+QUESTION = "How is my Kubernetes cluster set up?"
+
+
+@pytest.fixture(autouse=True)
+def clean_tables(db) -> Iterator[None]:
+ """Docs + steering + query log are global state: reset around tests."""
+ db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
+ db.commit()
+ yield
+ db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
+ db.commit()
+
+
+@pytest.fixture()
+def seeded_kb(db) -> Iterator[FakeRagLLM]:
+ """Fresh Postgres with the fixture docs imported (real pipeline)."""
+ db.execute(text("TRUNCATE chunks, documents, query_log"))
+ db.commit()
+ llm = FakeRagLLM()
+ summary = asyncio.run(import_sources([FIXTURES], llm, session=db))
+ assert summary.added == 8 # A9 formats; .hidden/ skipped
+ yield llm
+ db.execute(text("TRUNCATE chunks, documents, query_log"))
+ db.commit()
+
+
+def _seed_one_doc(db) -> Document:
+ """One minimal document (for the public document-content endpoint)."""
+ doc = Document(
+ source="docs",
+ path="homelab/kubernetes.md",
+ full_path="/tmp/kubernetes.md",
+ title="Kubernetes Homelab Cluster",
+ content="# Kubernetes Homelab Cluster\n\nTalos on 3 nodes.",
+ content_hash="c" * 64,
+ )
+ db.add(doc)
+ db.commit()
+ db.refresh(doc)
+ return doc
+
+
+def test_wrong_password_401_and_still_gated(client: TestClient) -> None:
+ r = client.post("/api/login", json={"password": "not-the-password"})
+ assert r.status_code == 401
+ assert r.json() == {"detail": "invalid password"}
+ # No session state was created by the failed attempt.
+ assert "bor_session" not in client.cookies
+
+ r = client.post("/api/login", json={"password": ""}) # empty → same 401
+ assert r.status_code == 401
+ assert r.json() == {"detail": "invalid password"}
+
+ # The gated surface stays closed (anonymous).
+ assert client.get("/api/docs").status_code == 403
+ r = client.get("/api/steering")
+ assert r.status_code == 403
+ assert r.json() == {"detail": "admin only"}
+ assert client.post("/api/steering", json={"note": "x"}).status_code == 403
+ assert client.get("/api/whoami").json() == {
+ "authenticated": False,
+ "role": "anonymous",
+ }
+
+
+def test_login_logout_lifecycle(client: TestClient) -> None:
+ # Anonymous shape before anything.
+ who = client.get("/api/whoami").json()
+ assert who == {"authenticated": False, "role": "anonymous"}
+
+ # Wrong first, right second — one generic 401, then success.
+ assert client.post("/api/login", json={"password": "nope"}).status_code == 401
+ r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
+ assert r.status_code == 204
+ assert "bor_session" in client.cookies # the signed session cookie
+
+ # Admin: whoami + the gated endpoints all open up.
+ assert client.get("/api/whoami").json() == {
+ "authenticated": True,
+ "role": "admin",
+ }
+ assert client.get("/api/docs").status_code == 200
+
+ created = client.post("/api/steering", json={"note": " be terse "})
+ assert created.status_code == 201
+ note = created.json()
+ assert note["note"] == "be terse"
+ listing = client.get("/api/steering")
+ assert listing.status_code == 200
+ assert [n["note"] for n in listing.json()["notes"]] == ["be terse"]
+ assert client.delete(f"/api/steering/{note['id']}").status_code == 204
+ assert client.get("/api/steering").json() == {"notes": []}
+
+ # Logout: 204, cookie gone, gated again.
+ assert client.post("/api/logout").status_code == 204
+ assert "bor_session" not in client.cookies
+ assert client.get("/api/whoami").json() == {
+ "authenticated": False,
+ "role": "anonymous",
+ }
+ assert client.get("/api/docs").status_code == 403
+ r = client.get("/api/steering")
+ assert r.status_code == 403
+ assert r.json() == {"detail": "admin only"}
+ # Logout is idempotent (anonymous logout is still a clean 204).
+ assert client.post("/api/logout").status_code == 204
+ assert client.get("/api/whoami").json()["authenticated"] is False
+
+
+def test_forged_cookie_is_rejected(client: TestClient) -> None:
+ client.cookies.set("bor_session", "tampered-session-blob")
+ assert client.get("/api/whoami").json() == {
+ "authenticated": False,
+ "role": "anonymous",
+ }
+ assert client.get("/api/docs").status_code == 403
+
+
+def test_anonymous_document_content_stays_public(client: TestClient, db) -> None:
+ """Soft rule (phase 16): the catalog is gated, the viewer is not."""
+ _seed_one_doc(db)
+
+ r = client.get(
+ "/api/documents/content", params={"source": "docs", "path": "homelab/kubernetes.md"}
+ )
+ assert r.status_code == 200
+ body = r.json()
+ assert body["title"] == "Kubernetes Homelab Cluster"
+ assert "Talos" in body["content"]
+ assert set(body) == {
+ "source",
+ "path",
+ "title",
+ "format",
+ "content",
+ "indexed_at",
+ "chunks",
+ }
+
+ # Unknown docs still 404 anonymously (no enumeration of titles).
+ r = client.get("/api/documents/content", params={"source": "docs", "path": "nope.md"})
+ assert r.status_code == 404
+
+
+def test_anonymous_chat_still_streams(
+ client: TestClient, seeded_kb: FakeRagLLM
+) -> None:
+ """Regression guard: sign-in must not have locked chat (A10 public)."""
+ fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
+ try:
+ status, content_type, frames = _stream_chat(client, QUESTION)
+ finally:
+ fastapi_app.dependency_overrides.clear()
+
+ assert status == 200
+ assert content_type.startswith("text/event-stream")
+ deltas = [f for f in frames if f.get("type") == "delta"]
+ assert len(deltas) >= 2 # genuinely streamed
+ assert frames[-1]["type"] == "done"
+ assert frames[-1]["deflected"] is False
+ assert frames[-1]["sources"][0]["path"] == "homelab/kubernetes.md"
diff --git a/tests/integration/test_docs_api.py b/tests/integration/test_docs_api.py
index 0e37ad9..7a7a2d7 100644
--- a/tests/integration/test_docs_api.py
+++ b/tests/integration/test_docs_api.py
@@ -12,15 +12,15 @@ from sqlalchemy import text
from app.models import Chunk, Document
-def test_docs_empty_shape(client, db) -> None:
+def test_docs_empty_shape(admin_client, db) -> None:
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
- r = client.get("/api/docs")
+ r = admin_client.get("/api/docs")
assert r.status_code == 200
assert r.json() == {"documents": []}
-def test_docs_populated_shape_sorted_with_chunk_counts(client, db) -> None:
+def test_docs_populated_shape_sorted_with_chunk_counts(admin_client, db) -> None:
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
now = datetime.now(UTC)
@@ -50,7 +50,7 @@ def test_docs_populated_shape_sorted_with_chunk_counts(client, db) -> None:
)
db.commit()
- r = client.get("/api/docs")
+ r = admin_client.get("/api/docs")
assert r.status_code == 200
body = r.json()
# Ordered by (source, path): Deployments < Homelab.
@@ -69,8 +69,8 @@ def test_docs_populated_shape_sorted_with_chunk_counts(client, db) -> None:
db.commit()
-def test_docs_response_matches_schema_shape(client, db) -> None:
- r = client.get("/api/docs")
+def test_docs_response_matches_schema_shape(admin_client, db) -> None:
+ r = admin_client.get("/api/docs")
assert r.status_code == 200
body = r.json()
assert set(body) == {"documents"}
diff --git a/tests/integration/test_importer_e2e.py b/tests/integration/test_importer_e2e.py
index 984eb9b..0dbdd0e 100644
--- a/tests/integration/test_importer_e2e.py
+++ b/tests/integration/test_importer_e2e.py
@@ -30,7 +30,7 @@ EXPECTED_DOCS = {
}
-def test_import_fixtures_end_to_end(client, db) -> None:
+def test_import_fixtures_end_to_end(admin_client, db) -> None:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit()
llm = FakeEmbedder()
@@ -67,7 +67,7 @@ def test_import_fixtures_end_to_end(client, db) -> None:
assert c.embedding is not None and len(c.embedding) == 768
# The Sources page consumes exactly this shape.
- r = client.get("/api/docs")
+ r = admin_client.get("/api/docs") # phase 16: the catalog is admin-only
assert r.status_code == 200
body = r.json()
assert len(body["documents"]) == 8
diff --git a/tests/integration/test_steering.py b/tests/integration/test_steering.py
index 9ec0dec..030bf2d 100644
--- a/tests/integration/test_steering.py
+++ b/tests/integration/test_steering.py
@@ -63,8 +63,8 @@ def _turn_log_lines(caplog: pytest.LogCaptureFixture) -> list[str]:
# ---------- CRUD ----------
-def test_create_note_returns_201_and_stores_trimmed(client: TestClient, db) -> None:
- r = client.post("/api/steering", json={"note": f" {NOTE} "})
+def test_create_note_returns_201_and_stores_trimmed(admin_client: TestClient, db) -> None:
+ r = admin_client.post("/api/steering", json={"note": f" {NOTE} "})
assert r.status_code == 201
body = r.json()
assert body["note"] == NOTE # trimmed before storage
@@ -74,13 +74,13 @@ def test_create_note_returns_201_and_stores_trimmed(client: TestClient, db) -> N
assert [row.note for row in rows] == [NOTE]
-def test_list_notes_empty(client: TestClient) -> None:
- r = client.get("/api/steering")
+def test_list_notes_empty(admin_client: TestClient) -> None:
+ r = admin_client.get("/api/steering")
assert r.status_code == 200
assert r.json() == {"notes": []}
-def test_list_notes_newest_first(client: TestClient, db) -> None:
+def test_list_notes_newest_first(admin_client: TestClient, db) -> None:
base = datetime.now(UTC)
db.add_all(
[
@@ -91,7 +91,7 @@ def test_list_notes_newest_first(client: TestClient, db) -> None:
)
db.commit()
- r = client.get("/api/steering")
+ r = admin_client.get("/api/steering")
assert r.status_code == 200
body = r.json()
assert [n["note"] for n in body["notes"]] == ["newest", "middle", "oldest"]
@@ -100,33 +100,33 @@ def test_list_notes_newest_first(client: TestClient, db) -> None:
uuid.UUID(n["id"])
-def test_delete_note_returns_204_and_removes(client: TestClient, db) -> None:
- created = client.post("/api/steering", json={"note": NOTE}).json()
+def test_delete_note_returns_204_and_removes(admin_client: TestClient, db) -> None:
+ created = admin_client.post("/api/steering", json={"note": NOTE}).json()
- assert client.delete(f"/api/steering/{created['id']}").status_code == 204
- assert client.get("/api/steering").json() == {"notes": []}
+ assert admin_client.delete(f"/api/steering/{created['id']}").status_code == 204
+ assert admin_client.get("/api/steering").json() == {"notes": []}
assert db.scalars(select(SteeringNote)).all() == []
-def test_delete_unknown_note_returns_404(client: TestClient) -> None:
- r = client.delete(f"/api/steering/{uuid.uuid4()}")
+def test_delete_unknown_note_returns_404(admin_client: TestClient) -> None:
+ r = admin_client.delete(f"/api/steering/{uuid.uuid4()}")
assert r.status_code == 404
assert "not found" in r.json()["detail"]
-def test_delete_invalid_id_returns_422(client: TestClient) -> None:
- assert client.delete("/api/steering/not-a-uuid").status_code == 422
+def test_delete_invalid_id_returns_422(admin_client: TestClient) -> None:
+ assert admin_client.delete("/api/steering/not-a-uuid").status_code == 422
-def test_create_rejects_empty_and_blank_notes(client: TestClient) -> None:
- assert client.post("/api/steering", json={"note": ""}).status_code == 422
- assert client.post("/api/steering", json={"note": " \t\n "}).status_code == 422
- assert client.get("/api/steering").json() == {"notes": []}
+def test_create_rejects_empty_and_blank_notes(admin_client: TestClient) -> None:
+ assert admin_client.post("/api/steering", json={"note": ""}).status_code == 422
+ assert admin_client.post("/api/steering", json={"note": " \t\n "}).status_code == 422
+ assert admin_client.get("/api/steering").json() == {"notes": []}
-def test_create_enforces_2000_char_limit(client: TestClient) -> None:
- assert client.post("/api/steering", json={"note": "x" * 2001}).status_code == 422
- r = client.post("/api/steering", json={"note": "x" * 2000})
+def test_create_enforces_2000_char_limit(admin_client: TestClient) -> None:
+ assert admin_client.post("/api/steering", json={"note": "x" * 2001}).status_code == 422
+ r = admin_client.post("/api/steering", json={"note": "x" * 2000})
assert r.status_code == 201
assert len(r.json()["note"]) == 2000
@@ -135,14 +135,14 @@ def test_create_enforces_2000_char_limit(client: TestClient) -> None:
def test_chat_turn_high_mode_receives_note_in_system_prompt(
- client: TestClient, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
+ admin_client: TestClient, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
) -> None:
- client.post("/api/steering", json={"note": NOTE})
+ admin_client.post("/api/steering", json={"note": NOTE})
caplog.set_level(logging.INFO, logger="app.chat")
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
try:
- _, _, frames = _stream_chat(client, QUESTION)
+ _, _, frames = _stream_chat(admin_client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
@@ -170,14 +170,14 @@ def test_chat_turn_high_mode_receives_note_in_system_prompt(
def test_chat_turn_low_mode_receives_note_in_system_prompt(
- client: TestClient, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
+ admin_client: TestClient, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
) -> None:
- client.post("/api/steering", json={"note": NOTE})
+ admin_client.post("/api/steering", json={"note": NOTE})
caplog.set_level(logging.INFO, logger="app.chat")
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
try:
- _, _, frames = _stream_chat(client, OFF_TOPIC)
+ _, _, frames = _stream_chat(admin_client, OFF_TOPIC)
finally:
fastapi_app.dependency_overrides.clear()
@@ -192,13 +192,13 @@ def test_chat_turn_low_mode_receives_note_in_system_prompt(
def test_chat_turn_without_notes_has_no_tuning_section(
- client: TestClient, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
+ admin_client: TestClient, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
) -> None:
caplog.set_level(logging.INFO, logger="app.chat")
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
try:
- _stream_chat(client, QUESTION)
+ _stream_chat(admin_client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
@@ -208,7 +208,7 @@ def test_chat_turn_without_notes_has_no_tuning_section(
assert lines and "tuning=0" in lines[-1]
-def test_chat_turn_numbers_notes_oldest_first(client: TestClient, db, seeded_kb) -> None:
+def test_chat_turn_numbers_notes_oldest_first(admin_client: TestClient, db, seeded_kb) -> None:
base = datetime.now(UTC)
db.add_all(
[
@@ -220,7 +220,7 @@ def test_chat_turn_numbers_notes_oldest_first(client: TestClient, db, seeded_kb)
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
try:
- _stream_chat(client, QUESTION)
+ _stream_chat(admin_client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
@@ -231,22 +231,22 @@ def test_chat_turn_numbers_notes_oldest_first(client: TestClient, db, seeded_kb)
assert system["content"].index("1. older note") < system["content"].index("2. newer note")
-def test_multiple_turns_keep_reading_notes(client: TestClient, seeded_kb) -> None:
+def test_multiple_turns_keep_reading_notes(admin_client: TestClient, seeded_kb) -> None:
"""The note steers EVERY subsequent turn, not just the next one."""
- client.post("/api/steering", json={"note": NOTE})
+ admin_client.post("/api/steering", json={"note": NOTE})
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
try:
- _stream_chat(client, QUESTION)
- _stream_chat(client, QUESTION)
+ _stream_chat(admin_client, QUESTION)
+ _stream_chat(admin_client, QUESTION)
assert len(seeded_kb.seen_messages) == 2
for messages in seeded_kb.seen_messages:
assert f"1. {NOTE}" in messages[0]["content"]
# Delete → the following turn is clean again.
- note_id = client.get("/api/steering").json()["notes"][0]["id"]
- assert client.delete(f"/api/steering/{note_id}").status_code == 204
- _stream_chat(client, QUESTION)
+ note_id = admin_client.get("/api/steering").json()["notes"][0]["id"]
+ assert admin_client.delete(f"/api/steering/{note_id}").status_code == 204
+ _stream_chat(admin_client, QUESTION)
assert len(seeded_kb.seen_messages) == 3
assert "" not in seeded_kb.seen_messages[-1][0]["content"]
finally:
diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py
new file mode 100644
index 0000000..0402942
--- /dev/null
+++ b/tests/unit/test_auth.py
@@ -0,0 +1,153 @@
+"""Unit tests: single-admin auth (phase 16; A10 revised).
+
+Covers the config gate (fail-loud, including via ``create_app``), the
+constant-time password check, the ``require_admin`` dependency, the
+whoami payload shape, and the sign_in/sign_out session semantics.
+"""
+from __future__ import annotations
+
+import pytest
+from fastapi import HTTPException
+from starlette.middleware.sessions import Session
+from starlette.requests import Request
+
+import app.main as main_mod
+from app.api.auth import whoami
+from app.config import Settings
+from app.core.auth import (
+ check_password,
+ ensure_admin_configured,
+ require_admin,
+ sign_in,
+ sign_out,
+)
+from app.schemas import WhoamiResponse
+
+
+def _settings(**kwargs: object) -> Settings:
+ return Settings(_env_file=None, **kwargs) # pyright: ignore[reportCallIssue]
+
+
+# ---------- ensure_admin_configured (fail-loud) ----------
+
+
+def test_configured_passes() -> None:
+ ensure_admin_configured(_settings(admin_password="pw", session_secret="s"))
+
+
+@pytest.mark.parametrize(
+ ("password", "secret", "named"),
+ [
+ ("", "s", "BOR_ADMIN_PASSWORD"),
+ ("pw", "", "BOR_SESSION_SECRET"),
+ ("", "", "BOR_ADMIN_PASSWORD"),
+ ("", "", "BOR_SESSION_SECRET"),
+ ],
+)
+def test_missing_vars_raise_naming_them(password: str, secret: str, named: str) -> None:
+ with pytest.raises(RuntimeError) as exc:
+ ensure_admin_configured(_settings(admin_password=password, session_secret=secret))
+ assert named in str(exc.value)
+ # Both vars are named when both are missing.
+ if not password and not secret:
+ assert "BOR_ADMIN_PASSWORD" in str(exc.value)
+ assert "BOR_SESSION_SECRET" in str(exc.value)
+
+
+def test_whitespace_only_counts_as_missing() -> None:
+ with pytest.raises(RuntimeError, match="BOR_ADMIN_PASSWORD"):
+ ensure_admin_configured(_settings(admin_password=" ", session_secret="s"))
+
+
+def test_create_app_raises_when_admin_password_missing(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(main_mod.settings, "admin_password", "")
+ with pytest.raises(RuntimeError, match="BOR_ADMIN_PASSWORD"):
+ main_mod.create_app()
+
+
+def test_create_app_raises_when_session_secret_missing(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(main_mod.settings, "session_secret", "")
+ with pytest.raises(RuntimeError, match="BOR_SESSION_SECRET"):
+ main_mod.create_app()
+
+
+def test_create_app_boots_when_configured(monkeypatch: pytest.MonkeyPatch) -> None:
+ # conftest set both env vars before app.main imported — the factory
+ # with valid config returns an app (and no static-dir warning fires
+ # here: the frontend dir exists in the repo).
+ monkeypatch.setattr(main_mod.settings, "admin_password", "pw")
+ monkeypatch.setattr(main_mod.settings, "session_secret", "s")
+ app2 = main_mod.create_app()
+ assert app2 is not None
+
+
+# ---------- check_password (constant-time, one generic result) ----------
+
+
+def test_check_password_match() -> None:
+ assert check_password("hunter2", "hunter2") is True
+
+
+def test_check_password_mismatch() -> None:
+ assert check_password("hunter2", "hunter3") is False
+
+
+def test_check_password_empty_candidate() -> None:
+ assert check_password("", "hunter2") is False
+
+
+def test_check_password_unicode() -> None:
+ assert check_password("pässwörd", "pässwörd") is True
+ assert check_password("pässwörd", "pässwörX") is False
+
+
+# ---------- require_admin (dependency: 403 for anonymous) ----------
+
+
+def _request_with_session(**session: object) -> Request:
+ # request.session is a scope-backed property (SessionMiddleware puts
+ # the Session into the scope) — build the scope the same way.
+ return Request({"type": "http", "session": Session(dict(session))})
+
+
+def test_require_admin_passes_for_admin_session() -> None:
+ require_admin(_request_with_session(admin=True)) # no exception
+
+
+@pytest.mark.parametrize("session", [{}, {"admin": False}, {"admin": None}])
+def test_require_admin_403s_anonymous(session: dict) -> None:
+ with pytest.raises(HTTPException) as exc:
+ require_admin(_request_with_session(**session))
+ assert exc.value.status_code == 403
+ assert exc.value.detail == "admin only"
+
+
+# ---------- whoami payload shape ----------
+
+
+def test_whoami_anonymous_payload() -> None:
+ body = whoami(_request_with_session())
+ assert body == WhoamiResponse(authenticated=False, role="anonymous")
+ assert set(body.model_dump()) == {"authenticated", "role"}
+
+
+def test_whoami_admin_payload() -> None:
+ body = whoami(_request_with_session(admin=True))
+ assert body == WhoamiResponse(authenticated=True, role="admin")
+
+
+# ---------- sign_in / sign_out session semantics ----------
+
+
+def test_sign_in_marks_session_admin_and_modified() -> None:
+ session = Session({})
+ sign_in(session)
+ assert session["admin"] is True
+ assert session.modified is True # the middleware will persist the cookie
+
+
+def test_sign_out_clears_session() -> None:
+ session = Session({"admin": True, "stray": "x"})
+ sign_out(session)
+ assert dict(session) == {}
+ assert "admin" not in session
diff --git a/uv.lock b/uv.lock
index d4579e7..b7ccf45 100644
--- a/uv.lock
+++ b/uv.lock
@@ -55,6 +55,7 @@ dependencies = [
{ name = "alembic" },
{ name = "fastapi" },
{ name = "httpx" },
+ { name = "itsdangerous" },
{ name = "openai" },
{ name = "pgvector" },
{ name = "psycopg", extra = ["binary"] },
@@ -80,6 +81,7 @@ requires-dist = [
{ name = "alembic", specifier = ">=1.13,<2.0" },
{ name = "fastapi", specifier = ">=0.115,<1.0" },
{ name = "httpx", specifier = ">=0.27,<1.0" },
+ { name = "itsdangerous", specifier = ">=2.2,<3.0" },
{ name = "openai", specifier = ">=1.40,<3.0" },
{ name = "pgvector", specifier = ">=0.3,<1.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.1,<4.0" },
@@ -433,6 +435,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
+[[package]]
+name = "itsdangerous"
+version = "2.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
+]
+
[[package]]
name = "jiter"
version = "0.16.0"