/* 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 CONTENT-level 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). The header was never the lock root (and is never inert) — it was only visually covered pre-phase-85: the gate's z-index (15) now sits below the header (20), so the mobile hamburger + menu stay reachable while the app content stays inert-locked (TODO.md L3). */ 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(); })(); }); }