Files
brain-of-reese/frontend/assets/token-gate.js
T
ducoterra 7fce6572d0
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 13s
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
2026-09-07 12:39:01 -04:00

253 lines
11 KiB
JavaScript

/* Brain of Reese — the in-app token gate (phase 79, task 05).
*
* The owner's sentence (TODO.md L5): "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."
*
* One reusable mount point for the two gated surfaces:
*
* • the shell (index.html) — app.js's boot IIFE awaits
* mountGate(#main, onAuthed) BEFORE its initSharedHeader;
* • the document viewer (document.html) — document.js wires
* mountGate(#main, onAuthed) around the existing boot sequence
* (whoami → load content).
*
* Both carry the same gate markup (class .auth-gate; the ids differ —
* #auth-gate / #doc-auth-gate — so the gate is found by CLASS: the
* ONE .auth-gate section on the page).
*
* The contract (pinned at source level in
* tests/unit/test_token_gate.py):
*
* • the gate SHIPS hidden + inert (the phase-16 ship-hidden pattern):
* an authenticated boot never shows it for a frame.
* • (1) at mount, a CACHED token (localStorage["bor.token"]) is
* re-sent to POST /api/token-auth SILENTLY — BEFORE the whoami
* check. On ANY failure the key is removed (the token may have
* been revoked) and the mount falls through to the role check.
* • (2) the role check is header.js's fetchWhoami() — the SAME
* cached promise the page's own header boot uses (exactly one
* /api/whoami per page load in dev): role "user" or "admin" →
* onAuthed() (the gate NEVER shows); "anonymous" → the gate is
* revealed (hidden AND inert dropped), the lock root is locked
* (lockRoot.inert = true — WCAG: the locked app must not receive
* focus or keyboard traversal, the inert-pair contract) and the
* token input is focused.
* • form submit → POST /api/token-auth: 204 → the token is cached
* (localStorage, try/catch — private mode: caching is a no-op,
* the gate still works), the whoami cache is invalidated and
* re-fetched (the choice below), role "user" → the gate is hidden
* again (hidden + inert re-added), the lock root unlocks,
* onAuthed() runs, and the shared header re-boots
* (initSharedHeader — a mid-page login must not leave the
* anonymous header state: Sign in hidden, Sign out visible).
* 401 (or any failure) → the #auth-gate-error (role=alert) line
* is revealed, the input cleared and re-focused — the entered
* token never lingers in the field.
* • the whoami-cache choice: CLEAR THE MODULE CACHE (header.js's
* resetWhoami) rather than a direct re-fetch — a direct fetch
* would verify the role for the gate but leave header.js's cache
* stale (the boot fired it pre-auth, when the page was still
* anonymous), so the header re-boot and every later fetchWhoami()
* would still read anonymous. Resetting makes the NEXT
* fetchWhoami() the single fresh post-auth request for the whole
* page (gate + header + view gates all reuse it).
* • ALL localStorage access is try/catch (the fail-silence storage
* contract — private mode or a storage error degrades to
* "re-enter the token each visit", never to a broken gate).
*
* The module has NO import-time side effects: pages call mountGate at
* boot (the shell from app.js's boot IIFE, the viewer from
* document.js). On the shell the call is AWAITED before app.js's
* initSharedHeader, so a silent re-auth lands before the FIRST whoami
* fires — the header sees the post-auth role deterministically (no
* stale "Sign in" for a returning token user). No CDN, no framework.
*/
import { fetchWhoami, initSharedHeader, resetWhoami } from "./header.js";
/* The cached-token key (the owner's sentence: "cache that token in
browser storage"). The server stores only the SHA-256 digest of the
full token (app/core/tokens.py) — the plaintext lives here, in the
browser, and is re-sent verbatim to /api/token-auth (the server
hashes it for the unique-index lookup). */
const TOKEN_KEY = "bor.token";
/* ---------- the fail-silence storage contract ----------
* Every localStorage access is try/catch: private mode or a storage
* error degrades to "re-enter the token each visit" — the gate itself
* still works either way. */
const readCachedToken = () => {
try {
const t = localStorage.getItem(TOKEN_KEY);
return typeof t === "string" && t.trim() !== "" ? t : null;
} catch {
return null; // private mode / storage error — nothing cached
}
};
const storeToken = (token) => {
try {
localStorage.setItem(TOKEN_KEY, token);
} catch {
/* private mode: the gate still works, caching is a no-op */
}
};
const removeToken = () => {
try {
localStorage.removeItem(TOKEN_KEY);
} catch {
/* nothing was stored */
}
};
/* POST /api/token-auth — every failure shape (malformed / unknown /
revoked / empty) is ONE generic 401 "invalid token" server-side
(the phase-16 no-enumeration contract), so the client only needs
the status: 204 = the session cookie is (re)set. */
async function tryTokenAuth(token) {
const r = await fetch("/api/token-auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
return r.status === 204;
}
/* The in-app token gate. `lockRoot` is the element the gate locks
while visible (#main on both pages — the locked app must not
receive focus or keyboard traversal: the inert-pair contract);
`onAuthed` runs ONLY once the visitor is signed in (role "user" or
"admin") — the shell passes a no-op (the lazy views mount on first
show exactly as today), the viewer passes its existing boot
sequence (the shared header + the content load). */
export async function mountGate(lockRoot, onAuthed) {
const gate = document.querySelector(".auth-gate");
const form = gate ? gate.querySelector("form") : null;
const input = gate ? gate.querySelector('input[name="token"]') : null;
const error = gate ? gate.querySelector(".auth-gate-error") : null;
const submit = form ? form.querySelector('button[type="submit"]') : null;
const hideGate = () => {
if (gate) {
gate.hidden = true;
gate.inert = true;
}
};
const showError = () => {
if (error) error.hidden = false;
if (input) {
input.value = ""; // the entered token never lingers in the field
input.focus();
}
};
/* (1) SILENT RE-AUTH — before the whoami check. A cached token
is re-sent to /api/token-auth first: on success the session
cookie is (re)set NOW, before any whoami on this page settles —
the cache is invalidated (resetWhoami) so the role check below
is a FRESH request (a boot-fired pre-auth whoami would still
say anonymous). On ANY failure the key is DROPPED — the token
may have been revoked — and the mount falls through to the role
check (the page may already be signed in as the admin, and the
anonymous visitor simply gets the gate). */
const cached = readCachedToken();
if (cached) {
const ok = await tryTokenAuth(cached).catch(() => false);
if (ok) {
resetWhoami(); // the cached whoami (boot-fired, pre-auth) is stale
const who = await fetchWhoami();
if (who.authenticated) {
// role "user" (the re-auth landed) or "admin" (the browser
// also holds an admin session — admin wins, whoami's
// contract): unlock and run onAuthed — the gate NEVER shows
// for a cached valid token (no flash).
if (lockRoot) lockRoot.inert = false;
hideGate(); // defensive: it ships hidden — the boot never
// reveals it for an authenticated role, so this is a no-op.
onAuthed();
return;
}
// token-auth said 204 but whoami still says no (the cookie did
// not land): treat it as a dead cached token.
removeToken();
} else {
removeToken(); // a failed silent re-auth drops the key
}
}
/* (2) ROLE CHECK — header.js's fetchWhoami(): the SAME cached
promise the page's own header boot awaits (app.js on the shell,
the viewer's onAuthed on document.html) — exactly one
/api/whoami per page load. */
const who = await fetchWhoami();
if (who.authenticated) {
// role "user" or "admin" — the gate NEVER shows.
if (lockRoot) lockRoot.inert = false;
onAuthed();
return;
}
/* Anonymous: reveal the gate (drop hidden AND inert — the inert-pair
contract), lock the app, focus the token input. The gate is the
only interactive surface while visible: the lock root is inert,
so Tab never reaches the locked app (the composer on the shell,
the content on the viewer). */
if (gate) {
gate.hidden = false;
gate.inert = false;
}
if (lockRoot) lockRoot.inert = true;
if (input) input.focus();
if (!form) return; // no gate markup — nothing to bind
/* Form submit: preventDefault → POST /api/token-auth → 204: cache
the token, invalidate + re-fetch the whoami cache (the documented
choice: clear the module cache — a direct re-fetch would leave
header.js's boot-fired anonymous cache stale for the header
re-boot), role "user" → hide the gate, unlock, onAuthed(), and
re-boot the shared header for the new role. 401 / failure: the
role=alert error line, the input cleared + re-focused, the
button re-enabled (never stale, PLAN §7.4). */
form.addEventListener("submit", (e) => {
e.preventDefault();
void (async () => {
const token = (input && input.value ? input.value : "").trim();
if (!token || (submit && submit.disabled)) return;
if (error) error.hidden = true;
if (submit) submit.disabled = true; // one auth at a time
const ok = await tryTokenAuth(token).catch(() => false);
if (!ok) {
showError();
if (submit) submit.disabled = false;
return;
}
storeToken(token); // try/catch — private mode: caching is a no-op
resetWhoami(); // the boot-fired whoami says anonymous — stale
const after = await fetchWhoami(); // the single fresh post-auth request
if (!after.authenticated) {
// token-auth said 204 but whoami still says anonymous (the
// session cookie did not land): drop the cache just written
// and let the user retry. (role "admin" here — an admin
// signed in from another tab in the meantime — is
// authenticated: the gate's job is to let them through.)
removeToken();
showError();
if (submit) submit.disabled = false;
return;
}
hideGate();
if (lockRoot) lockRoot.inert = false;
onAuthed();
// A mid-page login must not leave the anonymous header state
// (Sign in visible / Sign out hidden): re-boot the shared
// header on the fresh post-auth whoami (idempotent — it just
// re-toggles the same controls).
void initSharedHeader();
})();
});
}