From fe5c8ec4ad50221f1c11495110a59563044c3986 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 28 May 2026 10:53:23 -0700 Subject: [PATCH] fix(dashboard): auto-reload SPA on stale-token 401 in loopback mode (#33861) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard's loopback auth uses an ephemeral '_SESSION_TOKEN' that rotates on every server restart (hermes update, hermes gateway restart, etc.). A tab kept open across the restart holds the OLD token in window.__HERMES_SESSION_TOKEN__ from the previous HTML render, so every '/api/*' fetch returns '401 Unauthorized' — surfacing in the UI as 'Failed to load Kanban board: 401: Unauthorized', 'Analytics 401', etc. (#24186, #25275). Before this patch the workaround was to manually clear site data or hard-reload — annoying enough that users reported it as a regression even though the token rotation is by design (security property: stolen tokens can't survive a server restart). The HTML response already sets 'Cache-Control: no-store, no-cache, must-revalidate', so a reload reliably picks up the freshly-injected token. fetchJSON now triggers that reload automatically on the first loopback-mode 401, guarded by a sessionStorage flag so a genuine auth bug (where even the new token fails) falls through to throw on the second attempt instead of reload-looping. The flag is cleared on any 2xx so a subsequent server restart in the same tab gets its own reload cycle. Gated mode is unaffected — that path already redirects to login_url via the structured 401 envelope (Phase 6), and the new code is explicitly skipped when window.__HERMES_AUTH_REQUIRED__ is set. Refs #24186, #25275 --- web/src/lib/api.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 9f001c0aa7b..3c0d9520383 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -91,6 +91,43 @@ export async function fetchJSON(url: string, init?: RequestInit): Promise // Never resolve — the page is about to unload. return new Promise(() => {}); } + // Loopback mode: ``_SESSION_TOKEN`` rotates on every server restart + // (``hermes update``, ``hermes gateway restart``, etc.). A tab kept + // open across the restart holds the OLD token in + // ``window.__HERMES_SESSION_TOKEN__`` from the previous HTML render, + // so every fetch returns 401. The HTML is served ``Cache-Control: + // no-store`` so a reload picks up the freshly-injected token. Trigger + // that reload once on the first stale-token 401 — gated mode is + // handled above, so reaching here in gated mode means a real + // middleware failure that should not reload-loop. + if (!window.__HERMES_AUTH_REQUIRED__) { + let alreadyReloaded = false; + try { + alreadyReloaded = + sessionStorage.getItem("hermes.tokenReloadAttempted") === "1"; + } catch { + /* SSR / privacy mode — fall through to throw */ + } + if (!alreadyReloaded) { + try { + sessionStorage.setItem("hermes.tokenReloadAttempted", "1"); + } catch { + /* SSR / privacy mode — best effort */ + } + window.location.reload(); + return new Promise(() => {}); + } + } + } + if (res.ok) { + // Clear the stale-token reload guard: a successful 2xx proves the + // current ``window.__HERMES_SESSION_TOKEN__`` is valid, so the next + // 401 — if any — should be allowed to trigger its own reload cycle. + try { + sessionStorage.removeItem("hermes.tokenReloadAttempted"); + } catch { + /* SSR / privacy mode — ignore */ + } } if (!res.ok) { const text = await res.text().catch(() => res.statusText);