Files
ducoterra 412a560348 phase: 85_mobile_menu_gate_overlap
All verification complete. Final report:

**Phase 85 final verification pass — all green** (tasks 01–03 already complete; independently re-verified from scratch)
- Verified fix in tree: `styles.css` `.auth-gate` z-index 500→15 + rewritten stacking comment (no `z-index: 500` left); `index.html`/`document.html` comment-only; `token-gate.js` docstring-only (logic byte-identical); `test_mobile_hamburger_nav.py` real-click conversion + new `test_anonymous_toggle_tappable_with_gate_up`; new `tests/unit/test_gate_header_stacking.py` (3 pins); `test_api_tokens.py` untouched
- `uv run pytest` → 1717 passed, 1 warning (exit 0)
- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90%)
- E2E in isolation: `test_mobile_hamburger_nav.py` **8 passed**; `test_api_tokens.py` **9 passed** (unchanged); `test_smoke.py` **3 passed**; `test_document_viewer.py` **7 passed**
- `uv run ruff check . && uv run pyright` → clean / 0 errors
- Live probe (375×812, anonymous, fresh server): on `/` and `/history.html` `elementFromPoint` at toggle → SVG `path`, never `#auth-gate`; real click opens menu (`aria-expanded=true`); exactly one visible nav link (Chat) + sign-in copy; Chat link topmost over gate; `#main` inert — criteria 1, 2, 3 confirmed directly
- Criteria: (1) real-click menu on / + /history ✅ (2) dropdown above gate ✅ (3) anonymous contents + #main inert ✅ (4) admin byte-identical (phase-46 tests green) ✅ (5) doc-viewer gate under bar (CSS pin + doc-viewer suite) ✅ (6) full gate green ✅ (7) diff scoped to the 5 code files + new unit test + phase files, nothing in `app/` ✅ (8) commit + todo→complete move left to harness per executor rules (task files already in `complete/`)
- Note: `.agents/remediation_plan.md` (untracked) is a pre-existing artifact of the earlier security audit — left untouched; a missing "N passed" line seen mid-pass was my own `-q`+addopts `-q` = `-qq` quirk, not a defect
- Next pending phase: `86_history_page_width`
2026-09-08 01:56:37 -04:00

257 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 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();
})();
});
}