feat(auth): single-admin password login (signed cookie) — gate tuning + Sources catalog, keep chat and document viewer public

This commit is contained in:
2026-08-23 19:58:39 -04:00
parent fc0d9a2d5c
commit cbc263a4b2
46 changed files with 1555 additions and 691 deletions
Binary file not shown.
@@ -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;
`<caption class="visually-hidden">`, `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"
```
@@ -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, `<relevance>HIGH|LOW</relevance>`, `<documents>`
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
`<script>alert(1)</script>` 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"
```
@@ -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"
```
@@ -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 `<button type="button" class="suggestion-chip" role="listitem">`
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"
```
@@ -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"
```
@@ -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"
```
@@ -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', <OR-joined
stemmed tokens of the question>)` (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"
```
-52
View File
@@ -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"
```
+8
View File
@@ -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
+66 -1
View File
@@ -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=<the hex from above> # 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 `<tuning>` (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 |
+63
View File
@@ -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",
)
+15 -3
View File
@@ -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"))
+14 -6
View File
@@ -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 ``<tuning>`` 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 ``<tuning>`` 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]:
+14
View File
@@ -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
+89
View File
@@ -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()
+26
View File
@@ -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")
+17
View File
@@ -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
+56 -3
View File
@@ -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
/* 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();
loadSteering(); // phase 15: tuning notes (panel + count badge)
if (isAdmin) loadSteering(); // phase 15: panel + count badge (admin only)
})();
+80
View File
@@ -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();
})();
+25
View File
@@ -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;
}
(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();
})();
+124
View File
@@ -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. */
+11
View File
@@ -34,6 +34,17 @@
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
<span class="new-chat-label">New chat</span>
</button>
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign out
is visible; /api/whoami decides at load (app.js). Icon-only
below 640px (aria-labels keep the accessible names). -->
<a href="/login.html?next=/sources.html" class="auth-link" id="sign-in-link" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
<span class="auth-label">Sign in</span>
</a>
<button type="button" class="auth-link" id="sign-out-btn" aria-label="Sign out" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
<span class="auth-label">Sign out</span>
</button>
</div>
</header>
+62
View File
@@ -0,0 +1,62 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="description" content="Admin sign-in for Brain of Reese — unlocks the full Sources catalog and answer tuning.">
<title>Sign in · Brain of Reese</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%23121a2e%22%20stroke=%22%236d78f2%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%236d78f2%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%2322d3ee%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<!-- Standard app frame + sticky header (phase 12 consistency). -->
<header class="app-header">
<div class="container header-inner">
<span class="brand">
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
<span class="brand-text">Brain of <strong>Reese</strong></span>
</span>
<nav class="app-nav" aria-label="Primary">
<a href="/" class="nav-link">Chat</a>
<a href="/sources.html" class="nav-link">Sources</a>
</nav>
</div>
</header>
<main id="main" class="app-main" tabindex="-1">
<div class="container login-shell">
<!-- Centered sign-in card (phase 16): one admin, one password. -->
<section class="login-card" aria-labelledby="login-title">
<h1 id="login-title">Sign in</h1>
<p class="login-sub">
One admin account, one password. Signing in unlocks the full
Sources catalog and the answer-tuning controls — chat stays open
to everyone either way.
</p>
<form id="login-form">
<label class="visually-hidden" for="login-password">Admin password</label>
<input
id="login-password"
name="password"
type="password"
autocomplete="current-password"
required
>
<button type="submit" class="login-submit" id="login-submit">Sign in</button>
</form>
<p id="login-error" class="login-error" role="alert" hidden></p>
</section>
</div>
</main>
<footer class="app-footer">
<div class="container footer-inner">
<span>Powered by Reese's self-hosted models</span>
</div>
</footer>
<script type="module" src="/assets/login.js"></script>
</body>
</html>
+15
View File
@@ -34,6 +34,21 @@
</p>
</div>
<!-- Phase 16: anonymous sign-in gate. The catalog is what the
login locks — the document viewer itself stays public (soft
rule), so the copy says what stays open. -->
<section class="sources-gate" id="sources-gate" aria-labelledby="sources-gate-title" hidden>
<div class="sources-gate-glyph" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg>
</div>
<h2 id="sources-gate-title">Sign in to view the full catalog</h2>
<p class="sources-gate-sub">
The complete list of indexed documents is admin-only. Chat — and
any document an answer cites — stays open to everyone.
</p>
<a class="sources-gate-link" href="/login.html?next=/sources.html">Sign in</a>
</section>
<div class="stat-cards" id="stat-cards">
<div class="stat-card" role="group" aria-label="Document statistics">
<span class="stat-value" id="stat-docs">–</span>
+4
View File
@@ -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]
+21
View File
@@ -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``).
+38
View File
@@ -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)
+10
View File
@@ -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"],
+326
View File
@@ -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 <label for=…>).
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)
+4 -1
View File
@@ -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)
+4 -2
View File
@@ -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(
"""() => {
+2 -1
View File
@@ -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)
+2 -1
View File
@@ -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)
+18
View File
@@ -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
+8 -4
View File
@@ -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(
+7 -5
View File
@@ -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)
+11 -3
View File
@@ -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)
+8
View File
@@ -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")
+4
View File
@@ -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",
],
)
+190
View File
@@ -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"
+6 -6
View File
@@ -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"}
+2 -2
View File
@@ -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
+38 -38
View File
@@ -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 "<tuning>" not in seeded_kb.seen_messages[-1][0]["content"]
finally:
+153
View File
@@ -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
Generated
+11
View File
@@ -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"