diff --git a/web/src/components/connection-banner.test.tsx b/web/src/components/connection-banner.test.tsx index 3384ba2..13d9c35 100644 --- a/web/src/components/connection-banner.test.tsx +++ b/web/src/components/connection-banner.test.tsx @@ -33,12 +33,18 @@ function setOnline(value: boolean) { // hooks are re-read) — RouterProvider re-rendered with the same static route element would bail out. let rerenderBanner: () => void = () => {}; -function renderBanner(props: { bridge?: "connected" | "disconnected"; error?: boolean } = {}) { +function renderBanner( + props: { bridge?: "connected" | "disconnected"; error?: boolean; authError?: boolean } = {}, +) { function Harness() { const [, setN] = useState(0); rerenderBanner = () => setN((n) => n + 1); return ( - + ); } const router = createMemoryRouter([{ path: "/", element: }]); @@ -59,6 +65,21 @@ afterEach(() => { }); describe("ConnectionBanner — the single connection surface", () => { + it("shows the auth refusal with Reload and no connection treatment", () => { + h.trouble = true; + h.lost = true; + renderBanner({ authError: true }); + + expect(screen.getByRole("alert")).toHaveTextContent( + "Access refused. This is not a connection problem.", + ); + expect(screen.getByRole("button", { name: "Reload" })).toBeInTheDocument(); + expect(screen.queryByText("Reconnecting…")).toBeNull(); + expect(screen.queryByRole("button", { name: "Retry" })).toBeNull(); + expect(document.querySelector(".animate-spin")).toBeNull(); + expect(cfg.calls).toBe(0); + }); + it("renders nothing while healthy — no bar at all", () => { renderBanner({ bridge: "connected" }); expect(screen.queryByRole("status")).toBeNull(); diff --git a/web/src/components/connection-banner.tsx b/web/src/components/connection-banner.tsx index 0e816cb..ff17924 100644 --- a/web/src/components/connection-banner.tsx +++ b/web/src/components/connection-banner.tsx @@ -16,6 +16,8 @@ interface ConnectionBannerProps { bridge: BridgeStatus | undefined; /** The last snapshot fetch failed (stale data on screen). */ error: boolean; + /** The failed snapshot request was rejected with HTTP 401 or 403. */ + authError: boolean; } // The result of the /api/config probe (which never touches Herdr): "unknown" until it resolves, @@ -40,7 +42,49 @@ export const EXIT_MS = 200; // disagree; `connecting` is poll-truth (isConnecting) — navigator.onLine is COPY-only (it picks the // red cause), never a gate. Threshold lockstep with the shared clock is proven in use-connection-lost; // here we own the amber→red→green state machine and the smooth mount/unmount. -export function ConnectionBanner({ bridge, error }: ConnectionBannerProps) { +export function ConnectionBanner({ bridge, error, authError }: ConnectionBannerProps) { + if (authError) return ; + return ; +} + +// A refusal is not an outage, so it gets its own surface ahead of the connection state machine: no +// probe, no reconnect spinner, no escalation clock. The copy stays deliberately non-specific about +// the cause. The flag covers 401 and 403 alike, and a 403 can equally mean "this device is not +// allowlisted", "host not allowed" or "cross-origin rejected", so naming any one of them would be +// wrong more often than right. What the operator needs here is the one fact the old behaviour hid: +// this is not the network. Reload is the useful action either way, because it is what lets a +// fronting proxy serve its sign-in page. +function AuthErrorBanner() { + return ( +
+
+
+ + + Access refused. This is not a connection problem. + + +
+
+
+ ); +} + +function ConnectionStateBanner({ bridge, error }: Omit) { const stalled = useLoadingStalled(); const connecting = isConnecting({ bridge, error, stalled }); const trouble = useConnectionTrouble(connecting); diff --git a/web/src/components/update-banner.test.tsx b/web/src/components/update-banner.test.tsx index 4974d5d..a60b973 100644 --- a/web/src/components/update-banner.test.tsx +++ b/web/src/components/update-banner.test.tsx @@ -62,6 +62,7 @@ function homeData(update: UpdateInfo | undefined): HomeData { snoozedUntil: null, update, error: false, + authError: false, }; } diff --git a/web/src/components/update-check-control.test.tsx b/web/src/components/update-check-control.test.tsx index 1089060..95b5a60 100644 --- a/web/src/components/update-check-control.test.tsx +++ b/web/src/components/update-check-control.test.tsx @@ -35,6 +35,7 @@ function homeData(update: UpdateInfo | undefined): HomeData { snoozedUntil: null, update, error: false, + authError: false, }; } diff --git a/web/src/hooks/use-polling.test.ts b/web/src/hooks/use-polling.test.ts index 927d95a..1ca52e6 100644 --- a/web/src/hooks/use-polling.test.ts +++ b/web/src/hooks/use-polling.test.ts @@ -57,6 +57,7 @@ function makeData(agents: AgentView[], shellPanes: AgentView[] = []): HomeData { snoozedUntil: null, update: undefined, error: false, + authError: false, }; } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index e237e9f..94b86e1 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -26,6 +26,11 @@ class ApiError extends Error { } } +/** True when an API request failed with the given HTTP status. */ +export function isApiErrorStatus(error: unknown, status: number): boolean { + return error instanceof ApiError && error.status === status; +} + // Every request gets a deadline so a black-holed connection (phone sleep/wake, a Tailscale route // that goes dark) can't leave a fetch pending forever — which would zombify the app: the poller // gates on `revalidator.state === "idle"` and never fires again, and route navigations wait on a diff --git a/web/src/lib/loaders.test.ts b/web/src/lib/loaders.test.ts index 025d5f6..6969120 100644 --- a/web/src/lib/loaders.test.ts +++ b/web/src/lib/loaders.test.ts @@ -16,18 +16,33 @@ afterEach(() => { const failSnapshot = () => server.use(http.get("/api/snapshot", () => new HttpResponse(null, { status: 500 }))); +const rejectSnapshot = (status: 401 | 403) => + server.use(http.get("/api/snapshot", () => new HttpResponse(null, { status }))); + const failPane = () => server.use(http.get(/\/api\/pane\/[^/]+$/, () => new HttpResponse(null, { status: 500 }))); +const rejectPane = (status: 401 | 403) => + server.use(http.get(/\/api\/pane\/[^/]+$/, () => new HttpResponse(null, { status }))); + describe("rootLoader", () => { it("returns the live snapshot on success", async () => { const { rootLoader } = await import("./loaders"); const data = await rootLoader(); expect(data.error).toBe(false); + expect(data.authError).toBe(false); expect(data.bridge).toBe("connected"); expect(data.agents).toHaveLength(2); }); + it.each([401, 403] as const)("marks a %i response as an auth error", async (status) => { + rejectSnapshot(status); + const { rootLoader } = await import("./loaders"); + const data = await rootLoader(); + expect(data.error).toBe(true); + expect(data.authError).toBe(true); + }); + it("keeps the last-good herd (flagged error) when a refresh fails", async () => { const { rootLoader } = await import("./loaders"); await rootLoader(); // prime the cache with a good snapshot @@ -36,11 +51,20 @@ describe("rootLoader", () => { const stale = await rootLoader(); expect(stale.error).toBe(true); + expect(stale.authError).toBe(false); expect(stale.bridge).toBe("connected"); // from the cached snapshot expect(stale.agents).toHaveLength(2); expect(stale.agents[0]!.paneId).toBe(fixtureAgents[0]!.paneId); }); + it("does not mark a network error as an auth error", async () => { + const { rootLoader } = await import("./loaders"); + vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("network failed")); + const data = await rootLoader(); + expect(data.error).toBe(true); + expect(data.authError).toBe(false); + }); + it("returns empty + error when there is no last-good snapshot", async () => { failSnapshot(); const { rootLoader } = await import("./loaders"); @@ -59,6 +83,7 @@ describe("rootLoader", () => { vi.spyOn(globalThis, "fetch").mockRejectedValue(new DOMException("timed out", "TimeoutError")); const data = await rootLoader(); expect(data.error).toBe(true); + expect(data.authError).toBe(false); expect(data.bridge).toBeUndefined(); expect(data.agents).toEqual([]); }); @@ -91,10 +116,19 @@ describe("paneLoader", () => { const { paneLoader } = await import("./loaders"); const data = await paneLoader({ params: { paneId: "w1:p1" } }); expect(data.error).toBe(false); + expect(data.authError).toBe(false); expect(data.paneId).toBe("w1:p1"); expect(data.text).toBe("hello from the pane"); }); + it.each([401, 403] as const)("marks a %i response as an auth error", async (status) => { + rejectPane(status); + const { paneLoader } = await import("./loaders"); + const data = await paneLoader({ params: { paneId: "w1:p1" } }); + expect(data.error).toBe(true); + expect(data.authError).toBe(true); + }); + it("keeps the last-good pane text (flagged error) when a refresh fails", async () => { const { paneLoader } = await import("./loaders"); await paneLoader({ params: { paneId: "w1:p1" } }); // prime per-pane cache @@ -103,6 +137,7 @@ describe("paneLoader", () => { const stale = await paneLoader({ params: { paneId: "w1:p1" } }); expect(stale.error).toBe(true); + expect(stale.authError).toBe(false); expect(stale.text).toBe("hello from the pane"); expect(stale.paneId).toBe("w1:p1"); }); @@ -128,6 +163,7 @@ describe("paneLoader", () => { const stale = await paneLoader({ params: { paneId: "w1:p1" } }); expect(stale.error).toBe(true); + expect(stale.authError).toBe(false); expect(stale.text).toBe("hello from the pane"); expect(stale.paneId).toBe("w1:p1"); }); @@ -275,6 +311,23 @@ describe("loaders — offline navigation fast path", () => { expect(data.agents).toHaveLength(2); }); + it("keeps the last auth classification on the navigation fast path", async () => { + rejectSnapshot(401); + const { rootLoader } = await import("./loaders"); + const { latchLost } = await import("./connection-health"); + + const rejected = await rootLoader({ request: new Request("http://localhost/") }); + expect(rejected.authError).toBe(true); + latchLost(); + + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const data = await rootLoader({ request: new Request("http://localhost/space/w1") }); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(data.error).toBe(true); + expect(data.authError).toBe(true); + }); + it("a revalidation (same url) still really fetches while latched — polls keep probing", async () => { const { rootLoader } = await import("./loaders"); const { latchLost } = await import("./connection-health"); diff --git a/web/src/lib/loaders.ts b/web/src/lib/loaders.ts index 38fef17..b71b66d 100644 --- a/web/src/lib/loaders.ts +++ b/web/src/lib/loaders.ts @@ -13,7 +13,7 @@ // no flag, no race — and because a navigation aborts any in-flight revalidation, the nav is instant // even while a poll's doomed fetch is still hanging. -import { fetchPane, fetchSnapshot } from "@/lib/api"; +import { fetchPane, fetchSnapshot, isApiErrorStatus } from "@/lib/api"; import { isLostLatched } from "@/lib/connection-health"; import { SESSION_PARAM, normalizeSession } from "@/lib/session"; import type { @@ -75,6 +75,8 @@ export interface HomeData { update: UpdateInfo | undefined; /** True when this render is the last-good snapshot after a failed refresh. */ error: boolean; + /** True when the failed refresh was rejected with HTTP 401 or 403. */ + authError: boolean; } export interface PaneData { @@ -91,12 +93,32 @@ export interface PaneData { * the degraded (stale-text) path, where the guard's fresh fetch will reject a mismatch anyway. */ revision: number; error: boolean; + /** True when the failed refresh was rejected with HTTP 401 or 403. */ + authError: boolean; } // Keep-previous-data cache is now PER-SESSION: switching sessions must not show the other session's // herd flagged as stale. Keyed by session name ("" = primary). const lastSnapshot = new Map(); +// A latched navigation skips the network, so retain whether the last real outcome for each session +// was an auth rejection. Store only rejected sessions; every other real outcome removes the marker. +const authErrorSessions = new Set(); + +function rememberAuthError(session: string | undefined, authError: boolean): void { + const key = session ?? ""; + if (authError) authErrorSessions.add(key); + else authErrorSessions.delete(key); +} + +function hasAuthError(session: string | undefined): boolean { + return authErrorSessions.has(session ?? ""); +} + +function isAuthError(error: unknown): boolean { + return isApiErrorStatus(error, 401) || isApiErrorStatus(error, 403); +} + // The URL each loader last RAN for — the nav-vs-revalidate discriminator for the offline fast path (see // the header comment). Module-scoped so it survives revalidations (the loader re-runs every poll) and // resets on a full reload — same lifetime as the caches. `lastRootUrl` is enough for the root loader @@ -128,6 +150,7 @@ function toHomeData(snap: SnapshotResponse, session: string | undefined, error: snoozedUntil: snap.notifications?.snoozedUntil ?? null, update: snap.update, error, + authError: error && hasAuthError(session), }; } @@ -151,6 +174,7 @@ function staleHome(session: string | undefined): HomeData { snoozedUntil: null, update: undefined, error: true, + authError: hasAuthError(session), }; } @@ -173,9 +197,11 @@ export async function rootLoader({ request }: { request?: Request } = {}): Promi try { const snap = await fetchSnapshot(session, request?.signal); lastSnapshot.set(session ?? "", snap); + rememberAuthError(session, false); return toHomeData(snap, session, false); } catch (e) { if (isAbortError(e)) throw e; // superseded revalidation — let React Router drop it + rememberAuthError(session, isAuthError(e)); // Keep the last good herd on screen, flagged so the ConnectionBanner can say "reconnecting…". return staleHome(session); } @@ -254,6 +280,7 @@ function stalePane(paneId: string, session: string | undefined, lines: number): requestedLines: lines, revision: 0, error: true, + authError: hasAuthError(session), }; } @@ -289,9 +316,20 @@ export async function paneLoader({ const read: PaneReadResponse = await fetchPane(paneId, lines, session, request?.signal); const text = read.text || lastPaneText.get(key) || ""; rememberPaneText(key, text); - return { paneId, session, text, truncated: read.truncated, requestedLines: lines, revision: read.revision, error: false }; + rememberAuthError(session, false); + return { + paneId, + session, + text, + truncated: read.truncated, + requestedLines: lines, + revision: read.revision, + error: false, + authError: false, + }; } catch (e) { if (isAbortError(e)) throw e; // superseded revalidation — let React Router drop it + rememberAuthError(session, isAuthError(e)); // Genuine network / server failure: show stale text flagged as degraded. return stalePane(paneId, session, lines); } diff --git a/web/src/routes/detail.test.tsx b/web/src/routes/detail.test.tsx index eebd808..dfb1161 100644 --- a/web/src/routes/detail.test.tsx +++ b/web/src/routes/detail.test.tsx @@ -46,6 +46,7 @@ const connected = (agents: AgentView[], shellPanes: AgentView[] = []): HomeData snoozedUntil: null, update: undefined, error: false, + authError: false, }); function makeRouter(initialPath: string, homeLoader: () => HomeData) { @@ -68,6 +69,7 @@ function makeRouter(initialPath: string, homeLoader: () => HomeData) { requestedLines: 600, revision: 0, error: false, + authError: false, }), element: , }, diff --git a/web/src/routes/root.tsx b/web/src/routes/root.tsx index 9540d59..7ab2ded 100644 --- a/web/src/routes/root.tsx +++ b/web/src/routes/root.tsx @@ -43,7 +43,7 @@ export function RootLayout() { in amber "reconnecting…" only after ≥4s of sustained trouble (the flicker fix), escalates to a red "not connected" cause + Retry/Reload at ≥15s, and flashes green on recovery. Reads the same shared-clock signals as the header dog, so the two always agree. */} - + );