feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
Single consolidated commit for four completed, validated phases (77, 78, 79, 80). The pipeline run left all work uncommitted because the harness commits only with PHASE_COMMIT=1 while child executors are forbidden from committing; the phases themselves all passed validation and moved to .agents/phases/complete/. Phase 77 — navbar view refresh - router.js dispatches bor:view-refresh on re-show / active re-click / popstate (gated on wasMounted; first show and boot exempt) - History / RAG / Sources / Tuning re-fetch on refresh (admin branch); Chat deliberately excluded (stream survival) - History "Refresh" button (admin-only, in-flight disable + status line) - New story suite tests/e2e/test_navbar_refresh.py (7 tests) Phase 78 — static background - Removed the animated glow layers; static 44px grid over the flat --bg canvas; default and reduced-motion renders byte-identical - Updated background/theme E2E suites; removed bg-glow test pins Phase 79 — API tokens - api_tokens model + migration 0012; hash-only token service - Admin tokens API + Tokens admin view; POST /api/token-auth; live-revoking require_user on chat / suggestions / document content - Frontend token gate with localStorage cache; anonymous E2E suites migrated to token login - New story suite tests/e2e/test_api_tokens.py (9 tests) Phase 80 — history suggestion chips - last_questions() endpoint with SEED fallback; startNewChat() refetch - Seed-semantics docs (config.py, .env.example, README) - Integration state matrix + E2E suite rewritten to the 4 chip states Also included: phase-76 report artifacts and the repo restore-test-db skill (previously untracked), scripts/* ruff fixes from phase 77. Final gate state (phase 80 final pass, covers everything above): - uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99% - uv run ruff check . && uv run pyright → clean, 0 errors - Per-phase story E2E suites green in isolation
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# Phase 77 — Navbar clicks refresh the view's data (fresh list on re-show + a History refresh button)
|
||||
|
||||
**Source:** `TODO.md` L3 — "Clicking navbar icons should refresh the relevant page. For example, clicking 'history' doesn't load new history until I refresh. The history page should also have a refresh button."
|
||||
**Story:** n/a (TODO-derived — descendant of `.agents/user_stories/chat-history.md` (phase 50) and the phase-76 shell)
|
||||
**Context:** `frontend/assets/router.js` (the phase-76 shell router: the `VIEW` map, mount-once / hide-forever, `switchTo(name, { userInitiated })`, the delegated nav click handler with its `name === current` early return, the `popstate` handler), `frontend/assets/history.js` (the History view module — `loadChats()` APPENDS rows and is called once at mount), `frontend/assets/sources.js` (`loadDocs()` ~line 472 — already clears `tbody` before rendering), `frontend/assets/git-sources.js` (`loadSources()` ~line 230 — render + announce), `frontend/assets/tuning.js` (`loadNotes()` ~line 116 — `renderNotes` clears), `frontend/index.html` (`#view-history`'s `.page-head` — h1 "Saved chats" + sub; the `#history-status` live region), `tests/unit/test_frontend_router.py` (the source-level router pins), `tests/e2e/test_nav_switch_keeps_stream.py` (the phase-76 suite — must stay green UNCHANGED).
|
||||
|
||||
## Objective
|
||||
Since the phase-76 shell, a view's data is fetched exactly once, at mount (mount-once, hide-forever) — a History view opened at 10:00 still shows 10:00's data at 10:30. Make every user-initiated re-show of a view re-fetch its list, and give History an explicit Refresh button. The Chat view is deliberately out of scope: its in-flight stream and local conversation must survive (the phase-76 contract).
|
||||
|
||||
## Owner decisions (chat, 2026-09-06 — recorded per AGENTS.md rule 3)
|
||||
- **A1 confirmed:** "the relevant page" = the four data views (History, RAG, Sources, Tuning). **Chat is EXCLUDED from the refresh hook** — the in-flight SSE stream and the local conversation persist (the phase-76 LOCKED refinement).
|
||||
- The refresh fires on: (a) a switch TO the view when it is already mounted, (b) a re-click of the active view's own nav link (today a no-op), (c) back/forward (`popstate`) onto an already-mounted view. The FIRST show (the mount) and the boot never fire it — the mount's own load is the first fetch.
|
||||
- The History refresh button lives in the view's page-head and announces through the existing `#history-status` live region.
|
||||
|
||||
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||
- **Mechanism — a DOM event, zero router-state changes:** on any user-initiated re-show, the router dispatches `new CustomEvent("bor:view-refresh")` on the view's `<section>` root. A view module that wants fresh data listens on its own `root` inside `mount()` and re-runs its existing load function. Modules that do not listen are unaffected — the chat view never listens.
|
||||
- **First-show exemption (the no-double-fetch rule):** in `switchTo`, capture `const wasMounted = mounted[name]` BEFORE the mount block. After the view is shown and the head/nav state is written, dispatch the event `if (wasMounted)` — a re-show. The first show (mount) loads once and dispatches nothing; boot (`userInitiated: false`) can never dispatch (boot always finds an unmounted view or the chat view, and the dispatch site is gated on `wasMounted`).
|
||||
- **Active-view re-click:** the click handler's `if (name === current) return;` becomes: dispatch `bor:view-refresh` on `viewEls[name]` and return — NO `pushState` (the URL already IS that view's path); the mobile menu still closes (the container handler runs regardless).
|
||||
- **Re-entrance of the load functions:** each must be safe to call repeatedly. Verified: `sources.js` `loadDocs` clears via `tbody.replaceChildren()` (check its `showEmpty()` path also clears the rows — if not, clear at the top of `loadDocs`); `tuning.js` `renderNotes` clears via `tuneList.textContent = ""`; `git-sources.js` `renderSources` replaces the list (verify the error/empty states reset on a re-call — `showLoadError` hides table AND empty state). `history.js` `loadChats` **APPENDS** — it must remove the data rows (every `tr` in `#history-tbody` EXCEPT the hidden `#history-empty-row`) before re-fetching.
|
||||
- **Gate guard:** a view only re-fetches after its whoami gate has passed (History: anonymous shows `#history-gate` and NEVER calls `/api/chats` — the phase-50 contract the story E2E pins; the listener must respect the same branch).
|
||||
- **Focus/scroll unchanged:** the router still lands the viewport at the top on user-initiated switches; the refresh is a background re-fetch behind the already-shown view.
|
||||
- **E2E "freshness" proof:** create new backing data via the API AFTER a view has loaded, nav back (or re-click / refresh-button), assert the new row — with the phase-76 canonical same-document sentinel (`window` global set before the clicks is still readable after — no document load).
|
||||
|
||||
## Dependencies
|
||||
— (none; builds on the completed phase-76 shell)
|
||||
|
||||
## Tasks
|
||||
1. `01_router_refresh_hook.md` — the `bor:view-refresh` dispatch in `router.js` (re-show + active re-click + popstate; first show exempt) and the History view re-fetching on it.
|
||||
2. `02_refresh_other_views.md` — RAG, Sources, and Tuning listen and re-fetch; the Chat view stays untouched (with a comment pinning the exclusion).
|
||||
3. `03_history_refresh_button.md` — the History Refresh button + the story E2E suite `test_navbar_refresh.py` + the regression sweep + the atomic commit.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_frontend_router.py` — new source-level pins: the `bor:view-refresh` literal exists; the dispatch is gated on the pre-mount `mounted` state (first show exempt); the re-click branch dispatches instead of a bare `return` (no `pushState`); the three other view modules each carry a listener and `app.js` does NOT (negative pin).
|
||||
- E2E: new story suite `tests/e2e/test_navbar_refresh.py` run in isolation (the scenarios live in task 03).
|
||||
- Coverage: **>90%** on `app/` — this phase is frontend-only; the floor is preserved by not regressing.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_navbar_refresh.py -v --no-cov` green in isolation (DB up).
|
||||
- [ ] `tests/e2e/test_nav_switch_keeps_stream.py` still green UNCHANGED (the stream-survival contract holds with the hook in place).
|
||||
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] One atomic `--no-gpg-sign` Conventional-Commits commit (e.g. `feat(ui): refresh view data on navbar re-show + History refresh button`) whose body cites TODO.md L3; phase dir moved to `.agents/phases/complete/`.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Task 01 — The `bor:view-refresh` hook; History re-fetches on a user-initiated re-show
|
||||
|
||||
**Phase:** `77_navbar_refresh` · **Source:** `TODO.md:3` — "Clicking navbar icons should refresh the relevant page. For example, clicking 'history' doesn't load new history until I refresh."
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
A user-initiated re-show of an already-mounted view re-fetches its data: `router.js` dispatches `bor:view-refresh` on the view's section (re-shows and active re-clicks only — never on the first mount or boot), and the History view listens and re-loads its chat list.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/router.js` — in `switchTo`: capture `const wasMounted = mounted[name]` BEFORE the mount block; after the show + head/nav state is written (before the focus/scroll tail is fine — the event order is: visible → refresh dispatched), `if (wasMounted) root.dispatchEvent(new CustomEvent("bor:view-refresh"))`. In the delegated nav click handler, replace the `if (name === current) return;` early return with a dispatch of `bor:view-refresh` on `viewEls[name]` + `return` (no `pushState` — the URL is already this view's path; the mobile menu still closes via the container handler). The `popstate` path flows through `switchTo`, so it inherits the `wasMounted` gating automatically. Update the file-header contract comment with the refresh rule: the event fires exactly when an already-mounted view is shown again — first show and boot never (the mount's own load is the first fetch).
|
||||
2. `frontend/assets/history.js` — inside `mount(root)`:
|
||||
- make `loadChats()` re-entrant: at its top, remove the data rows — every `tr` in `#history-tbody` EXCEPT the hidden `#history-empty-row` — so a re-load replaces the list instead of appending a duplicate set;
|
||||
- add the listener in the ADMIN branch (after the `fetchIsAdmin()` gate passes — anonymous shows `#history-gate` and never fetches, per the phase-50 contract the story E2E pins): `root.addEventListener("bor:view-refresh", () => { if (started) loadChats(); })` where `started` flips to `true` once the first `loadChats()` call is made (the gate branch that `return`s early must not arm a listener that fetches).
|
||||
3. `tests/unit/test_frontend_router.py` — new source pins (house pattern — read the JS source, no browser): the `bor:view-refresh` literal exists in `router.js`; the dispatch site is guarded by a pre-mount `mounted` capture (assert the `wasMounted`-style capture appears before the mount block — e.g. the capture assignment precedes the `mounted[name]` set); the re-click branch dispatches (assert the `name === current` branch contains the dispatch literal, not a bare return); `history.js` contains the listener.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: item 3 (mechanism-level pins; the existing router pins — pushState switches, mount-once, hidden+inert, single-writer head/nav — must stay green UNCHANGED).
|
||||
- E2E: none yet — the story suite lands in task 03.
|
||||
- Coverage: n/a for this task (frontend-only) — the `app/` floor must not regress.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/unit/test_frontend_router.py -v` green (old + new pins).
|
||||
- [ ] Manual spot check (optional): load History as admin, save a new chat via the API, click another view then History again — the new row is there without a document reload.
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Task 02 — RAG, Sources, and Tuning join the refresh (Chat stays out)
|
||||
|
||||
**Phase:** `77_navbar_refresh` · **Source:** `TODO.md:3` — "Clicking navbar icons should refresh the relevant page."
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
The three remaining data views re-fetch on `bor:view-refresh` with the same gate-guarded, re-entrant pattern as History. The Chat view deliberately does NOT listen — its in-flight stream and local conversation persist (the phase-76 contract).
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/sources.js` — in `mount(root)`, in the admin branch (after the `fetchIsAdmin()` gate passes): `root.addEventListener("bor:view-refresh", () => loadDocs())`. Verify `loadDocs()` re-entrance end to end: the populated path already clears (`tbody.replaceChildren()`); check the `showEmpty()` path — if it does not clear the tbody rows, a refresh from a populated list into an empty result would leave ghost rows: add the row-clear at the top of `loadDocs()` (the hidden empty-row stays in place, exactly the History pattern from task 01).
|
||||
2. `frontend/assets/git-sources.js` — same: `root.addEventListener("bor:view-refresh", () => loadSources())` in the admin branch. Verify a re-call resets all three list states (populated render, the empty state, and the `showLoadError` state — `showLoadError` already hides the table AND the empty state, so an error followed by a successful refresh must clear the error: `loadSources` already calls `hideLoadError()` on success — confirm).
|
||||
3. `frontend/assets/tuning.js` — same: `root.addEventListener("bor:view-refresh", () => loadNotes())` in the admin branch; `renderNotes` already clears (`tuneList.textContent = ""`). Note: `loadNotes` keeps the last rendered list on a failed fetch (its documented contract) — a refresh that fails must behave the same way (no change needed; the listener just calls the function).
|
||||
4. `frontend/assets/app.js` (the chat view) — add NO listener. Add a one-line comment where the chat view's module-scope state begins: the `bor:view-refresh` exclusion is deliberate — the in-flight SSE stream and the local conversation must survive every switch (phase 76), so the chat view never re-fetches on a show.
|
||||
5. `tests/unit/test_frontend_router.py` (or the neighboring source-pin file if the house split the pins — match where the task-01 pins landed) — source pins: `sources.js`, `git-sources.js`, and `tuning.js` each contain the `bor:view-refresh` listener; `app.js` does NOT (negative pin — the exclusion is a contract, not an oversight).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: item 5.
|
||||
- E2E: covered by the task-03 story suite — one assertion per view (mutate the backing data via the API while the view is hidden, nav back, the row reflects the mutation; where a view's backing data has no cheap API mutation path, assert via the Playwright request log that the re-fetch happened on re-show).
|
||||
- Coverage: n/a (frontend) — the `app/` floor preserved.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] All four data views re-fetch on a re-show; the chat view is untouched (stream survival still holds — `test_nav_switch_keeps_stream.py` green).
|
||||
- [ ] The new unit pins are green; `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Task 03 — History refresh button + the story E2E suite + the commit
|
||||
|
||||
**Phase:** `77_navbar_refresh` · **Source:** `TODO.md:3` — "…The history page should also have a refresh button."
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
The explicit half of the item: a visible Refresh control on the History view — plus the phase's Playwright story suite proving every refresh path, the regression sweep, and the atomic commit.
|
||||
|
||||
## Work
|
||||
1. `frontend/index.html` — in `#view-history`'s `.page-head`: add a right-aligned actions slot (`.page-head` becomes a flex row — title block left, actions right; wraps below 640px): `<button type="button" class="history-refresh" id="history-refresh" aria-label="Refresh saved chats">` with the house inline-SVG refresh glyph (`aria-hidden="true"`) + a visible "Refresh" text label (the phase-46 auth-link convention: label visible ≥640px, icon-only below — the `aria-label` keeps the accessible name in both).
|
||||
2. `frontend/assets/history.js` — bind `#history-refresh` (admin branch only — the button lives in the view, which is admin-gated): on click: disable the button (no double-fire while in flight) → `loadChats()` → announce the outcome in `#history-status` (`Saved chats refreshed.` on success; the existing failure lines on a non-2xx / network error — reuse the exact copy `loadChats`'s callers would see) → re-enable the button. The button stays reachable while the empty state is showing (it sits in the page-head, outside the table wrap).
|
||||
3. `frontend/assets/styles.css` — `.history-refresh` (reuse the `.new-chat-btn` visual language: brand background, WCAG 4.5:1, `focus-visible` ring, hover state) + the `.page-head` flex layout (no layout change for views that have no actions slot — the other four views' page-heads are untouched).
|
||||
4. `tests/e2e/test_navbar_refresh.py` (NEW story suite — run in isolation, DB up, the mock-LLM fixture from `tests/e2e/conftest.py`; admin via `auth_helpers.login`):
|
||||
- **re-show refresh:** sign in → create saved chat A via `POST /api/chats` (authed httpx or the UI) → nav to History (row A visible) → create chat B via the API → nav to Chat → nav back to History → row B visible; a `window` sentinel set before the nav clicks is still readable afterwards (the phase-76 canonical no-document-load proof).
|
||||
- **active-view re-click:** with History visible (rows A, B) → create C via the API → click the History nav link AGAIN → C appears; the URL is still `/history.html` (no new history entry — `history.length` unchanged).
|
||||
- **refresh button:** create D via the API → click `#history-refresh` → D appears; `#history-status` announces `Saved chats refreshed.`; the button is `disabled` during the in-flight request (assert via the Playwright request hook or a short-poll on the disabled state).
|
||||
- **popstate:** Chat → History (loads) → create E via the API → `page.go_back()` → History view → E present.
|
||||
- **the four views:** one assertion per data view that a re-show re-fetches (RAG / Sources / Tuning / History — the request-log or row-delta pattern from task 02).
|
||||
- **stream-survival control:** send a question (mock LLM — the ~8 s stream) → mid-stream nav to RAG → back to Chat → the FULL answer completes (the phase-76 contract holds with the hook in place). The unchanged `tests/e2e/test_nav_switch_keeps_stream.py` run in isolation is the additional control.
|
||||
5. Regression sweep + commit: `uv run pytest --cov=app --cov-report=term-missing` (>90%), `uv run ruff check . && uv run pyright`, `tests/e2e/test_nav_switch_keeps_stream.py` green unchanged in isolation, then ONE atomic `--no-gpg-sign` Conventional-Commits commit — e.g. `feat(ui): refresh view data on navbar re-show + History refresh button` — body cites TODO.md L3; move the phase dir to `.agents/phases/complete/`.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E: item 4 is the story gate (AGENTS.md rules 4 + 9 — one file, run in isolation).
|
||||
- Coverage: **>90%** on `app/` (frontend-only phase — the floor is preserved).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_navbar_refresh.py -v --no-cov` green in isolation.
|
||||
- [ ] The Refresh button is visible, keyboard-reachable, and announces (WCAG 2.1 AA basics — AGENTS.md rule 5).
|
||||
- [ ] Full suite + coverage + lint green; the phase-76 suite unchanged-green; one atomic commit; phase dir in `.agents/phases/complete/`.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Phase 78 — Static background (the animated glow layers are removed)
|
||||
|
||||
**Source:** `TODO.md` L4 — "Remove the animated css background, it's too resource intensive"
|
||||
**Story:** n/a (TODO-derived — supersedes the fading-glow contract of `.agents/user_stories/background-no-motion.md` (phase 25), which itself superseded `background-animation.md` (phase 08))
|
||||
**Context:** `frontend/assets/styles.css` (the background block, ~lines 70–155: the STATIC grid texture on `body::before`, the three opacity-fading glow spots on `body::after` / `html::before` / `html::after`, the `@keyframes bg-glow-a/b/c` blocks, and the `prefers-reduced-motion` rule that stills those layers), `tests/unit/test_background_animation.py` + `tests/unit/test_background_no_motion.py` (source-level pins of the glow contract), `tests/e2e/test_background_no_motion.py` (the phase-25 suite — computed-style fade assertions) + `tests/e2e/test_background_animation.py` (the phase-22 E2E repurposed as the phase-25 no-motion regression — same story, `background-no-motion.md` — its fade assertions directly contradicted by the new contract), `tests/e2e/test_dark_tech_theme.py` (palette assertions — must stay green UNCHANGED), `frontend/assets/themes/` (configurable theme files — the built-in palette is the scope; verify they carry no glow rules).
|
||||
|
||||
## Objective
|
||||
Delete the animated background: the three opacity-fading glow pseudo-layers and their keyframes stop existing — the owner reports they are too resource-intensive (the infinite CSS animations run continuously on every page, in every tab). The background becomes fully static: the 44px grid texture stays (it is static — zero animation cost).
|
||||
|
||||
## Owner decisions (chat, 2026-09-06 — recorded per AGENTS.md rule 3)
|
||||
- **A2 confirmed:** "the animated css background" = the three fading glow spots (the ONLY animated part of the background). The static grid (`body::before`) STAYS. If the owner later wants the grid gone too, that is a one-line follow-up — out of scope here.
|
||||
- Other UI animations (the mobile nav slide, modal fades, typing dots, …) are NOT the background — they stay untouched.
|
||||
|
||||
## Design (shared by both tasks)
|
||||
- `body::before` (the grid) stays BYTE-IDENTICAL: 44px cells, 1px lines at 60% `--line` alpha, the widened radial mask.
|
||||
- Deleted from `styles.css`: the `body::after`, `html::before`, and `html::after` glow rules; the three `@keyframes bg-glow-*` blocks; the `prefers-reduced-motion` rule whose only job was stilling these background layers — FIRST verify it references nothing else (if any non-background selector sits in it, strip only the background selectors).
|
||||
- The block's comment header (the phase-08/25 owner-direction prose) is replaced with a 3–4 line phase-78 note: the animated layers were removed at owner direction (TODO.md L4) as too resource-intensive; the static grid remains.
|
||||
- **Test rewrites (the house pattern — the premise changed, so the suites pin the NEW contract; the phase-76 precedent rewrote the phase-20 suite the same way):**
|
||||
- `tests/unit/test_background_no_motion.py` → rewritten as the static-contract source pin: no `@keyframes bg-glow-*` in `styles.css`; none of the four pseudo-element selectors (`body::before/after`, `html::before/after`) carries an `animation:` declaration; `body::before` (the grid) is present with its 44px `background-size` and no animation.
|
||||
- `tests/unit/test_background_animation.py` → **DELETED** (the phase-25 unit source-pin suite — its entire premise, three fading glows / exactly three `bg-glow-*` keyframe blocks, is gone; superseded chain 08 → 25 → 78).
|
||||
- `tests/e2e/test_background_no_motion.py` → rewritten: Playwright computed-style checks — the three glow pseudo-elements report `animation-name: none` and no background-image; `body::before` still carries the grid background. Docstring updated to the phase-78 contract.
|
||||
- `tests/e2e/test_background_animation.py` → **DELETED** (the phase-22 E2E repurposed as the phase-25 regression — its premise, that the glow layers fade, is removed; its story (`background-no-motion.md`) is carried on by the rewritten `test_background_no_motion.py`, the single story suite going forward).
|
||||
- `tests/e2e/test_dark_tech_theme.py` must stay green UNCHANGED — the PALETTE is untouched; only the light spots go.
|
||||
- `tests/unit/test_themes.py` + `frontend/assets/themes/indigo.css` — verify no glow/keyframe carry-over; if a theme file re-introduces animated background layers, the owner's direction applies to the whole background (report before deviating — the built-in palette is the confirmed scope).
|
||||
|
||||
## Dependencies
|
||||
— (none)
|
||||
|
||||
## Tasks
|
||||
1. `01_remove_glow_layers.md` — the CSS deletion + the four test-file rewrites/deletions.
|
||||
2. `02_regression_sweep_commit.md` — theme + smoke E2E, the visual check, the full gate, the atomic commit.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the rewritten source-pin file + `tests/unit/test_themes.py` green.
|
||||
- E2E: the rewritten static-background suite in isolation (DB up) + `test_dark_tech_theme.py` + `test_smoke.py` unchanged-green.
|
||||
- Coverage: **>90%** on `app/` (CSS-only phase — the floor is preserved).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] No `animation` / `@keyframes` remains for the background in `styles.css` (`rg "bg-glow" frontend/ tests/ app/` → zero hits); the grid renders as before (visual check in a real browser).
|
||||
- [ ] The rewritten suites are green; the deleted suites are gone; `test_dark_tech_theme.py` green UNCHANGED.
|
||||
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] One atomic `--no-gpg-sign` Conventional-Commits commit (e.g. `perf(ui): remove the animated background glow layers — static grid only`); phase dir moved to `.agents/phases/complete/`.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Task 01 — Delete the glow layers + keyframes; re-pin the background contract
|
||||
|
||||
**Phase:** `78_static_background` · **Source:** `TODO.md:4` — "Remove the animated css background, it's too resource intensive"
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
The animated background no longer exists: the three glow pseudo-layers, their keyframes, and their reduced-motion rule are deleted from `styles.css`, and the unit/e2e background suites are rewritten to pin the new static contract (or deleted where the premise is gone).
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/styles.css` — delete the three glow rules (`body::after`, `html::before`, `html::after`), the `@keyframes bg-glow-a` / `bg-glow-b` / `bg-glow-c` blocks, and the `prefers-reduced-motion` block that stills the background — the file carries EIGHT `@media (prefers-reduced-motion: reduce)` blocks; delete the ONE that contains only `body::before, body::after, html::before, html::after { animation: none; }` (~line 1353) whole, and leave the other seven (typing dots, spinner, toasts, nav slide, etc. — unrelated UI) untouched. Keep the `body::before` grid rule byte-identical (44px cells, the 60% `--line` alpha gradients, the radial mask). Replace the large phase-08/25 comment header with a 3–4 line phase-78 note: owner direction (TODO.md L4) — the animated background was removed as too resource-intensive; the static grid remains.
|
||||
2. `tests/unit/test_background_no_motion.py` — rewrite to the static contract (source-level, house pattern): `styles.css` contains no `@keyframes bg-glow-*`; none of `body::before` / `body::after` / `html::before` / `html::after` carries an `animation:` declaration (assert the three glow selectors are ABSENT or animation-free — they will be absent); `body::before` is present with `background-size: 44px 44px` and no animation. Update the docstring to the phase-78 contract.
|
||||
3. DELETE `tests/unit/test_background_animation.py` (the phase-25 unit source-pin suite — its premise, exactly three fading glows with named keyframe cycles, is removed; superseded chain 08 → 25 → 78).
|
||||
4. `tests/e2e/test_background_no_motion.py` — rewrite: Playwright computed-style checks on a loaded page — `body::before` still carries the grid (background-image non-empty, animation-name `none`); `body::after`, `html::before`, `html::after` report no background-image and `animation-name: none` (or the pseudo-element has no box). Update the docstring.
|
||||
5. DELETE `tests/e2e/test_background_animation.py` (the phase-22 E2E repurposed as the phase-25 no-motion regression — its fade assertions contradict the new contract; the story `background-no-motion.md` is carried on by the rewritten `test_background_no_motion.py`).
|
||||
6. Verify nothing else references the removed names: `rg "bg-glow" app/ frontend/ tests/` → zero hits; `tests/unit/test_themes.py` and `frontend/assets/themes/*.css` do not re-introduce glow/animated-background rules (grep for `bg-glow` / `@keyframes` on background selectors — report before deviating if a theme file carries them).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the rewritten pin file (item 2) + `tests/unit/test_themes.py` green.
|
||||
- E2E: the rewritten suite (item 4) in isolation (DB up): `uv run pytest tests/e2e/test_background_no_motion.py -v --no-cov`.
|
||||
- Coverage: n/a (CSS only) — the `app/` floor preserved.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `rg "bg-glow" frontend/ tests/ app/` → no hits; the three glow rules + keyframes are gone from `styles.css`; the grid rule is untouched.
|
||||
- [ ] Rewritten unit + e2e suites green; the two animation suites deleted; `tests/e2e/test_dark_tech_theme.py` green UNCHANGED.
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Task 02 — Regression sweep + the commit
|
||||
|
||||
**Phase:** `78_static_background` · **Source:** `TODO.md:4` — "Remove the animated css background, it's too resource intensive"
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
Prove the deletion regressed nothing (palette, theme, smoke, full pipeline) and land the atomic commit.
|
||||
|
||||
## Work
|
||||
1. E2E (DB up, each in isolation): `uv run pytest tests/e2e/test_background_no_motion.py -v --no-cov`, then `uv run pytest tests/e2e/test_dark_tech_theme.py -v --no-cov` (UNCHANGED file — if it breaks, the deletion touched the palette; fix the deletion, not the test), then `uv run pytest tests/e2e/test_smoke.py -v --no-cov`.
|
||||
2. Visual check in a real browser (real server, not the mock): the page background is the static grid over the flat `--bg` canvas — no pulsing light spots at the top-left / bottom-right / bottom-left corners; a `prefers-reduced-motion` browser profile sees the same static page; the theme CSS (indigo) still applies cleanly if set.
|
||||
3. Full gate: `uv run pytest --cov=app --cov-report=term-missing` (>90% on `app/`), `uv run ruff check . && uv run pyright`.
|
||||
4. ONE atomic `--no-gpg-sign` Conventional-Commits commit — e.g. `perf(ui): remove the animated background glow layers — static grid only` — body cites TODO.md L4 + the resource-intensity reason + the confirmed scope (glow spots gone, static grid stays). Move the phase dir to `.agents/phases/complete/`.
|
||||
|
||||
## Testing & Quality
|
||||
- The full suite IS the test; coverage **>90%** on `app/`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] Items 1–3 all green.
|
||||
- [ ] Committed; phase dir in `.agents/phases/complete/`.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Phase 79 — API tokens: admin-issued access to the app (only shared chats stay open)
|
||||
|
||||
**Source:** `TODO.md` L5 — "Add api tokens that the admin can generate and hand out so people can log in to use the app. The only thing that should be accessible without an API token is shared chats. The web ui should ask for a token before letting a user through and should cache that token in browser storage so they don't have to keep entering it."
|
||||
**Story:** n/a (TODO-derived — extends the phase-16 single-admin auth, `.agents/user_stories/admin-auth.md`)
|
||||
**Context:** `app/core/auth.py` (the SessionMiddleware cookie session, `require_admin`, `sign_in`/`sign_out`, `ADMIN_SESSION_KEY`), `app/api/auth.py` (`/api/login`, `/api/logout`, `/api/whoami` — `WhoamiResponse{authenticated, role: "admin"|"anonymous"}`), `app/models.py` (SQLAlchemy 2.0 mapped-column models — `SavedChat` is the last one) + `alembic/versions/` (latest is `0011_doc_drafts.py` — the format to mirror), `app/api/chat.py` (`POST /api/chat` — public today), `app/api/suggestions.py` (public today), `app/api/docs.py` (`GET /api/documents/content` — the phase-16 soft rule: deliberately public), `app/schemas.py` (`LoginRequest`, `WhoamiResponse`, …), `frontend/assets/header.js` (the single `/api/whoami` call site — `fetchIsAdmin()` returns `authenticated === true`; the admin-link reveal; the sign-out binding), `frontend/index.html` (the shell — `#app-nav`, the `#view-*` sections, the sign-in/out links, `#main`), `frontend/document.html` + `frontend/assets/document.js` (the document viewer — `fetchIsAdmin` gates the admin-only edit affordance; the viewer itself is public today), `tests/e2e/auth_helpers.py` (the real-form `login` helper), `tests/integration/test_auth_api.py` (pins the phase-16 contract — "viewer stays public (soft rule) and `POST /api/chat` still streams" — that soft rule is SUPERSEDED by this phase).
|
||||
|
||||
## Objective
|
||||
The admin can generate named API tokens and hand them out; a token holder signs in at the in-app gate and uses the app — chat, suggestion chips, cited documents. The ONLY anonymous content is the shared chats (plus the login/infra endpoints the gate itself needs). Every existing admin-only surface stays admin-only.
|
||||
|
||||
## Owner decisions (chat, 2026-09-06 — recorded per AGENTS.md rule 3)
|
||||
- **A3 confirmed — token-user scope:** a token user (role `user`) may: `POST /api/chat`, `GET /api/suggestions`, `GET /api/documents/content`, `GET /api/whoami`, `POST /api/logout`. Admin-only UNCHANGED: the docs list/import/sync, tuning, git sources, doc drafts, and the saved-chats list/save/share/delete (saved chats have no per-user attribution — token users get NO History view; only the admin sees saved chats).
|
||||
- **A4 confirmed — token shape & lifecycle:** `bor_` + 32 hex chars (`secrets.token_hex(16)`); only the SHA-256 hex digest of the FULL token string is stored (unique index) — the plaintext is returned EXACTLY ONCE at creation. `revoked_at` set = dead, and revocation is enforced IMMEDIATELY on the user's next request (the session stores the token id; `require_user` live-checks the row is unrevoked — no server-side session store is added, just a PK lookup).
|
||||
- **A5 confirmed — the gate:** an in-app token-entry overlay on the shell + the same inline gate on `document.html`; `login.html` (admin password) and `shared.html` (anonymous) are UNCHANGED. The entered token is cached in `localStorage["bor.token"]` and silently re-sent to `POST /api/token-auth` on every page load (a failed silent re-auth — revoked token — drops the key and shows the gate). Sign out clears the key. `/api/config` stays public (the gate UI itself needs the branding).
|
||||
- **Auth error semantics:** an unauthenticated (or revoked) caller to a `require_user` endpoint gets 401 `{"detail": "authentication required"}` — 401, not 403 (there is no higher privilege that would unblock them); `require_admin` keeps its 403 `admin only`. `POST /api/token-auth` failures (malformed / unknown / revoked) all get ONE generic 401 `{"detail": "invalid token"}` (no enumeration — the phase-16 pattern).
|
||||
- **`whoami` shape:** `WhoamiResponse{authenticated: bool, role: "admin"|"user"|"anonymous"}` — `authenticated` is true for admin AND user; ALL UI gating switches from `authenticated` to `role === "admin"` (the frontend change is owned by task 05). An admin-signed-in session keeps working exactly as today (a browser that holds BOTH an admin and a user session reports admin; `sign_out` clears everything — one session dict, one logout).
|
||||
|
||||
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||
- **Model — `api_tokens` (migration `0012_api_tokens.py`):** `id` UUID PK (uuid4 default); `label` String(120) NOT NULL (the hand-out name, e.g. "alice" — display-only, no index, not unique); `token_hash` String(64) NOT NULL UNIQUE (the sha256 hex digest of the full `bor_…` string — the `documents.content_hash` String(64) precedent); `created_at` TIMESTAMPTZ NOT NULL server-default now; `last_used_at` TIMESTAMPTZ NULL; `revoked_at` TIMESTAMPTZ NULL.
|
||||
- **Service — `app/core/tokens.py` (new):** `generate_token() -> str` (`"bor_" + secrets.token_hex(16)`); `hash_token(token) -> str` (sha256 hexdigest of the FULL token — hashing the full string, not the suffix, so a stripped prefix can never collide); `create_token(db, label) -> tuple[ApiToken, str]` (returns the row + the plaintext exactly once — the row only ever carries the hash); `find_active_by_token(db, token) -> ApiToken | None` (hash → `token_hash ==` lookup → `revoked_at IS NULL`); `mark_used(tok)` (bump `last_used_at` to now — the caller commits); `revoke(db, token_id) -> bool` (set `revoked_at` when not already — False when the row is missing). Module docstring: the lookup is by HASH (a unique-index hit) — sha256's pre-image resistance means there is no token-enumeration or timing surface beyond the DB lookup (the contrast with `check_password`'s constant-time compare is documented, not replicated — there is nothing to compare in constant time here, only to look up).
|
||||
- **Admin API — `app/api/tokens.py` (new router, `tags=["tokens"]`, router-level `dependencies=[Depends(require_admin)]` — the `doc_drafts.py` pattern):** `POST /tokens` body `TokenCreateRequest{label}` → 201 `TokenCreated{id, label, token, created_at}` — the ONLY response that ever carries the plaintext; `GET /tokens` → `TokenList{tokens: [TokenListItem{id, label, created_at, last_used_at, revoked: bool}]}` newest-first (no hashes, no plaintext); `POST /tokens/{id}/revoke` → 204, idempotent (already-revoked → still 204; unknown id → 404 `token not found`). Registered in `app/main.py` with the other API routers (before the static mount).
|
||||
- **Auth API — `app/api/auth.py`:** new `POST /token-auth` (PUBLIC — it is the login): body `TokenAuthRequest{token}` → `find_active_by_token` → miss → 401 `invalid token`; hit → `mark_used` + commit + `session[USER_SESSION_KEY] = True` + `session[USER_TOKEN_ID_KEY] = str(token.id)` → 204. `whoami` reports the three roles. `logout` is unchanged (its `session.clear()` already wipes both roles).
|
||||
- **`require_user` (in `app/core/auth.py`):** `def require_user(request: Request, db: Session = Depends(get_db))` — admin key set → pass; `user` key set → fetch the `ApiToken` row by `user_token_id` (PK hit) — row missing OR `revoked_at` set → pop BOTH user keys from the session + raise 401 `authentication required`; else pass; neither key → 401 same detail. Applied to exactly three endpoints: `POST /api/chat` (`app/api/chat.py`), `GET /api/suggestions` (`app/api/suggestions.py`), `GET /api/documents/content` (`app/api/docs.py` — update its docstring: the phase-16 "deliberately PUBLIC soft rule" is SUPERSEDED — the shared chats page is now the anonymous surface). Everything else: unchanged.
|
||||
- **Public list (the only anonymous access — the owner's sentence):** `/api/health`, `/api/config`, `/api/whoami`, `/api/login`, `/api/token-auth`, `/api/shared/<token>` (JSON snapshot) + the `/shared/<token>` page + `shared.html`, the static assets, and the page documents themselves (`login.html`, `document.html`, the shell — the documents load; their GATED DATA does not: the shell shows the gate, `document.html` shows its inline gate).
|
||||
- **Frontend gate (task 05):** new `frontend/assets/token-gate.js` (module) exposing `mountGate(lockRoot, onAuthed)`: at call — (1) if `localStorage["bor.token"]` exists → `POST /api/token-auth` with it (silent; on failure remove the key — it may have been revoked — and fall through); (2) `fetchWhoami()` → `user` or `admin` → `onAuthed()` (the gate never shows); `anonymous` → show the gate AND `lockRoot.inert = true` (the shell passes `#main`; `document.html` passes its content wrapper) + focus the token input. Submit → token-auth → 204 → `localStorage.setItem("bor.token", …)` → whoami → user → hide the gate (`hidden` + `inert` on the gate — the ship-hidden pattern), `lockRoot.inert = false`, `onAuthed()`. 401 → `#auth-gate-error` (`role="alert"`) visible, input cleared + re-focused. All `localStorage` access in try/catch (private mode → the gate still works, caching is a no-op — the fail-silence storage contract). `header.js`: the single whoami now caches the FULL `{authenticated, role}` in one module promise (`fetchWhoami()`); `fetchIsAdmin()` becomes `fetchWhoami().then(w => w.role === "admin")` — SAME single request, all existing callers keep working; `initSharedHeader()` switches its admin variable to `role === "admin"` (byte-identical behavior for admin/anonymous; a `user` gets: sign-in hidden, sign-out visible, all admin nav links hidden, steering panel removed — the anonymous branch); the sign-out binding gains `localStorage.removeItem("bor.token")` (try/catch, before the reload).
|
||||
- **Gate markup (shell — `index.html`):** body-level `<section class="auth-gate" id="auth-gate" hidden inert aria-labelledby="auth-gate-title">` AFTER `#main` (a `position: fixed; inset: 0` overlay — the body-level doc-modal precedent): the `#sources-gate` visual language (glyph, h2 `#auth-gate-title` "Enter your access token", sub-text pointing at the admin, a `<form id="auth-gate-form">` with a visually-hidden label + `<input id="auth-gate-input" type="text" autocomplete="off" autocapitalize="none" spellcheck="false" required>` (mono), a [Sign in] submit, `#auth-gate-error` (`role="alert"`, hidden), and a "Sign in as admin" link to `/login.html?next=/` (the header's `?next=` convention). `document.html` carries the same markup as `#doc-auth-gate` (task 05).
|
||||
- **Tokens admin view (task 06):** a sixth navbar view folded per the phase-76 pattern — `#nav-tokens` (ships hidden; `header.js` reveals it for role admin, same contract as the other four links), `#view-tokens` section in `index.html`, new `frontend/assets/tokens.js` (`export async function mount(root)`, admin-gated via `fetchIsAdmin()` like `history.js`), `router.js` entries in `VIEW` / `VIEW_PATH` / `VIEW_MODULES` / `TITLES` / `DESCRIPTIONS` (the brand-composition `replaceAll` contract carries over), `"/tokens.html"` in BOTH `app/main.py`'s `_shell_routes` tuple and `app/core/caching.py`'s `HTML_PAGES` (the no-cache + `?v=` contract — the phase-76 task-03 integration-test updates apply: the shell-route / title-table / `_page_file` override assertions gain the path). UI: a create row (label input + [Generate]) → the plaintext appears ONCE in a mono read-only field + [Copy] (the clipboard + inline-fallback house pattern — `tokens.js` keeps its own ~10-line copy, the per-page duplication house style); the once-block is NOT re-shown on a re-render/re-show (the plaintext is gone); a full-width table Label | Created | Last used | Status (Active em-dash vs rose Revoked pill — the stale-pill visual language) | Actions (Revoke — the inline two-step confirm, the `history-confirm-*` pattern, focus to Yes); a `role="status"` live region.
|
||||
- **E2E migration (task 04):** ten chat suites POST to `/api/chat` anonymously today and must sign in first (`auth_helpers.login(page, app_url, next="/")`): `test_agent_document_tools.py`, `test_agent_unlimited_tools.py`, `test_chat_rag.py`, `test_grep_regex_teaching.py`, `test_harness_aligned_tools.py`, `test_honest_deflection.py`, `test_llm_retry.py`, `test_search_tool.py`, `test_tool_path_teaching.py`, `test_tool_scaffolding_guardrails.py`. `auth_helpers.py` gains `login_with_token(page, app_url, token)` — it drives the REAL gate (fill `#auth-gate-input` → submit → wait for the gate to hide); `tests/e2e/test_admin_auth.py`'s anonymous pins that the app is open (chat streams, viewer public) are updated to the 401/gate contract (its password-flow assertions stay). The shared-chat suites stay ANONYMOUS — that is the point of the item.
|
||||
- **Integration test updates (task 03):** the tests that hit the three gated endpoints anonymously (`tests/integration/test_api.py`, `test_chat_api.py`, `test_auth_api.py`, …) sign in as admin first or assert the new 401 where the test's purpose IS the auth contract.
|
||||
|
||||
## Dependencies
|
||||
— (none; extends the completed phase-16 auth; phase 80 builds on this phase's `/api/suggestions` gating)
|
||||
|
||||
## Tasks
|
||||
1. `01_token_model_migration.md` — the `api_tokens` model + migration `0012_api_tokens.py`.
|
||||
2. `02_token_admin_api.md` — the token service + the admin create/list/revoke endpoints.
|
||||
3. `03_token_auth_enforcement.md` — `POST /api/token-auth`, the three-role whoami, `require_user` (live revoke check), enforcement on chat/suggestions/document-content, the integration-contract updates.
|
||||
4. `04_migrate_anonymous_e2e.md` — the `login_with_token` helper + the ten anonymous chat suites sign in; the E2E inventory is green against the gated app.
|
||||
5. `05_frontend_token_gate.md` — the header role plumbing + the shell gate + the localStorage caching + the `document.html` gate.
|
||||
6. `06_tokens_admin_view.md` — the admin Tokens view (phase-76 fold pattern) with generate / list / revoke.
|
||||
7. `07_e2e_story_suite.md` — `tests/e2e/test_api_tokens.py` — the owner's sentence, pinned in a browser.
|
||||
8. `08_regression_sweep_commit.md` — the full pipeline + the README auth section + the atomic commit.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: `tests/unit/test_tokens.py` (the service — shape, hash, create/find round-trip, revocation, last-used, the malformed/unknown/revoked miss paths) + `tests/unit/test_auth.py` extended (the `require_user` matrix: admin pass, active user pass, revoked user 401 + session keys popped, missing row 401, anonymous 401).
|
||||
- Integration: the admin API (201 plaintext-once, list shape without hashes, revoke idempotency, 403 anonymous, 403 token-user); token-auth (valid / invalid / revoked / malformed); the enforcement matrix on the three endpoints; whoami's three roles; logout clearing the token session; the existing anonymous-chat pins updated to the 401 contract.
|
||||
- E2E: the new story suite (task 07) + the migrated suites (task 04) + `test_admin_auth.py` updated + the shared-chat suites green ANONYMOUS.
|
||||
- Coverage: **>90%** on `app/` (the delta: `app/core/tokens.py`, `app/api/tokens.py`, and the modified auth/chat/suggestions/docs files — every new branch tested).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_api_tokens.py -v --no-cov` green in isolation.
|
||||
- [ ] A token user (fresh browser context) can chat end-to-end (mock LLM) and open a cited document; an anonymous caller gets the gate in the UI and 401s on the API; shared chats open anonymously; every admin surface 403s the token user.
|
||||
- [ ] The cached token survives a reload with no re-entry; sign out clears it; a revoked token is refused on the next request AND on a fresh login attempt.
|
||||
- [ ] Full suite green, coverage >90%, `uv run ruff check . && uv run pyright` clean; one atomic `--no-gpg-sign` commit; phase dir moved to `.agents/phases/complete/`.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Task 01 — The `api_tokens` model + migration 0012
|
||||
|
||||
**Phase:** `79_api_tokens` · **Source:** `TODO.md:5` — "Add api tokens that the admin can generate and hand out so people can log in to use the app."
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
The storage for admin-issued access tokens: an `api_tokens` table (hashed token, label, lifecycle timestamps) behind alembic migration `0012_api_tokens.py`.
|
||||
|
||||
## Work
|
||||
1. `app/models.py` — `class ApiToken(Base)` after `SavedChat` (the house mapped-column style): `id` UUID PK default uuid4; `label` String(120) NOT NULL (display-only — the hand-out name; no index, not unique); `token_hash` String(64) NOT NULL with `unique=True, index=True` (the sha256 hex digest of the full token — the `documents.content_hash` String(64) precedent); `created_at` DateTime(timezone=True) server-default `func.now()`; `last_used_at` DateTime(timezone=True) NULL; `revoked_at` DateTime(timezone=True) NULL. Docstring: the trust model — the plaintext exists only in the 201 create response; the hash is the stored credential (the `saved_chats.share_token` / `doc_drafts.token` lineage, but HASHED because these are long-lived hand-out credentials, unlike the unguessable uuid4 link tokens).
|
||||
2. `alembic/versions/0012_api_tokens.py` — `revision = "0012"`, `down_revision = "0011"`; the module docstring mirrors the `0011_doc_drafts.py` format (phase citation, per-column rationale, the hashed-credential decision); upgrade: `op.create_table("api_tokens", …)` mirroring the model, the `token_hash` unique index (match how `0009_saved_chat_share_token.py` created its unique index — check whether it used the column's `unique=True` or an explicit `op.create_index`, and follow the same shape); downgrade: `op.drop_table("api_tokens")`.
|
||||
3. `tests/unit/test_api_tokens_model.py` (new) — follow the existing model-test precedent in `tests/unit/` (find how other models are unit-tested — schema-level assertions vs a test-DB flush): the table name, the column set + nullability, `token_hash` uniqueness (two tokens with the same hash collide), `label` not unique.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: item 3.
|
||||
- Migration: `uv run alembic upgrade head` applies 0012 cleanly on the dev DB (Postgres up via `podman compose up -d db`) and `uv run alembic downgrade -1 && uv run alembic upgrade head` round-trips; the integration-suite schema bootstrap (however the existing integration tests create the schema — verify in `tests/conftest.py` — `create_all` or migrations) picks up the new table for tasks 02/03.
|
||||
- Coverage: **>90%** on the new model code (import-level; the behavior lands with the service in task 02).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run alembic upgrade head` / `downgrade -1` / `upgrade head` round-trips cleanly.
|
||||
- [ ] Unit tests green; `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Task 02 — The token service + the admin create/list/revoke API
|
||||
|
||||
**Phase:** `79_api_tokens` · **Source:** `TODO.md:5` — "…api tokens that the admin can generate and hand out…"
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
The admin surface: generate a named token (the plaintext shown exactly once), list tokens (no secrets), revoke one. All behind `require_admin`.
|
||||
|
||||
## Work
|
||||
1. `app/core/tokens.py` (new) — per the phase design: `generate_token() -> str` (`"bor_" + secrets.token_hex(16)`); `hash_token(token: str) -> str` (`hashlib.sha256(token.encode("utf-8")).hexdigest()` — the FULL token string); `create_token(db, label: str) -> tuple[ApiToken, str]` (strip the label; the API layer guarantees non-empty — the service trusts it; returns the row + plaintext exactly once); `find_active_by_token(db, token: str) -> ApiToken | None` (hash → `token_hash ==` → `revoked_at IS None` — ANY other shape is a miss: the hash of a malformed string simply matches no row); `mark_used(tok: ApiToken) -> None` (bump `last_used_at` to `datetime.now(timezone.utc)` — the caller commits); `revoke(db, token_id: uuid.UUID) -> bool` (set `revoked_at` when not already; False when the row is missing). Module docstring: the lookup is by HASH (a unique-index hit) — sha256 pre-image resistance means no token-enumeration surface beyond the DB lookup (document the contrast with `check_password`'s constant-time compare — there is nothing to compare in constant time here, only to look up).
|
||||
2. `app/schemas.py` — `TokenCreateRequest{label: str}` (validator: 1–120 chars after strip — fail loud, the house `ValueError` pattern); `TokenCreated{id, label, token: str, created_at}` (the ONLY schema that carries `token` — the plaintext, once); `TokenListItem{id, label, created_at, last_used_at: datetime | None, revoked: bool}`; `TokenList{tokens: list[TokenListItem]}`; `TokenAuthRequest{token: str}` (non-empty after strip — the 401-vs-422 choice: an empty/whitespace token is a MALFORMED login attempt → 401 `invalid token` from the endpoint, NOT a 422 — so NO min-length validator here; the endpoint checks `token.strip()` and 401s).
|
||||
3. `app/api/tokens.py` (new router, `tags=["tokens"]`, router-level `dependencies=[Depends(require_admin)]` — the `doc_drafts.py` pattern):
|
||||
- `POST /tokens` → 201 `TokenCreated` — `create_token` + commit, then respond (the plaintext in this response is the one and only moment);
|
||||
- `GET /tokens` → `TokenList` — newest-first (`created_at` desc, `id` desc tiebreak); `revoked` derived from `revoked_at is not None`; NO `token` or `token_hash` field ever appears;
|
||||
- `POST /tokens/{id}/revoke` → 204 — idempotent (already-revoked → still 204, no re-stamp); unknown id → 404 `token not found`.
|
||||
4. `app/main.py` — register: `app.include_router(tokens_router, prefix="/api")` with the other API routers (before the static mount, the existing comment's "API routes first" contract).
|
||||
5. `tests/unit/test_tokens.py` (new) — `generate_token` shape (`^bor_[0-9a-f]{32}$`, two calls differ); `hash_token` determinism + 64-hex length + full-string semantics (hashing `bor_X` ≠ hashing `X`); `create_token`/`find_active_by_token` round-trip (active hit); `find_active_by_token` returns None for: revoked token, unknown well-formed token, empty string, short string, wrong prefix (these are all "hash matches no row" — the generic-miss contract); `revoke` sets the stamp once (second call idempotent, returns False only for a missing id); `mark_used` stamps `last_used_at`.
|
||||
6. `tests/integration/test_tokens_api.py` (new, the house TestClient + admin-login pattern from `tests/integration/test_auth_api.py`) — anonymous: 403 on all three endpoints; admin (signed in via `POST /api/login`): create → 201, body's `token` matches `^bor_[0-9a-f]{32}$`, and `GET /tokens` NEVER exposes it (no `token` key in items, no `token_hash`, the hash string itself absent from the serialized body); two tokens with the same label → both created (labels are not unique); create with blank label → 422; revoke → 204, the list item shows `revoked: true` + the row keeps its `last_used_at`; re-revoke → 204; revoke unknown id → 404; list is newest-first.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: item 5. Integration: item 6.
|
||||
- Coverage: **>90%** on `app/core/tokens.py` + `app/api/tokens.py` (every branch — the miss paths, the idempotency, the 404).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] The admin can create / list / revoke tokens through the API; the plaintext appears exactly once (in the 201 body) and never in the list.
|
||||
- [ ] Anonymous is 403 on all three (router-level dependency — a token user, once task 03 lands, will be 403 too; pin that in task 03's matrix).
|
||||
- [ ] Unit + integration green; `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Task 03 — Token login, the `user` role, and the auth gate on the app API
|
||||
|
||||
**Phase:** `79_api_tokens` · **Source:** `TODO.md:5` — "…so people can log in to use the app. The only thing that should be accessible without an API token is shared chats."
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
A token becomes a session: `POST /api/token-auth` signs a token holder in (role `user`), `whoami` reports the three roles, and `require_user` (admin OR live token) guards the app surface — chat, suggestions, document content. Shared chats + login infra stay public; every admin surface stays admin-only.
|
||||
|
||||
## Work
|
||||
1. `app/core/auth.py` — `USER_SESSION_KEY = "user"`, `USER_TOKEN_ID_KEY = "user_token_id"`; `def require_user(request: Request, db: Session = Depends(get_db)) -> None` (import `get_db` from `app.db`, the house dependency pattern): admin key set → return (an admin always passes, token state irrelevant); `user` key set → `select(ApiToken).where(ApiToken.id == uuid.UUID(session[USER_TOKEN_ID_KEY]))` — row missing OR `revoked_at is not None` → `request.session.pop(USER_SESSION_KEY, None)` + `request.session.pop(USER_TOKEN_ID_KEY, None)` (the dead session is dropped NOW — the next `whoami` is anonymous) + raise `HTTPException(401, detail="authentication required")`; else return; neither key → 401 same detail. `sign_in` (admin) is UNCHANGED — coexistence is deliberate: an admin key does not erase the user keys; `whoami` reports admin whenever the admin key is set; `sign_out`'s `session.clear()` already wipes both roles.
|
||||
2. `app/api/auth.py` — `POST /token-auth` (PUBLIC — it is the login route): `TokenAuthRequest` body → `find_active_by_token(get_db_session, payload.token)` (obtain the DB session via the house `get_db` dependency) → miss (or `payload.token` empty/whitespace) → ONE generic 401 `{"detail": "invalid token"}` (malformed / unknown / revoked are indistinguishable — the phase-16 no-enumeration pattern) → hit: `mark_used` + commit + `request.session[USER_SESSION_KEY] = True` + `request.session[USER_TOKEN_ID_KEY] = str(row.id)` → 204 (the signed cookie is emitted by the SessionMiddleware on the session write — same mechanism as `/api/login`). `whoami`: `role` = `"admin"` if the admin key is set, else `"user"` if the user key is set, else `"anonymous"`; `authenticated = role != "anonymous"`. `WhoamiResponse.role` is a plain `str` with a `# "admin" | "anonymous"` comment (`app/schemas.py` ~line 92) — update the comment to `# "admin" | "user" | "anonymous"` (no type change needed). `logout` unchanged.
|
||||
3. Enforcement — add the `_user: None = Depends(require_user)` parameter (the `_admin` naming precedent in `app/api/docs.py`) to exactly three endpoints:
|
||||
- `app/api/chat.py` — `POST /chat`;
|
||||
- `app/api/suggestions.py` — `GET /suggestions` (the endpoint gains the `db` dependency — needed by the dependency's signature; `get_db` is already the house pattern);
|
||||
- `app/api/docs.py` — `GET /documents/content` — update the docstring: the phase-16 "Deliberately PUBLIC (soft rule)" note is SUPERSEDED by this phase — the shared chats page is the anonymous surface; the viewer content is token-or-admin.
|
||||
4. `tests/unit/test_auth.py` — extend with the `require_user` matrix (the house unit pattern for dependencies — check how `require_admin` is unit-tested today and match it): admin session passes; active-user session passes; revoked-user session → 401 AND both user keys popped from the session dict; user session pointing at a missing row → 401 + popped; anonymous → 401 `authentication required`; admin + user coexistence → passes as admin.
|
||||
5. `tests/integration/test_auth_api.py` — update the phase-16 pins to the new contract + add the token flows: `POST /token-auth` valid → 204 + `GET /api/whoami` → `{authenticated: true, role: "user"}`; invalid / revoked / malformed (short, wrong prefix, empty) → 401 `invalid token` (ALL the same body); whoami anonymous → `{authenticated: false, role: "anonymous"}`; whoami admin unchanged; logout after token-auth → whoami anonymous; REVOCATION MID-SESSION: token-auth → chat 200 → admin revokes via `POST /api/tokens/{id}/revoke` → next chat request 401 AND whoami is now anonymous (the live check cleared the keys). The old "chat still streams anonymously" pin becomes: anonymous `POST /api/chat` → 401 `authentication required`; admin `POST /api/chat` still streams (the existing streaming assertions survive under a signed-in client).
|
||||
6. The other integration tests that hit the three gated endpoints anonymously — find them precisely: `rg -n '"/api/chat"|"/api/suggestions"|"/api/documents/content"' tests/integration` — sign in as admin first (the house TestClient login helper from `test_auth_api.py`) or assert the new 401 where the test's purpose IS the auth contract. `tests/integration/test_api.py` (the `client.post("/api/chat", json={"message": ""})` 4xx-shape check at ~line 377 — verify which status it pins: an empty message was a 422 validation; with `require_user` the ANONYMOUS client now 401s BEFORE validation — update to sign in first so the validation assertion keeps testing validation).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: item 4. Integration: items 5–6.
|
||||
- Coverage: **>90%** on the modified `app/` files (`app/core/auth.py`, `app/api/auth.py`, and the three enforcement sites — every new branch exercised).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] The matrix holds: anonymous — chat 401, suggestions 401, document content 401, `/api/shared/<token>` 200, `/api/whoami` anonymous, `/api/config` 200, `/api/health` 200, `/api/login` + `/api/token-auth` reachable. Token user — chat 200 (stream), suggestions 200, document content 200, `/api/tokens` 403, `/api/docs` 403, `/api/chats` 403, `/api/steering` 403, `/api/git-sources` 403. Admin — everything as before.
|
||||
- [ ] Revocation is enforced on the next request (chat 401 + whoami drops to anonymous).
|
||||
- [ ] Full unit + integration suite green; `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Task 04 — The E2E suites meet the new auth contract
|
||||
|
||||
**Phase:** `79_api_tokens` · **Source:** `TODO.md:5` — "The only thing that should be accessible without an API token is shared chats."
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
No E2E suite may drive the gated app anonymously: the ten chat suites that POST to `/api/chat` without signing in sign in as admin first, `auth_helpers.py` gains the real token-gate helper (task 07 uses it), `test_admin_auth.py`'s anonymous pins are updated, and the full E2E inventory is green against the gated app.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/auth_helpers.py` — `login_with_token(page, app_url, token, next="/")`: `page.goto(f"{app_url}/")` → `expect(#auth-gate).to_be_visible()` (anonymous with no cached token — the test contexts are fresh, so no stored key) → `page.fill("#auth-gate-input", token)` → click the gate's submit → `expect(#auth-gate).to_be_hidden()` + the app is interactive (the composer reachable). Wrong-token contract (the `login` wrong-password mirror): a helper parameter or a second small function `login_with_token(page, app_url, token="bor_" + "0" * 32)` → `#auth-gate-error` (`role="alert"`) visible, the gate stays visible, `whoami` still anonymous (assert via the UI state — the gate is the proof).
|
||||
2. Sign the TEN suites in — each chat-driving test gets `login(page, app_url, next="/")` at the top (the suites already import or can import `e2e.auth_helpers.login`; touch only the anonymous flows, leave any admin-context flows as they are): `test_agent_document_tools.py`, `test_agent_unlimited_tools.py`, `test_chat_rag.py`, `test_grep_regex_teaching.py`, `test_harness_aligned_tools.py`, `test_honest_deflection.py`, `test_llm_retry.py`, `test_search_tool.py`, `test_tool_path_teaching.py`, `test_tool_scaffolding_guardrails.py`.
|
||||
3. `tests/e2e/test_admin_auth.py` — update the phase-16 anonymous pins that assert the app is open (anonymous chat streams; the document viewer opens anonymously) to the new contract (401 via the API / the gate visible in the UI); the password sign-in / sign-out / wrong-password assertions stay green UNCHANGED.
|
||||
4. Audit sweep + full inventory: scan every remaining e2e file for anonymous use of a now-gated endpoint (`rg -n 'goto\(f?"\{app_url\}/?"|/api/chat|/api/suggestions|/api/documents/content' tests/e2e/*.py`) — each hit either signs in or is a deliberate anonymous-surface test: the shared-chat suites (`test_share_chat.py` et al.) MUST stay anonymous (that is the point of the item — assert they still pass), the smoke suite's page-loads are fine (the PAGES load; the gate shows — update smoke's expectations only if it asserts on gated content). Run the FULL E2E inventory (DB up, mock LLM) and fix only auth-contract breakage — no semantic changes to story behavior.
|
||||
|
||||
## Testing & Quality
|
||||
- E2E: the full inventory green per AGENTS.md rule 9 — at minimum each of the ten migrated files, `test_admin_auth.py`, `test_share_chat.py` (anonymous), `test_smoke.py`, and `test_nav_switch_keeps_stream.py` in isolation.
|
||||
- Coverage: n/a (tests only) — the `app/` floor preserved.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] No suite drives `/api/chat`, `/api/suggestions`, or `/api/documents/content` anonymously.
|
||||
- [ ] The shared-chat suites pass as ANONYMOUS — the one open surface, the owner's sentence, pinned.
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Task 05 — The in-app token gate + the browser caching
|
||||
|
||||
**Phase:** `79_api_tokens` · **Source:** `TODO.md:5` — "The web ui should ask for a token before letting a user through and should cache that token in browser storage so they don't have to keep entering it."
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
An anonymous visitor meets a token-entry gate on the shell (and the document viewer); a correct token unlocks the app and is cached in `localStorage` so the next visit re-auths silently; sign out clears it. The header learns the three roles without a second whoami request.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/header.js` — the single whoami now caches the FULL response: a module-level `whoamiPromise` storing `{ authenticated, role }` (network failure / non-2xx → `{ authenticated: false, role: "anonymous" }` — the anonymous-safe contract, unchanged in spirit); `export function fetchWhoami()` returns that promise (the new canonical call); `export function fetchIsAdmin()` becomes `fetchWhoami().then(w => w.role === "admin")` — SAME single `/api/whoami` request (the string `/api/whoami` appears in this file exactly once), all existing callers keep working with zero changes. `initSharedHeader()` switches its local `admin` variable to `role === "admin"`: admin/anonymous behavior is byte-identical; a `user` gets the anonymous branch (sign-in hidden, sign-out visible, `#nav-sources` / `#nav-git-sources` / `#nav-tuning` / `#nav-history` hidden, the steering panel REMOVED — `/api/steering` is 403 for a user, so it must never be fetched). The sign-out binding gains `try { localStorage.removeItem("bor.token"); } catch {}` BEFORE the `window.location.reload()` (the fail-silence storage contract).
|
||||
2. `frontend/index.html` — a body-level gate AFTER `#main` (a `position: fixed; inset: 0` overlay — the body-level doc-modal precedent; the gate is the only interactive surface while visible):
|
||||
```html
|
||||
<section class="auth-gate" id="auth-gate" hidden inert aria-labelledby="auth-gate-title">…</section>
|
||||
```
|
||||
Content (the `#sources-gate` visual language — glyph, heading, sub, action): h2 `#auth-gate-title` "Enter your access token"; a sub line ("Ask the admin for a token — it opens chat, the answers, and the documents they cite. Shared chats stay open."); a `<form id="auth-gate-form">` with `<label class="visually-hidden" for="auth-gate-input">Access token</label>`, `<input id="auth-gate-input" name="token" type="text" autocomplete="off" autocapitalize="none" spellcheck="false" required>`, a submit button (the house button styling) labeled "Sign in"; `<p class="auth-gate-error" id="auth-gate-error" role="alert" hidden>`; and a "Sign in as admin" link to `/login.html?next=/` (the header's `?next=` convention — the static href is the no-JS fallback).
|
||||
3. `frontend/assets/token-gate.js` (new module) — `export function mountGate(lockRoot, onAuthed)` (reusable — the shell passes `#main`, `document.html` passes its content wrapper):
|
||||
- at call: (1) if `localStorage["bor.token"]` exists (try/catch) → `POST /api/token-auth` with it — SILENT; on any failure `localStorage.removeItem("bor.token")` (it may have been revoked) and fall through to the whoami check; (2) `fetchWhoami()` → role `user` or `admin` → `onAuthed()` (the gate NEVER shows — no flash for a cached valid token); role `anonymous` → show the gate (drop `hidden` AND `inert` on `#auth-gate`), `lockRoot.inert = true` (the locked app must not receive focus or keyboard traversal — WCAG, the inert-pair contract), focus `#auth-gate-input`;
|
||||
- form submit (preventDefault): `POST /api/token-auth` → 204 → `localStorage.setItem("bor.token", token)` (try/catch) → `fetchWhoami()` re-fetch (the promise cache must be invalidated for THIS re-fetch — either re-fetch directly or clear the module cache; document the choice) → role `user` → hide the gate (re-add `hidden` + `inert`), `lockRoot.inert = false`, `onAuthed()`; → 401 → `#auth-gate-error` visible with "That token isn't valid — check it with the admin.", input cleared + re-focused.
|
||||
- the gate ships `hidden` + `inert` (the phase-16 ship-hidden pattern — an authenticated boot never shows it for a frame).
|
||||
4. `frontend/index.html` — load `token-gate.js` (module, AFTER `app.js` and `router.js` — the boot-order comment updates) with its boot call: `mountGate(document.getElementById("main"), () => {})` — in the shell, `onAuthed` needs no view work: the lazy views mount on first show exactly as today (mount-once, hide-forever untouched), and the already-mounted views keep their state.
|
||||
5. `frontend/document.html` + `frontend/assets/document.js` — the content endpoint is now `require_user`-gated, so a direct anonymous URL shows the inline gate instead of a content error: add the same gate markup to `document.html` as `<section class="auth-gate" id="doc-auth-gate" hidden inert …>` (reusing the shell's copy, the id renamed), load `token-gate.js`, and wire `mountGate(document.getElementById("main"), onAuthed)` — document.html's content root is `<main id="main" class="app-main">` (the same id as the shell's — separate documents, so no collision) where `onAuthed` runs the EXISTING boot sequence (whoami → load content). An admin (or a validly cached token user) on `document.html` gets `onAuthed` immediately — the gate never shows. The admin-only edit affordance (`docAdminReady()` → `role === "admin"`) stays admin-only.
|
||||
6. `frontend/assets/styles.css` — `.auth-gate` (fixed overlay, `z-index` above the app content but below the doc-modal — check the existing z-index ladder; solid `--bg` + the grid is inherited from `html`, so the gate reads as the app's own surface; centered inner card on the `--surface` with the `sources-gate` spacing), the input (mono font, `--surface` background, visible focus ring — WCAG, 4.5:1 text), the error line (the rose/danger family used by `#history-status`-style alerts), the admin link (`.sources-gate-link` reuse), the button (the house submit-button language). Mobile: the overlay scrolls when the viewport is short.
|
||||
7. `tests/unit/test_token_gate.py` (new, source-level house pattern — read the JS sources, no browser): `token-gate.js` contains the `bor.token` localStorage key literal; the silent re-auth attempt happens BEFORE the whoami check (source ordering); a failed silent re-auth removes the key (the `removeItem` call sits in the failure path); `header.js` — `fetchWhoami` is exported, `fetchIsAdmin` delegates to it, and the string `fetch("/api/whoami")` appears in `header.js` exactly once (the single-request contract — the file's comments also mention whoami, so pin the fetch call, not the word); the sign-out binding removes `bor.token`; `index.html` loads `token-gate.js` after `router.js`.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: item 7.
|
||||
- E2E: the story suite (task 07) covers the gate flows end to end; a quick manual pass now (real server): anonymous → gate; wrong token → error; right token → unlock + reload with no gate; sign out → gate back.
|
||||
- Coverage: n/a (frontend) — the `app/` floor preserved.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] Anonymous first visit: the gate is the only interactive surface (Tab never reaches the `#message-input` composer — the lock is `#main.inert`); a valid token unlocks WITHOUT a reload.
|
||||
- [ ] A cached valid token re-auths silently on reload — the gate never shows.
|
||||
- [ ] A REVOKED cached token is dropped (localStorage empty) + the gate reappears.
|
||||
- [ ] `login.html` and `shared.html` are UNTOUCHED and their suites stay green; the header's admin/anonymous behavior is byte-identical (the phase-16 + phase-19 suites green).
|
||||
@@ -0,0 +1,32 @@
|
||||
# Task 06 — The admin Tokens view (generate · list · revoke)
|
||||
|
||||
**Phase:** `79_api_tokens` · **Source:** `TODO.md:5` — "…api tokens that the admin can generate and hand out…"
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
The admin UI for tokens: a sixth navbar view (admin-only, folded per the phase-76 pattern) where the admin generates a named token (the plaintext shown once, copyable), lists all tokens with their lifecycle, and revokes with an inline two-step confirm.
|
||||
|
||||
## Work
|
||||
1. `frontend/index.html` — (a) `#app-nav`: after the History link, `<a href="/tokens.html" class="nav-link" id="nav-tokens" hidden>Tokens</a>` (ships hidden — the phase-16 anonymous-safe pattern; the mobile dropdown copy is NOT needed — the link lives in the same `#app-nav` element the hamburger opens, exactly like the other four); (b) after `#view-history`: `<section class="view" id="view-tokens" hidden inert aria-label="Tokens" tabindex="-1">` containing `.container > .page-head` (h1 "Access tokens", sub: "Generate a token and hand it out — it opens chat, the answers, and the documents they cite. Shared chats stay open."), a `#tokens-gate` (the `#history-gate` pattern — the `sources-gate` visual language, sign-in link to `/login.html?next=/tokens.html`), a `<span class="tokens-status" id="tokens-status" role="status" aria-live="polite">` live region, a create row: `<input id="token-label" maxlength="120" placeholder="e.g. alice" aria-label="Token label">` + `<button type="button" class="token-generate" id="token-generate">Generate</button>`, a `#token-once` block (`hidden`): the "shown once" copy line, a mono read-only field `<input id="token-once-value" readonly>` + `<button type="button" id="token-once-copy" aria-label="Copy token">Copy</button>`, and the full-width table (AGENTS.md rule 5 — no skinny list): `#tokens-table` with thead Label | Created | Last used | Status | Actions (the Actions header visually-hidden, the row buttons carry aria-labels — the history-table convention), `#tokens-tbody` + a hidden `#tokens-empty-row`.
|
||||
2. `frontend/assets/tokens.js` (new — `export async function mount(root)`, the `history.js` structure as the template, ALL cells via textContent — labels are admin-derived, still textContent, the XSS-safe-by-construction house rule):
|
||||
- admin gate: `if (!(await fetchIsAdmin()))` → show `#tokens-gate`, hide the table, NO fetch (the router 403s anonymous — the same request-log contract as the history view);
|
||||
- `loadTokens()` → `GET /api/tokens` → rows: label; created (locale date+time, full ISO in `title`); last used (locale or "never"); Status — an "Active" em-dash vs a rose "Revoked" pill (the stale-pill visual language, `aria-label` on the cell in BOTH states — WCAG); Actions — Revoke (the inline two-step, the `history-confirm-*` pattern: first click swaps to "Revoke? [Yes] [No]", focus to Yes, Yes → `POST /api/tokens/<id>/revoke` → row re-renders Revoked + announce; No / failure restores) — Revoked rows show NO action (nothing left to revoke);
|
||||
- generate: label from `#token-label` (blank → send `"token"` — the placeholder documents the fallback; the API's 1–120 validator is satisfied) → `POST /api/tokens` → 201 → `#token-once` visible with the plaintext in `#token-once-value` + [Copy] (clipboard + the inline-fallback house pattern — `tokens.js` keeps its OWN ~10-line copy, the per-page duplication house style) + announce "Token created — copy it now; it won't be shown again." → `loadTokens()` (the new row appears Active) → the once-block HIDES on the next `loadTokens()` / re-show (the plaintext is NOT stored anywhere client-side — no localStorage, no data attribute);
|
||||
- the re-fetch contract from phase 77: `root.addEventListener("bor:view-refresh", () => { if (loaded) loadTokens(); })` — a re-show re-lists (and re-hides the once-block, if one was up);
|
||||
- every action lands a line in `#tokens-status` (success or failure — the never-stale feedback contract).
|
||||
3. `frontend/assets/router.js` — the phase-76 fold entries: `VIEW["/tokens.html"] = "tokens"`; `VIEW_PATH.tokens = "/tokens.html"`; `VIEW_MODULES.tokens = () => import("./tokens.js")`; `TITLES.tokens = "Access tokens · Brain of Reese"`; `DESCRIPTIONS.tokens = "Generate and revoke the API tokens that let people use the app."` (the `replaceAll` brand-composition contract applies — no hardcoded-name write).
|
||||
4. `frontend/assets/header.js` — reveal `#nav-tokens` for role admin in `initSharedHeader()` (the SAME ship-hidden / reveal-for-admin contract as the other four links — one more line, same pattern).
|
||||
5. `app/main.py` — `"/tokens.html"` into the `_shell_routes` tuple (the list is caller-driven — the phase-76 comment documents exactly this extension); `app/core/caching.py` — `"/tokens.html"` into `HTML_PAGES` (the no-cache + `?v=` contract for the deep link). Then the phase-76 task-03 test updates: run `uv run pytest tests/integration` and extend whatever asserts the shell-route / title-table / `_page_file`-override map (the phase-76 task 03 work items named these — follow the same shape for the sixth path).
|
||||
6. `frontend/assets/styles.css` — the create row (flex, wraps ≤640px), the once-block (mono field, the copy button — the `share-link-fallback` visual language), the table (the `history-table` visual language — full-width, the AGENTS.md rule-5 shape), the Active/Revoked pills (the `stale-pill` rose for Revoked, a plain em-dash for Active), `focus-visible` + 4.5:1 throughout.
|
||||
7. `tests/unit/test_frontend_router.py` — the view-map pins adapt to the sixth entry (mechanism-level pins should hold as-is — verify; if a pin enumerates the views, extend the enumeration).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: item 7 + the source-pin convention for view modules (if the house pins the other four modules' `export async function mount`, `tokens.js` gets the same pin).
|
||||
- Integration: the `/tokens.html` shell-route + caching assertions (item 5).
|
||||
- E2E: covered by the task-07 story suite (admin UI: generate → once-field + copy, list, revoke two-step).
|
||||
- Coverage: **>90%** on the modified `app/` code (`main.py` tuple + `caching.py` list — one line each, exercised by the integration assertions).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] The admin sees the Tokens nav link (desktop + mobile menu); anonymous and token users never do (ship-hidden + role check — even mid-DOM, the link is `hidden`).
|
||||
- [ ] Direct load of `/tokens.html` deep-links to the view (admin: the table; anonymous: the gate) and carries the no-cache + `?v=` contract (the cache-busting suites green with the new page).
|
||||
- [ ] Generate → plaintext once + copy works (clipboard + the http fallback); revoke → two-step → Revoked; the cache-busting + nav-switch suites stay green.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Task 07 — The story E2E suite: `tests/e2e/test_api_tokens.py`
|
||||
|
||||
**Phase:** `79_api_tokens` · **Source:** `TODO.md:5` — "Add api tokens that the admin can generate and hand out so people can log in to use the app. The only thing that should be accessible without an API token is shared chats. The web ui should ask for a token before letting a user through and should cache that token in browser storage so they don't have to keep entering it."
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
The owner's sentence, pinned in a real browser: the admin generates a token and hands it out (a fresh context); the holder uses the app; the ONLY anonymous content is shared chats; the cached token removes the re-entry; revocation closes the door.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_api_tokens.py` (NEW — run in isolation: `uv run pytest tests/e2e/test_api_tokens.py -v --no-cov`; DB up, the mock-LLM fixture; fresh browser contexts per scenario — no cached token leaks between tests):
|
||||
- **anonymous is locked out:** fresh context → chat page: `#auth-gate` visible; the composer (`#message-input`) is NOT keyboard-reachable (the `#main` inert lock — the Tab-order assertion pattern from `test_suggestion_chips.py`'s keyboard walk, inverted); direct API with the context's (empty) cookies: `POST /api/chat` → 401, `GET /api/suggestions` → 401, `GET /api/documents/content?source=…&path=…` → 401.
|
||||
- **shared stays open:** as admin, create + share a saved chat (`POST /api/chats` + `POST /api/chats/<id>/share` — the house API pattern from `test_share_chat.py`) → a FRESH context opens `/shared/<token>` anonymously → the conversation renders (no gate anywhere on that page).
|
||||
- **admin generates (UI):** signed-in admin (`auth_helpers.login`) → nav to Tokens → label "e2e-alice" → Generate → `#token-once-value` carries `^bor_[0-9a-f]{32}$` (read it into the test) → the table shows an Active row "e2e-alice"; the once-block hides on a re-show (nav away + back → `#token-once` hidden — the plaintext is gone).
|
||||
- **the token flow (fresh context):** `login_with_token(page, app_url, token)` (the task-04 helper) → gate hidden → ask a question (mock LLM) → the brain bubble renders → a cited source chip opens the document (the same-page modal) → the admin nav links (RAG, Sources, Tuning, History, Tokens) are ALL absent from `#app-nav` (the role-`user` contract) and Sign out is visible.
|
||||
- **caching:** `page.reload()` → NO gate (`#auth-gate` hidden) — the silent re-auth from localStorage; the chat UI is interactive without re-entry.
|
||||
- **admin-only walls (the token user's cookies, httpx):** `GET /api/tokens` 403, `GET /api/chats` 403, `GET /api/docs` 403, `POST /api/steering` 403, `GET /api/git-sources` 403.
|
||||
- **sign out:** the token user clicks Sign out → back to the gate; `localStorage.getItem("bor.token")` is `null` (Playwright `page.evaluate`).
|
||||
- **revocation:** admin revokes the token (UI two-step: Revoke → Yes) → the token user's NEXT action 401s (ask a question → the error banner, or assert `POST /api/chat` 401 with the context cookies) and a FRESH `login_with_token` attempt with the same token fails (`#auth-gate-error` visible, gate stays).
|
||||
- **wrong token:** fresh context, `login_with_token(…, token="bor_" + "0" * 32)` → `#auth-gate-error` visible, still anonymous (the API also 401s — the generic message, no enumeration: the error body for a wrong-format token equals the one for a well-formed unknown token).
|
||||
2. Run in isolation until green; fix app bugs the suite exposes (the suite is the spec — the owner's sentence).
|
||||
|
||||
## Testing & Quality
|
||||
- This file IS the story gate (AGENTS.md rules 4 + 9 — one file per story, run in isolation).
|
||||
- Coverage: n/a (E2E) — the `app/` floor preserved.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_api_tokens.py -v --no-cov` green in isolation.
|
||||
- [ ] Every clause of TODO.md L5 is asserted: generate (UI), hand out (fresh context), use the app (chat + document), only-shared-chats-open (the anonymous matrix), cached token (reload without re-entry), revocation (immediate refusal).
|
||||
@@ -0,0 +1,23 @@
|
||||
# Task 08 — Regression sweep + the commit
|
||||
|
||||
**Phase:** `79_api_tokens` · **Source:** `TODO.md:5`
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
The full pipeline green against the gated app, the operator docs updated, one atomic commit, the phase closed.
|
||||
|
||||
## Work
|
||||
1. Full unit + integration: `uv run pytest --cov=app --cov-report=term-missing` — **>90%** on `app/` (the delta: `app/core/tokens.py`, `app/api/tokens.py`, and the modified `app/core/auth.py` / `app/api/auth.py` / `app/api/chat.py` / `app/api/suggestions.py` / `app/api/docs.py` — every new branch tested per tasks 02/03).
|
||||
2. `uv run ruff check . && uv run pyright`.
|
||||
3. E2E inventory spot-checks in isolation (the high-touch files): `test_api_tokens.py`, `test_admin_auth.py`, `test_share_chat.py` (ANONYMOUS), `test_chat_rag.py` (migrated), `test_nav_switch_keeps_stream.py` (the phase-76 contract under the gate), `test_smoke.py`.
|
||||
4. Manual verification in a real browser (real LLM, real server): the admin generates a token; a private window enters it at the gate, chats, opens a cited document, reloads WITHOUT re-entry; an anonymous window meets the gate and opens a shared chat; the admin revokes the token and the private window's next question fails.
|
||||
5. Operator docs: `README.md` — the "Admin & sign-in" section gains a short "API tokens" subsection (how to generate one in the Tokens view, what a token user can and cannot do, the gate + the browser cache, revocation semantics — immediate on the next request). `.env.example` — UNCHANGED (no new environment variable: tokens live in the DB, generated by the admin — verify no settings were added; if any task added one, the doc goes here).
|
||||
6. ONE atomic `--no-gpg-sign` Conventional-Commits commit — e.g. `feat(auth): admin-issued API tokens gate the app; only shared chats stay anonymous` — body cites TODO.md L5 + the confirmed scope decision (A3–A5: token users get chat/suggestions/document viewer; every admin surface untouched). Move the phase dir to `.agents/phases/complete/`.
|
||||
|
||||
## Testing & Quality
|
||||
- The full suite IS the test; coverage **>90%** on `app/`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] Items 1–4 all green/verified.
|
||||
- [ ] README documents the token flow for the operator.
|
||||
- [ ] Committed; phase dir in `.agents/phases/complete/`.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Phase 80 — Onboarding chips: the last 3 questions asked (the env var seeds a fresh deployment)
|
||||
|
||||
**Source:** `TODO.md` L6 — "The chat suggestions (suggestion chips) should be the last 3 questions asked rather than supplied by env vars. The env var should be used to offer questions before any have been asked as a 'seed' for a new deployment."
|
||||
**Story:** n/a (TODO-derived — extends `.agents/user_stories/suggestion-chips.md` (phase 05))
|
||||
**Context:** `app/api/suggestions.py` (`GET /api/suggestions` → `settings.suggestions` — a static list, 15 lines today), `app/config.py` (`suggestions: list[str]` — the 4 built-in defaults + the `BOR_SUGGESTIONS` env override; `.env.example:55`; `README.md:782` env-table row), `app/models.py` (`SavedChat.messages` — the JSONB `bor.chat.v1` record: a list of `{who, text, sources?, …}` entries, conversational order oldest→newest; `updated_at` stamped on save), `app/rag/suggestions.py` (the deflection "Maybe try" chips — `derive_suggestions`, a SEPARATE contract, untouched by this phase), `frontend/assets/app.js` (`loadSuggestions()` at boot → `renderChips` into `#suggestions` (role="list") inside `#empty-state` (~line 159 of `index.html`); `startNewChat()` — the empty state comes back with STALE chips), `tests/e2e/test_suggestion_chips.py` (the phase-05 suite — pins the settings-defaults contract; REWRITTEN in task 04 per the phase-76 precedent), `tests/integration/test_api.py` (current `/api/suggestions` integration pins).
|
||||
|
||||
## Objective
|
||||
The onboarding chips reflect the deployment's recent activity: the 3 most recent questions actually asked (across saved chats), newest first. A brand-new deployment — zero saved questions — gets the seed list instead (`BOR_SUGGESTIONS`, or the built-in default while unset). The chips also refresh when the empty state comes back (New chat), so the row is never stale. The LLM "Maybe try" deflection chips are untouched.
|
||||
|
||||
## Owner decisions (chat, 2026-09-06 — recorded per AGENTS.md rule 3)
|
||||
- **A6 confirmed:** "the last 3 questions asked" = the 3 most recent USER questions across ALL `saved_chats` — chats walked newest-`updated_at` first, each chat's `messages` walked newest-first, collecting entries with `who == "user"` and a non-blank `text` — de-duplicated (exact, case-sensitive match; a verbatim re-ask counts once), cap 3 applied AFTER dedup. 1–2 saved questions → exactly those show (NO mixing with the seed). ZERO saved questions → the full seed list.
|
||||
- **A7 confirmed:** only the onboarding row (`#suggestions` in the empty state) changes. The deflection "Maybe try" chips (title-derived via `app.rag/suggestions.derive_suggestions`, carried in the chat response) keep their contract.
|
||||
- **Scope is deployment-wide:** saved chats have no per-user attribution and are created only by the admin (phase 79 keeps the save surface admin-only) — the chips reflect the admin's recent questions; there is no per-user question history to scope by (and no new attribution is added in this phase).
|
||||
|
||||
## Design (shared by all tasks)
|
||||
- **Extraction — `app/api/suggestions.py`:** the endpoint gains the `db` dependency and a module-level `last_questions(db, limit: int = 3) -> list[str]`: `SELECT … FROM saved_chats ORDER BY updated_at DESC, created_at DESC` (the tiebreak keeps the order deterministic when timestamps collide); for each chat, walk `chat.messages` (the JSONB column deserializes to a Python list of dicts — NO SQL JSON ops needed; the message counts are the `bor.chat.v1` conversation sizes) in REVERSE (newest first), collecting `m["text"].strip()` for entries where `m.get("who") == "user"`, skipping blanks, stopping once `limit` UNIQUE texts are collected (exact case-sensitive dedup — the docstring notes the choice: case-insensitive dedup would drop a legitimate differently-cased re-ask). Result order = encounter order (newest first).
|
||||
- **The endpoint:** `qs = last_questions(db)` → `SuggestionList(suggestions=qs if qs else get_settings().suggestions)` — the seed (`BOR_SUGGESTIONS` override or the built-in default) appears ONLY when the walk yields zero questions. The endpoint is `require_user`-gated by phase 79 (admin OR token user) — no auth change here; the tests sign in first.
|
||||
- **The deflection path is untouched:** `app/rag/suggestions.py` (`derive_suggestions` — used by the chat turn for the "Maybe try" row) does not call this endpoint and is not modified; its tests stay green.
|
||||
- **Frontend — the refetch (task 03):** `startNewChat()` (the `bor:new-chat` handler in `app.js`) gains a `loadSuggestions()` call when it restores the empty state — the existing function (fetch `/api/suggestions` → `renderChips` into `#suggestions`, replacing the previous chips in place; progressive enhancement, swallows its own failures). The boot fetch stays. No other frontend change: the chips' one-tap submit, the role="list" semantics, and the "Maybe try" row are all existing contracts.
|
||||
- **Docs (task 02):** `BOR_SUGGESTIONS` is documented as the SEED — shown only before any question has ever been saved — in the `config.py` docstring, `.env.example:55`, and the README (the env-table row + the chat feature description).
|
||||
|
||||
## Dependencies
|
||||
- `79_api_tokens` (todo) — `/api/suggestions` is `require_user`-gated there; this phase's tests sign in first and build on that contract (a token user ALSO sees the deployment-wide chips — consistent with A6's deployment-wide scope).
|
||||
|
||||
## Tasks
|
||||
1. `01_last_questions_endpoint.md` — the `last_questions` extraction + the seed fallback + the integration state matrix.
|
||||
2. `02_seed_semantics_docs.md` — the config / `.env.example` / README doc updates.
|
||||
3. `03_chips_new_chat_refetch.md` — the app.js refetch on New chat + the source pin.
|
||||
4. `04_e2e_suite_commit.md` — the story-suite rewrite to the new semantics + the full gate + the atomic commit.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: the full state matrix for `/api/suggestions` (empty DB → the built-in seed; `BOR_SUGGESTIONS` override → the override while empty; 4 questions in one chat → the 3 newest; two chats → the `updated_at` order respected; dedup; exactly 2 questions → 2 chips, no top-up; brain-only messages never picked).
|
||||
- E2E: the REWRITTEN `tests/e2e/test_suggestion_chips.py` run in isolation (the phase-76 precedent — a semantic change rewrites the story suite in place).
|
||||
- Coverage: **>90%** on `app/` (the delta is the ~30-line helper + endpoint in `app/api/suggestions.py` — every branch covered).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov` green in isolation.
|
||||
- [ ] The deflection chips are UNCHANGED (`derive_suggestions` + its suites green).
|
||||
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] One atomic `--no-gpg-sign` Conventional-Commits commit (e.g. `feat(chat): onboarding chips are the last 3 questions asked; the env seed only before the first`); phase dir moved to `.agents/phases/complete/`.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Task 01 — `/api/suggestions` returns the last 3 questions asked (the seed before the first)
|
||||
|
||||
**Phase:** `80_history_suggestion_chips` · **Source:** `TODO.md:6` — "The chat suggestions (suggestion chips) should be the last 3 questions asked rather than supplied by env vars. The env var should be used to offer questions before any have been asked as a 'seed' for a new deployment."
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
The onboarding chips become the 3 most recent user questions across saved chats (newest first, de-duplicated, cap 3); a fresh deployment with zero saved questions gets the seed list (`BOR_SUGGESTIONS` / the built-in default).
|
||||
|
||||
## Work
|
||||
1. `app/api/suggestions.py` — the endpoint gains `db: Session = Depends(get_db)` (import from `app.db` — the house pattern) and a module-level helper:
|
||||
```python
|
||||
def last_questions(db: Session, limit: int = 3) -> list[str]:
|
||||
```
|
||||
- query `SavedChat` ordered by `updated_at.desc(), created_at.desc()` (the tiebreak — deterministic when timestamps collide; `from app.models import SavedChat`);
|
||||
- for each chat, walk `chat.messages` in REVERSE (conversational order is oldest→newest — the `bor.chat.v1` shape) collecting `str(m.get("text", "")).strip()` for entries where `m.get("who") == "user"`; skip blanks;
|
||||
- de-dup EXACT (case-sensitive) against the collected window; stop once `limit` unique texts are collected; return in encounter order (newest first).
|
||||
- docstring: the JSONB column deserializes to a Python list of dicts (no SQL JSON ops — the `SavedChat.messages` model docstring says the record shape is the `bor.chat.v1` list); the exact-dedup choice is documented (case-insensitive would drop a legitimately differently-cased re-ask); the helper is pure-DB (unit-testable without the endpoint).
|
||||
- endpoint body: `qs = last_questions(db)` → `return SuggestionList(suggestions=qs if qs else get_settings().suggestions)` — update the module/endpoint docstring: this endpoint owns the "last-3-questions-or-seed" contract; the deflection "Maybe try" chips (`app.rag.suggestions.derive_suggestions`) are a separate contract, untouched.
|
||||
- note: phase 79 gates this endpoint with `require_user` — if phase 79 has NOT landed yet when this task runs (it is queued before this phase, so it has), the endpoint already carries the dependency; do not remove it.
|
||||
2. `tests/integration/test_suggestions_api.py` (new — the house integration pattern, admin sign-in first — the endpoint is authed):
|
||||
- **empty DB** (no saved chats) → exactly the built-in default list (assert equality with `get_settings().suggestions` — and `tests/unit/test_config.py` keeps pinning that default);
|
||||
- **seed override:** the `BOR_SUGGESTIONS` JSON parsing is ALREADY pinned at unit level by `tests/unit/test_config.py::test_suggestions_env_override_is_json_list` (`monkeypatch.setenv("BOR_SUGGESTIONS", json.dumps(…))`) — do NOT hack per-test env into the integration app; the integration contract is the env-agnostic one below it (empty DB → exactly `get_settings().suggestions`, whatever the environment makes that);
|
||||
- **cap + order:** one saved chat with 4 user questions q1..q4 (plus brain replies between them) → chips == `[q4, q3, q2]` (newest first, cap 3);
|
||||
- **chat order:** two saved chats with DISTINCT `updated_at` (set the timestamps explicitly on the rows) → the newer chat's questions are walked first — a question from the newer chat outranks a newer-LOOKING question from the older chat;
|
||||
- **dedup:** the same question text asked in two chats → appears exactly once;
|
||||
- **partial:** exactly 2 saved questions deployment-wide → exactly 2 chips (NO seed top-up — the A6 contract);
|
||||
- **brain-only:** a chat whose messages are all `who == "brain"` (or blank user texts) → contributes nothing; an all-brain deployment → the seed.
|
||||
- anonymous → 401 (phase-79 contract — one assertion so the auth state of this endpoint is pinned HERE too).
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: item 2 (every branch of `last_questions` + the fallback + the auth pin).
|
||||
- Coverage: **>90%** on the modified `app/api/suggestions.py` (the helper's every branch: empty, partial, cap, dedup, blank-skip, brain-skip).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `GET /api/suggestions` (authed) returns exactly the designed contract in every state of item 2.
|
||||
- [ ] `app/rag/suggestions.py` (`derive_suggestions`) is UNTOUCHED and its tests green; the deflection "Maybe try" E2E behavior unchanged.
|
||||
- [ ] `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Task 02 — Document the seed semantics (config, `.env.example`, README)
|
||||
|
||||
**Phase:** `80_history_suggestion_chips` · **Source:** `TODO.md:6`
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
The docs describe `BOR_SUGGESTIONS` as what it now is: the pre-first-question SEED — not "the" onboarding chips.
|
||||
|
||||
## Work
|
||||
1. `app/config.py` — the comment block above `suggestions: list[str]` becomes: "Onboarding-chip SEED (phase 80, TODO.md L6): shown ONLY while no saved chat has ever asked a question — after that, `GET /api/suggestions` serves the last 3 questions asked (deployment-wide, newest first). `BOR_SUGGESTIONS` overrides this seed for a new deployment."
|
||||
2. `.env.example` (line ~55) — the comment becomes: `# BOR_SUGGESTIONS=["How is my Kubernetes cluster set up?"] # JSON seed chips — shown only before any question has been saved (phase 80)`
|
||||
3. `README.md` — the env-table row (`BOR_SUGGESTIONS`, ~line 782): "JSON seed for the onboarding chips — shown only before the first saved question; afterwards the chips are the last 3 questions asked (phase 80)".
|
||||
4. `README.md` — wherever the chat / suggestion-chip feature is described (the feature list row ~line 263 and the deflection note ~line 829 stay accurate — the deflection chips are UNCHANGED; add/adjust ONE line in the chat feature description: the onboarding chips follow the last 3 questions asked, seeding from `BOR_SUGGESTIONS` on a fresh deployment).
|
||||
|
||||
## Testing & Quality
|
||||
- Docs only — `tests/unit/test_config.py` and the stale-copy suites must stay green (no behavior change; the `suggestions` default list itself is UNTOUCHED).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] The three doc sites (config, `.env.example`, README) agree with the implemented contract.
|
||||
- [ ] Full suite green; `uv run ruff check . && uv run pyright` clean.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Task 03 — The chips refresh when the empty state comes back (New chat)
|
||||
|
||||
**Phase:** `80_history_suggestion_chips` · **Source:** `TODO.md:6`
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
The onboarding row is fetched once at boot; after "New chat" (or clearing a restored conversation) the empty state returns with STALE chips — the last-3 state moved on while the user was chatting. Refetch on `bor:new-chat` so the row always reflects the current last-3 state.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/app.js` — in `startNewChat()` (the `bor:new-chat` handler, ~line 1848): after `if (emptyState) emptyState.hidden = false;`, add `loadSuggestions();` — the existing function (fetch `/api/suggestions` → `renderChips` into `#suggestions`, which clears the previous chips in place; progressive enhancement — swallows its own failures, no error spam). The in-flight-turn guard at the top of `startNewChat` means the refetch only runs for a real new chat.
|
||||
- The boot path is UNCHANGED: `loadSuggestions()` still runs at shell boot (first paint of the empty state). The `/?chat=<id>` boot opens a saved chat (empty state hidden) — when the user then clicks New chat, this refetch covers it. No other frontend change: the chips' one-tap submit, `role="list"` semantics, and the "Maybe try" deflection row are existing contracts.
|
||||
2. Source pin (house pattern — read the JS source, no browser): in the app.js source-pin file that covers the new-chat flow (find where `bor:new-chat` / `startNewChat` is pinned today — `tests/unit/test_chat_persistence.py` or a `test_frontend_*.py` sibling; if none exists, add the pin to the most app.js-adjacent frontend source-pin file): `startNewChat` calls `loadSuggestions()` (the two literals, `startNewChat`'s function body containing the `loadSuggestions()` call — a containment assertion on the function's source slice, the house style).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: item 2 (the source pin).
|
||||
- E2E: covered by the task-04 suite (the new-chat refetch assertion).
|
||||
- Coverage: n/a (frontend) — the `app/` floor preserved.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] After New chat, `#suggestions` reflects a FRESH `/api/suggestions` response (the Playwright request log shows a second GET after the boot fetch).
|
||||
- [ ] The source pin is green; `uv run ruff check . && uv run pyright` clean — the gate runs after this task.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Task 04 — Rewrite the story suite to the new semantics + sweep + the commit
|
||||
|
||||
**Phase:** `80_history_suggestion_chips` · **Source:** `TODO.md:6`
|
||||
**Story:** n/a (TODO-derived)
|
||||
|
||||
## Objective
|
||||
`tests/e2e/test_suggestion_chips.py` pins the NEW contract (the phase-76 precedent — a semantic change rewrites the story suite in place), the full pipeline is green, and the phase closes.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_suggestion_chips.py` — REWRITE (keep the file name — one suite per story, run in isolation; the endpoint is authed, so sign in as admin first via `auth_helpers.login`):
|
||||
- **seed state:** fresh DB (no saved chats) → the chip texts equal the built-in default list — assert against a test-local constant copied from the `Settings.suggestions` default in `app/config.py` (the unit suite `test_config.py` pins only the SHAPE — ≥3 non-blank distinct strings — so this E2E literal is the pin for the exact seed list; keep it in sync with the config default); the chips render in `#suggestions` (role="list", the chip buttons) exactly as today.
|
||||
- **last-3 state:** as admin, save two chats via `POST /api/chats` with known distinct questions (5 user questions total; set `updated_at` apart so the order is deterministic — the API stamps it; save the older one first) → a fresh page load → the chips are EXACTLY the 3 newest questions, in newest-first order.
|
||||
- **partial state:** a DB with exactly 2 saved questions → exactly 2 chips (no seed top-up — the A6 contract, visible in the UI).
|
||||
- **new-chat refetch:** load the page with the seed chips visible → via the API save a chat whose newest question is Q → click New chat (`#new-chat-btn`) → the chips now include Q (the refetch happened — the request log shows the second `GET /api/suggestions`).
|
||||
- **carry over the story behavior** (unchanged semantics, same assertions as the old suite where they still hold): one-tap submit (chip click → composer filled → submitted — the mock-LLM brain bubble), Tab+Enter keyboard reachability of the chips (the original suite's keyboard-walk assertion).
|
||||
- Run in isolation until green: `uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov`.
|
||||
2. Full gate: `uv run pytest --cov=app --cov-report=term-missing` (>90%), `uv run ruff check . && uv run pyright`; the deflection-related suites green UNCHANGED (their chips are the LLM/title path — find them: `test_honest_deflection.py` and any "Maybe try" assertions — they must not have been touched by this phase).
|
||||
3. ONE atomic `--no-gpg-sign` Conventional-Commits commit — e.g. `feat(chat): onboarding chips are the last 3 questions asked; the env seed only before the first` — body cites TODO.md L6 + the A6/A7 decisions (deployment-wide, exact dedup, no mixing, deflection untouched). Move the phase dir to `.agents/phases/complete/`.
|
||||
|
||||
## Testing & Quality
|
||||
- The full suite IS the test; coverage **>90%** on `app/`.
|
||||
- E2E: the rewritten story suite in isolation (AGENTS.md rule 9).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_suggestion_chips.py -v --no-cov` green in isolation — all four states (seed / last-3 / partial / refetch) + the carried-over story behavior.
|
||||
- [ ] Full suite green, coverage >90%, `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] Committed; phase dir in `.agents/phases/complete/`.
|
||||
Reference in New Issue
Block a user