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:
+106
-27
@@ -17,7 +17,12 @@
|
||||
* (anonymous-safe default — the phase-16 "absent, not hidden"
|
||||
* spirit), so no anonymous user ever sees one for a frame; and
|
||||
* • the sign-out click binding (POST /api/logout → reload) — moved
|
||||
* here from app.js so there is exactly one implementation;
|
||||
* here from app.js so there is exactly one implementation.
|
||||
* Phase 79 (task 05): it ALSO drops the cached token
|
||||
* (localStorage["bor.token"], try/catch — the fail-silence storage
|
||||
* contract) BEFORE the reload, so a signing-out token user meets
|
||||
* the gate again on the next load (the server session is wiped by
|
||||
* the logout; the localStorage key must go with it);
|
||||
* • the mobile hamburger binding (phase 46, owner permission
|
||||
* 2026-08-27, TODO.md L9) — at ≤640px (CSS hides the button
|
||||
* elsewhere) the #nav-toggle button opens the nav as an animated
|
||||
@@ -68,39 +73,96 @@
|
||||
* every page; a page that lacks one simply skips it.
|
||||
*
|
||||
* whoami is fetched at most ONCE per page load: the promise is cached in
|
||||
* the module-level `adminPromise`, so app.js's tuning gate, the sources
|
||||
* the module-level `whoamiPromise`, so app.js's tuning gate, the sources
|
||||
* page's catalog gate, and the header toggling all share one request.
|
||||
* Anonymous-safe: any network failure resolves to false (the anonymous
|
||||
* UI), mirroring the per-page catch the pages used before phase 19.
|
||||
* Phase 79 (task 05): the cache stores the FULL response —
|
||||
* `{ authenticated, role }` (role: "admin" | "user" | "anonymous") —
|
||||
* not just the admin flag: the token-gate module (assets/token-gate.js)
|
||||
* reuses it for its role check, and `resetWhoami()` invalidates it
|
||||
* right after a mid-page auth (a silent re-auth or an interactive
|
||||
* login) so the next fetchWhoami() is a fresh post-auth request.
|
||||
* Anonymous-safe: any network failure or non-2xx resolves to
|
||||
* `{ authenticated: false, role: "anonymous" }` (the anonymous UI),
|
||||
* mirroring the per-page catch the pages used before phase 19.
|
||||
*
|
||||
* A10/A11 untouched: no API change, no CDN, no state beyond the cached
|
||||
* promise; the soft gate page and the A10 API split are unchanged —
|
||||
* this is UI visibility only.
|
||||
*/
|
||||
|
||||
let adminPromise = null;
|
||||
/* The anonymous fallback (phase 79): non-2xx, a network failure, or a
|
||||
malformed body all resolve to the anonymous role — the UI degrades
|
||||
to the guest surface, never to an error (the phase-19 anonymous-safe
|
||||
contract, unchanged in spirit). */
|
||||
const ANONYMOUS_WHOAMI = Object.freeze({ authenticated: false, role: "anonymous" });
|
||||
|
||||
/* The SINGLE /api/whoami call site for the whole frontend. First call
|
||||
stores the promise in `adminPromise`; every later call — on this page
|
||||
— returns the same promise, i.e. exactly one request per page load.
|
||||
Anonymous-safe: non-2xx or a network failure resolves to false. */
|
||||
export function fetchIsAdmin() {
|
||||
if (!adminPromise) {
|
||||
adminPromise = fetch("/api/whoami")
|
||||
.then(async (r) => (r.ok ? (await r.json()).authenticated === true : false))
|
||||
.catch(() => false);
|
||||
let whoamiPromise = null;
|
||||
|
||||
/* The SINGLE /api/whoami call site for the whole frontend (phase 79,
|
||||
task 05: the cache now stores the FULL response — { authenticated,
|
||||
role } — not just the admin flag). First call stores the promise in
|
||||
`whoamiPromise`; every later call — on this page — returns the same
|
||||
promise, i.e. exactly one request per page load. Anonymous-safe:
|
||||
non-2xx or a network failure resolves to the anonymous role. */
|
||||
export function fetchWhoami() {
|
||||
if (!whoamiPromise) {
|
||||
whoamiPromise = fetch("/api/whoami")
|
||||
.then(async (r) => {
|
||||
if (!r.ok) return ANONYMOUS_WHOAMI;
|
||||
const data = await r.json();
|
||||
return {
|
||||
authenticated: data.authenticated === true,
|
||||
role:
|
||||
data.role === "admin" || data.role === "user"
|
||||
? data.role
|
||||
: "anonymous",
|
||||
};
|
||||
})
|
||||
.catch(() => ANONYMOUS_WHOAMI);
|
||||
}
|
||||
return adminPromise;
|
||||
return whoamiPromise;
|
||||
}
|
||||
|
||||
/* The phase-16/19 contract every existing admin gate consumes: the
|
||||
role check is `role === "admin"` — a token user (role "user") is
|
||||
authenticated but NOT an admin, so every admin-only surface keys off
|
||||
this (never off `authenticated`). SAME single request: it delegates
|
||||
to fetchWhoami(), so all existing callers keep working with zero
|
||||
changes. */
|
||||
export function fetchIsAdmin() {
|
||||
return fetchWhoami().then((w) => w.role === "admin");
|
||||
}
|
||||
|
||||
/* Phase 79 (task 05): the token gate changes the session MID-PAGE (a
|
||||
silent re-auth of a cached token, or an interactive login) — a
|
||||
whoami cached BEFORE that auth (fired at boot) is stale. The gate
|
||||
clears the cache right after a successful auth, so the NEXT
|
||||
fetchWhoami() is a fresh request carrying the post-auth role — and
|
||||
every consumer that awaits it afterwards (the header re-boot, the
|
||||
view gates) reuses that one fresh promise. */
|
||||
export function resetWhoami() {
|
||||
whoamiPromise = null;
|
||||
}
|
||||
|
||||
/* Toggle the shared header controls, only the ones present on this page
|
||||
(querySelector, null-safe — missing → no-op). Returns the admin flag
|
||||
so callers can reuse it instead of awaiting fetchIsAdmin() again (the
|
||||
so callers can reuse it instead of awaiting fetchWhoami() again (the
|
||||
cached promise makes both awaits the same single request). */
|
||||
export async function initSharedHeader() {
|
||||
const admin = await fetchIsAdmin();
|
||||
// The Sign in link: hidden for the admin, visible otherwise — and its
|
||||
// href is rewritten to return the admin to THIS page after login
|
||||
const whoami = await fetchWhoami();
|
||||
const admin = whoami.role === "admin";
|
||||
// Phase 79 (task 05): the auth PAIR keys off the authenticated role —
|
||||
// admin OR token user: both get Sign out (the binding below drops the
|
||||
// cached token too) and neither sees Sign in. The admin-ONLY surfaces
|
||||
// (the nav links, the steering refresh) still key off role ===
|
||||
// "admin": a token user gets the anonymous branch — the links stay
|
||||
// hidden and the steering panel is REMOVED from the DOM (/api/steering
|
||||
// 403s a user, so it must never be fetched). Admin and anonymous
|
||||
// behavior is byte-identical to phase 16/19.
|
||||
const signedIn = whoami.authenticated;
|
||||
// The Sign in link: hidden for any authenticated role (admin or
|
||||
// token user — phase 79), visible for anonymous — and its href is
|
||||
// rewritten to return the admin to THIS page after login
|
||||
// (phase 34 task 02: "return to where you were"). The markup keeps its
|
||||
// own static ?next= as the no-JS fallback. location.pathname is always
|
||||
// a query-safe "/…" string (never "//"; ? # and spaces stay
|
||||
@@ -118,10 +180,10 @@ export async function initSharedHeader() {
|
||||
const nextPath = window.location.pathname || "/";
|
||||
const signInNext = nextPath.startsWith("/shared/") ? "/" : nextPath;
|
||||
document.querySelectorAll(".sign-in-link").forEach(link => {
|
||||
link.hidden = admin;
|
||||
link.hidden = signedIn;
|
||||
link.href = "/login.html?next=" + signInNext;
|
||||
});
|
||||
document.querySelectorAll(".sign-out-btn").forEach(btn => { btn.hidden = !admin; });
|
||||
document.querySelectorAll(".sign-out-btn").forEach(btn => { btn.hidden = !signedIn; });
|
||||
const navSources = document.querySelector("#nav-sources");
|
||||
if (navSources) navSources.hidden = !admin;
|
||||
// Phase 35 (owner permission 2026-08-26): the Sources nav link —
|
||||
@@ -140,12 +202,20 @@ export async function initSharedHeader() {
|
||||
// the Tuning link above.
|
||||
const navHistory = document.querySelector("#nav-history");
|
||||
if (navHistory) navHistory.hidden = !admin;
|
||||
// Phase 79 (task 06): the Tokens nav link (the shell's sixth view —
|
||||
// it ships ONLY in the shell's header) — admin-only, the same
|
||||
// ship-hidden / reveal-for-admin contract as the History link above.
|
||||
// Null-safe: a page without the link (the viewer / login / shared
|
||||
// pages) is a no-op. A token user (role "user") never sees it.
|
||||
const navTokens = document.querySelector("#nav-tokens");
|
||||
if (navTokens) navTokens.hidden = !admin;
|
||||
// Phase 34: the steering panel (phase 15) is module-owned. The
|
||||
// navbar #steering-toggle was removed at owner request (2026-08-28)
|
||||
// — the panel ships hidden and is only kept fresh. Admin: refresh
|
||||
// the list (fire-and-forget). Anonymous: the panel is REMOVED from
|
||||
// the DOM entirely — the phase-16 contract says "absent", not just
|
||||
// hidden — and /api/steering is never fetched.
|
||||
// the list (fire-and-forget). Anonymous AND token user (phase 79):
|
||||
// the panel is REMOVED from the DOM entirely — the phase-16 contract
|
||||
// says "absent", not just hidden — and /api/steering is never fetched
|
||||
// (it 403s a user; only the admin's notes steer the prompt).
|
||||
if (admin) {
|
||||
if (steeringPanel) refreshSteering();
|
||||
} else {
|
||||
@@ -169,9 +239,12 @@ export function clearChatStorage() {
|
||||
import, so every page that loads header.js gets it exactly once.
|
||||
Binds to all .sign-out-btn elements (bar copy for desktop + mobile
|
||||
dropdown copy for ≤640px). Disable during the call, POST /api/logout
|
||||
(the result is ignored — the reload resets the UI either way), then
|
||||
reload so the header re-resolves to the anonymous state (Sign in
|
||||
back, Sources gone). */
|
||||
(the result is ignored — the reload resets the UI either way), drop
|
||||
the cached token (phase 79: one logout clears BOTH the server
|
||||
session and the localStorage key — a signing-out token user meets
|
||||
the gate again), then reload so the header re-resolves to the
|
||||
anonymous state (Sign in back, Sources gone, the gate back for the
|
||||
not-yet-token holder). */
|
||||
document.querySelectorAll(".sign-out-btn").forEach(btn => {
|
||||
btn.addEventListener("click", async () => {
|
||||
btn.disabled = true;
|
||||
@@ -180,6 +253,12 @@ document.querySelectorAll(".sign-out-btn").forEach(btn => {
|
||||
} catch {
|
||||
/* the reload resets the UI either way */
|
||||
}
|
||||
try {
|
||||
localStorage.removeItem("bor.token");
|
||||
} catch {
|
||||
/* private mode / storage error — the server logout already signed
|
||||
out; the next load re-gates either way */
|
||||
}
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user