diff --git a/src/services/api.test.ts b/src/services/api.test.ts index 37587fb..3bf7380 100644 --- a/src/services/api.test.ts +++ b/src/services/api.test.ts @@ -31,7 +31,7 @@ describe("api client session gate", () => { const burst = () => Promise.allSettled([ - apiClient.get("/users/me"), + apiClient.get("/containers"), apiClient.get("/deployments"), apiClient.get("/stats"), apiClient.get("/ai/status"), @@ -82,6 +82,23 @@ describe("api client session gate", () => { expect(location.href).toBe("/login"); }); + it("keeps the session when reading the current user is refused", async () => { + localStorage.setItem("auth_token", "good"); + apiClient.defaults.adapter = ((config: Parameters[0]) => + (config.url || "").startsWith("/users/me") ? unauthorized(config) : ok(config)) as AxiosAdapter; + + const [me, ...rest] = await Promise.allSettled([ + apiClient.get("/users/me"), + apiClient.get("/deployments"), + apiClient.get("/stats"), + ]); + + expect(me.status).toBe("rejected"); + expect(rest.every((r) => r.status === "fulfilled")).toBe(true); + expect(localStorage.getItem("auth_token")).toBe("good"); + expect(location.href).toBe("/"); + }); + // A page that loaded fine can still be holding a token that lapses while it is open. it("gates the next burst again after the first one has drained", async () => { localStorage.setItem("auth_token", "good-then-stale"); diff --git a/src/services/api.ts b/src/services/api.ts index 4dbe059..3664541 100755 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -32,6 +32,13 @@ export const apiClient = axios.create({ const publicPaths = ["/auth/login", "/auth/status", "/setup", "/health"]; const isPublic = (url: string) => publicPaths.some((p) => url.startsWith(p)); + +// The agent answers 401 on /users/me for an authenticated actor with no user record, so this call +// stays outside the gate until that becomes a 403: https://github.com/flatrun/agent/issues/208 +const sessionAgnosticPaths = ["/users/me"]; +const isSessionAgnostic = (url: string) => sessionAgnosticPaths.some((p) => url.startsWith(p)); + +const isUngated = (url: string) => isPublic(url) || isSessionAgnostic(url); const onAuthPage = () => window.location.pathname.includes("/login") || window.location.pathname.includes("/setup"); // A page load fans out into a dozen calls at once. Firing them all against a token the agent @@ -93,11 +100,15 @@ apiClient.interceptors.request.use(async (config) => { } config.headers.Authorization = `Bearer ${token}`; + if (sessionRejected) { + throw new axios.Cancel("session already rejected"); + } + if (isSessionAgnostic(url)) { + return config; + } + inFlight++; try { - if (sessionRejected) { - throw new axios.Cancel("session already rejected"); - } if (!gate) { gate = startGate(); return config; @@ -122,14 +133,14 @@ apiClient.interceptors.request.use(async (config) => { apiClient.interceptors.response.use( (response) => { - if (!isPublic(response.config.url || "")) { + if (!isUngated(response.config.url || "")) { settleGate(true); finish(); } return response; }, (error) => { - if (axios.isCancel(error) || isPublic(error.config?.url || "")) { + if (axios.isCancel(error) || isUngated(error.config?.url || "")) { return Promise.reject(error); }