feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 13s

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:
2026-09-07 12:39:01 -04:00
parent 495d042a98
commit 7fce6572d0
215 changed files with 10142 additions and 1643 deletions
+416
View File
@@ -0,0 +1,416 @@
/* Brain of Reese — Tokens view (access tokens, phase 79 task 06;
* the phase-76 fold pattern: shell view module).
*
* TODO.md L5 (owner 2026-09-06): "…api tokens that the admin can
* generate and hand out so people can log in to use the app."
*
* Wires the admin-only token endpoints (phase 79 task 02) into the
* view:
*
* • the create row (label + Generate): a BLANK label sends "token"
* (the placeholder documents the fallback; the API's 1–120
* validator is satisfied). POST /api/tokens → 201 — the ONE
* response that carries the plaintext (owner-locked A4) — and the
* plaintext appears EXACTLY ONCE: in the #token-once block's mono
* read-only field, with a Copy (the clipboard; a non-secure http
* origin that rejects it gets the inline fallback — the field
* selects itself for Ctrl/Cmd+C). The block hides on the NEXT
* loadTokens() / re-show (and the field is wiped with it) — the
* plaintext is NOT stored anywhere client-side (no localStorage,
* no data attribute), so a re-render can never re-show it;
* • the full-width table (AGENTS.md rule 5): Label (textContent —
* admin-derived, still text) | Created (locale date+time, the full
* ISO in the title) | Last used (locale or "never") | Status — a
* plain em-dash for Active, the rose .stale-pill for Revoked (the
* stale-pill visual language; the cell carries its aria-label in
* BOTH states — WCAG 2.1 AA) | Actions — Revoke, the inline
* TWO-STEP confirm (the history-confirm-* pattern — NO native
* confirm dialog: the first click swaps to "Revoke? [Yes] [No]",
* focus moves to Yes; Yes POSTs /api/tokens/<id>/revoke and the
* row re-renders Revoked + the live region line; No or a failed
* request restores the Revoke button — retryable). Revoked rows
* carry NO action (nothing left to revoke).
*
* Every cell is built with the DOM APIs (textContent) — this file
* never builds HTML (the XSS-safe-by-construction house rule; a full-
* file source pin enforces it).
*
* The whoami gate (phase 19 shared-header module, cached promise):
* • anonymous → the #tokens-gate is shown, the create row + table
* hide, and NO /api/tokens request is made at all (the router
* 403s anonymous — the same request-log contract as the history
* view);
* • admin → the gate hides, the create row + table reveal, and
* `loadTokens()` renders the rows; a 0-row fetch (and a failed
* load) reveals the empty-state row.
*
* Phase 76 (task 06) — shell view module: the top-level boot is
* `export async function mount(root)` — root is the view's
* `<section id="view-tokens">`, and every DOM lookup scopes to root
* (the view ids stay unique across the shell). The router mounts a
* view ONCE (mount-once, hide-forever), so the bindings + state
* survive every switch. The initSharedHeader() call is DROPPED: in
* the shell the shared header boots exactly once, via the chat module
* (app.js) at shell boot. The admin gate keeps fetchIsAdmin() — the
* SAME cached /api/whoami promise header.js exports (zero extra
* requests).
*
* Phase 77 (the re-show refresh contract): the shell router dispatches
* `bor:view-refresh` on the view's section when the user RE-SHOWS an
* already-mounted view (a switch back onto it, a re-click of the
* Tokens nav link, or back/forward) — the first show (the mount) and
* boot never (the mount's own load is the first fetch). This module
* listens on root and re-runs `loadTokens()`, which is re-entrant: a
* re-load drops the data rows (the hidden #tokens-empty-row stays in
* the tbody) before fetching, so the list is REPLACED — never
* duplicated — and it ALSO re-hides the once-block if one was up
* (the plaintext is gone). The listener is armed only in the ADMIN
* branch, after the whoami gate passes: anonymous shows the gate and
* never fetches.
*
* The clipboard + inline-fallback helper is tokens.js's OWN ~10-line
* copy (the per-page duplication house style — history.js keeps the
* share link's, app.js the chat page's; no new shared module).
*/
import { fetchIsAdmin } from "./header.js";
export async function mount(root) {
/* ---------- view elements (the view's section, scoped to root) ---------- */
const tableWrap = root.querySelector("#tokens-table-wrap");
const tbody = root.querySelector("#tokens-tbody");
const emptyRow = root.querySelector("#tokens-empty-row");
const gateEl = root.querySelector("#tokens-gate");
const statusEl = root.querySelector("#tokens-status");
// The create row (label + Generate) — SHIPS hidden (anonymous-safe;
// the admin branch reveals it).
const createRow = root.querySelector("#token-create");
const labelInput = root.querySelector("#token-label");
const generateBtn = root.querySelector("#token-generate");
// The shown-once block — the plaintext lives in the read-only field's
// VALUE only (never a data attribute, never localStorage).
const onceBlock = root.querySelector("#token-once");
const onceValue = root.querySelector("#token-once-value");
const onceCopy = root.querySelector("#token-once-copy");
/* Action feedback — the role="status" live region (the "never stale"
contract: every action lands a line here, success or failure
alike). */
function announce(message) {
if (statusEl) statusEl.textContent = message;
}
function fmtDate(iso) {
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
/* Clipboard + the inline fallback (tokens.js's OWN copy — the
per-page duplication house style): a non-secure (http) homelab
origin rejects navigator.clipboard, so the failure path FOCUSES
+ SELECTS the visible once field — the token is right there, ready
for Ctrl/Cmd+C (the field is the fallback surface; unlike the
share-link case there is nothing to render — the plaintext is
already on screen). Returns true when the clipboard took it. */
async function copyTokenToClipboard() {
const token = onceValue ? onceValue.value : "";
if (!token) return true;
try {
await navigator.clipboard.writeText(token);
return true;
} catch {
if (onceValue) {
onceValue.focus({ preventScroll: true });
onceValue.select();
}
return false;
}
}
/* Copy (the once block's button): clipboard → the inline fallback.
The live region lands the outcome either way. */
async function copyTokenAction() {
const copied = await copyTokenToClipboard();
announce(
copied
? "Token copied."
: "The token is selected in the field — press Ctrl/Cmd+C to copy.",
);
}
/* One row. The Status cell carries the cell-level aria-label in BOTH
states (Active em-dash / the rose Revoked pill — the stale-pill
visual language) so the marker is conveyed without the visual
(WCAG 2.1 AA). The Actions cell carries the two-step Revoke for
active rows only — revoked rows have nothing left to revoke. */
function makeRow(tok) {
const tr = document.createElement("tr");
const labelTd = document.createElement("td");
labelTd.className = "tokens-label-cell";
labelTd.title = tok.label; // full label on hover (the column ellipsizes)
labelTd.textContent = tok.label; // admin-derived — textContent only
tr.appendChild(labelTd);
const createdTd = document.createElement("td");
createdTd.className = "tokens-date-cell";
createdTd.title = tok.created_at; // full ISO on hover
createdTd.textContent = fmtDate(tok.created_at);
tr.appendChild(createdTd);
const usedTd = document.createElement("td");
usedTd.className = "tokens-date-cell";
if (tok.last_used_at) {
usedTd.title = tok.last_used_at; // full ISO on hover
usedTd.textContent = fmtDate(tok.last_used_at);
} else {
usedTd.textContent = "never"; // not used yet (the token-auth stamp)
}
tr.appendChild(usedTd);
const statusTd = document.createElement("td");
statusTd.className = "tokens-status-cell";
if (tok.revoked) {
statusTd.setAttribute(
"aria-label",
"Revoked — this token is dead and can no longer sign in",
);
const pill = document.createElement("span");
pill.className = "stale-pill";
pill.title = "Revoked — the token can no longer sign in";
pill.textContent = "Revoked";
statusTd.appendChild(pill);
} else {
statusTd.setAttribute(
"aria-label",
"Active — this token can still be used to sign in",
);
statusTd.textContent = "—"; // the em-dash: active rows' marker
}
tr.appendChild(statusTd);
const actionsTd = document.createElement("td");
actionsTd.className = "tokens-actions-cell";
if (!tok.revoked) {
actionsTd.appendChild(makeRevokeControl(tok, tr));
}
tr.appendChild(actionsTd);
return tr;
}
/* The inline two-step Revoke (the history-confirm-* pattern — NO
native confirm dialog anywhere in this file). The Revoke button is
replaced, in place, by the "Revoke? [Yes] [No]" pair; focus moves
to Yes (keyboard-reachable confirm). Yes → POST
/api/tokens/<id>/revoke → the row re-renders Revoked (+ the live
region line); No or a failed request restores the Revoke button
(retryable). */
function makeRevokeControl(tok, row) {
const cell = document.createElement("span");
cell.className = "tokens-actions";
const revokeBtn = document.createElement("button");
revokeBtn.type = "button";
revokeBtn.className = "token-revoke";
revokeBtn.setAttribute("aria-label", `Revoke token: ${tok.label}`);
revokeBtn.textContent = "Revoke";
function restoreRevoke() {
cell.replaceChildren(revokeBtn);
revokeBtn.focus(); // focus returns to the (restored) control
}
revokeBtn.addEventListener("click", () => {
const label = document.createElement("span");
label.className = "history-confirm-text";
label.textContent = "Revoke?";
const yes = document.createElement("button");
yes.type = "button";
yes.className = "history-confirm-yes";
yes.textContent = "Yes";
const no = document.createElement("button");
no.type = "button";
no.className = "history-confirm-no";
no.textContent = "No";
yes.addEventListener("click", () =>
confirmRevoke(tok, row, yes, restoreRevoke));
no.addEventListener("click", restoreRevoke);
cell.replaceChildren(label, yes, no);
yes.focus(); // the confirm pair takes over the focus
});
cell.appendChild(revokeBtn); // the shipped state IS the Revoke button
return cell;
}
/* The confirmed revoke: POST /api/tokens/<id>/revoke (204 —
idempotent server-side) → the row STAYS (it is not removed) and
re-renders Revoked, and the live region gets `Revoked "<label>".`
A 404 means the row is gone (revoked elsewhere) — re-render it
Revoked and say so. Any other failure or a network error keeps the
row, restores the Revoke button (retryable), and lands the error
line. */
async function confirmRevoke(tok, row, yesBtn, restoreRevoke) {
yesBtn.disabled = true; // no double-fire while the request is in flight
let r;
try {
r = await fetch(`/api/tokens/${tok.id}/revoke`, { method: "POST" });
} catch {
announce(`Couldn't revoke "${tok.label}" — is the app reachable?`);
restoreRevoke();
return;
}
if (r.status === 404) {
row.replaceWith(makeRow({ ...tok, revoked: true }));
announce("That token was already revoked.");
return;
}
if (!r.ok) {
announce(`Couldn't revoke "${tok.label}" — try again.`);
restoreRevoke();
return;
}
row.replaceWith(makeRow({ ...tok, revoked: true }));
announce(`Revoked "${tok.label}".`);
}
/* The empty-state row reappears exactly when there is nothing else
in the tbody (the empty row itself ships in the tbody, hidden). */
function showEmptyState() {
if (!tbody) return;
tbody.replaceChildren(emptyRow);
if (emptyRow) emptyRow.hidden = false;
}
/* GET /api/tokens → render the rows (newest-first — the server's
order). A 0-row fetch shows the empty-state row. Re-entrant (the
phase-77 re-show contract): a re-show re-run must REPLACE the
list, not append a duplicate set — the data rows (every <tr>
EXCEPT the hidden #tokens-empty-row, which the load itself
re-hides / reveals) are dropped before the fetch — and the
once-block HIDDEN + its field wiped (the plaintext is gone: a
re-render can never re-show it).
A FAILED load announces its line in the live region (the house
copy: "is the app reachable?" / "try again.") and RETURNS the
outcome: true when the fetch settled (a 0-row fetch is a
SUCCESS — the empty state is the honest view), false on non-2xx /
network error. */
async function loadTokens() {
if (onceBlock) onceBlock.hidden = true;
if (onceValue) onceValue.value = "";
if (tbody) {
for (const tr of tbody.querySelectorAll("tr")) {
if (tr !== emptyRow) tr.remove();
}
}
if (emptyRow) emptyRow.hidden = true;
let r;
try {
r = await fetch("/api/tokens");
} catch {
announce("Couldn't load tokens — is the app reachable?");
showEmptyState();
return false;
}
if (!r.ok) {
announce("Couldn't load tokens — try again.");
showEmptyState();
return false;
}
const { tokens } = await r.json();
if (!tokens.length) {
showEmptyState();
return true;
}
for (const tok of tokens) {
tbody.appendChild(makeRow(tok));
}
return true;
}
/* Generate: the label comes from #token-label — a BLANK label sends
"token" (the placeholder documents the fallback; the API's 1–120
validator is satisfied). The button runs the §7.4 never-stale
lifecycle: "Generating…" while the POST is in flight, re-enabled
on success AND failure (the finally — a click can never leave it
stuck disabled). On 201 the re-entrant list load runs FIRST (it
hides the once-block — the re-render contract) and THEN the
once-block reveals with the plaintext (the 201 body's token is
the ONE plaintext that exists, A4 — it lives in this closure
until the next loadTokens() hides the block again), the live
region gets the shown-once line, and the label input clears (a
new token is a new hand-out). A failed create keeps the label
(retryable) and lands the error line. */
async function generateToken() {
const label = (labelInput ? labelInput.value : "").trim() || "token";
if (generateBtn) generateBtn.disabled = true;
if (generateBtn) generateBtn.textContent = "Generating…";
let created = null;
try {
const r = await fetch("/api/tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ label }),
});
if (!r.ok) {
announce("Couldn't create the token — try again.");
return;
}
created = await r.json();
} catch {
announce("Couldn't create the token — is the app reachable?");
return;
} finally {
if (generateBtn) generateBtn.disabled = false;
if (generateBtn) generateBtn.textContent = "Generate";
}
/* The re-render load FIRST (the once-block's hider), then the
reveal: the plaintext lands in the field's VALUE only. */
await loadTokens();
if (onceValue) onceValue.value = created.token;
if (onceBlock) onceBlock.hidden = false;
if (labelInput) labelInput.value = "";
announce("Token created — copy it now; it won't be shown again.");
}
/* ---------- view boot (phase 79 task 06) ----------
* The shared header is NOT booted here — in the shell it runs
* exactly once, via the chat module (app.js) at shell boot. The
* whoami gate reads fetchIsAdmin() — the SAME cached whoami promise
* the header uses (zero extra requests). Anonymous: the gate in,
* the create row + table out — and NO /api/tokens request at all
* (the router 403s anonymous, so the view must never call it). */
if (!(await fetchIsAdmin())) {
if (gateEl) gateEl.hidden = false;
if (createRow) createRow.hidden = true;
if (tableWrap) tableWrap.hidden = true;
return;
}
if (gateEl) gateEl.hidden = true;
if (createRow) createRow.hidden = false;
if (tableWrap) tableWrap.hidden = false;
if (onceBlock) onceBlock.hidden = true; // ships hidden; only a 201 reveals it
/* Phase 77: a user-initiated re-show of this already-mounted view
makes the router dispatch bor:view-refresh on the section —
re-load then (loadTokens is re-entrant: the list is replaced AND
the once-block re-hidden — the plaintext is gone). The listener
is armed ONLY here, after the whoami gate passed: anonymous shows
the gate and must never fetch. `started` flips true once the
first loadTokens() is made (below), so the listener can only ever
re-run a load the mount already did. */
let started = false;
root.addEventListener("bor:view-refresh", () => {
if (started) loadTokens();
});
if (generateBtn) {
generateBtn.addEventListener("click", () => void generateToken());
}
if (onceCopy) {
onceCopy.addEventListener("click", () => void copyTokenAction());
}
started = true;
loadTokens();
}