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);