From a5c1f925b59a5a3588033aa823936ae87f55072a Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Thu, 28 May 2026 16:58:42 -0400 Subject: [PATCH] fix(web): stop /api/auth/me 401 from triggering a reload loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In loopback mode the dashboard's identity probe (/api/auth/me) returns 401 by design — AuthWidget swallows it and renders nothing. But the probe routed through fetchJSON, whose loopback 401 handler treats a 401 as a rotated session token and full-page-reloads to pick up a fresh one. That reload is guarded by a one-shot sessionStorage flag which every *successful* request clears, so with auth/me reliably 401ing and the other dashboard calls (status/config/sessions) reliably succeeding, the guard never sticks and the page reload-loops indefinitely (the "boot flash"). Add an allowUnauthorized option to fetchJSON that skips only the loopback stale-token reload (the 401 still throws so AuthWidget can catch it, and the gated-mode login_url envelope redirect is unaffected), and use it for getAuthMe. Co-authored-by: Cursor --- web/src/lib/api.ts | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 3c0d9520383..f475201d148 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -41,7 +41,11 @@ function setSessionHeader(headers: Headers, token: string): void { } } -export async function fetchJSON(url: string, init?: RequestInit): Promise { +export async function fetchJSON( + url: string, + init?: RequestInit, + options?: FetchJSONOptions, +): Promise { // Inject the session token into all /api/ requests. const headers = new Headers(init?.headers); const token = window.__HERMES_SESSION_TOKEN__; @@ -100,7 +104,7 @@ export async function fetchJSON(url: string, init?: RequestInit): Promise // 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__) { + if (!window.__HERMES_AUTH_REQUIRED__ && !options?.allowUnauthorized) { let alreadyReloaded = false; try { alreadyReloaded = @@ -198,8 +202,19 @@ export const api = { * still exists but is never useful there (no Session, no cookie). The * AuthWidget component swallows 401s from this call: if the gate isn't * engaged, /api/auth/me returns 401 and the widget renders nothing. + * + * ``allowUnauthorized`` is load-bearing: in loopback mode this endpoint + * 401s by design, and fetchJSON's default loopback behaviour treats a + * 401 as a rotated session token and full-page-reloads to pick up a + * fresh one. Because every *other* dashboard request succeeds (and so + * clears the one-shot reload guard), that turns this expected 401 into + * an infinite reload loop. Opting out keeps the 401 a plain throw the + * widget can catch. */ - getAuthMe: () => fetchJSON("/api/auth/me"), + getAuthMe: () => + fetchJSON("/api/auth/me", undefined, { + allowUnauthorized: true, + }), logout: () => fetch(`${BASE}/auth/logout`, { method: "POST", @@ -514,6 +529,15 @@ export interface ActionResponse { pid: number; } +/** Per-call overrides for {@link fetchJSON}. */ +interface FetchJSONOptions { + /** When true, a 401 response is surfaced as a normal thrown error rather + * than triggering the loopback stale-token page reload. Use for probes + * whose 401 is an expected signal (e.g. /api/auth/me in non-gated mode) + * rather than evidence of a rotated session token. */ + allowUnauthorized?: boolean; +} + export interface ActionStatusResponse { exit_code: number | null; lines: string[];