From beffc257e11caf115d9597074ec45745cd4a574b Mon Sep 17 00:00:00 2001 From: "ashlee@vellum.ai" Date: Wed, 3 Jun 2026 03:04:57 +0000 Subject: [PATCH] fix(web): await the platform-session probe before the onboarding hosting/welcome fork The local-mode auth middleware chose between the onboarding hosting and welcome screens off a bare `hasPlatformSession`. That flag is set by a fire-and-forget platform-session probe (the local gateway auth paths return before the session is known), so when the middleware runs during the probe window it reads an ambiguous `false` and sends a returning platform user -- one with zero local assistants -- to the new-user welcome flow instead of the hosting picker. Wait for `platformSessionResolved` before forking, mirroring the `waitForAuthReady` gate already used for `isLoading` and the prechat funnel fix (#33123) that introduced the resolved flag for exactly this race. --- apps/web/src/lib/auth/auth-middleware.test.ts | 108 ++++++++++++++++++ apps/web/src/lib/auth/auth-middleware.ts | 21 ++++ 2 files changed, 129 insertions(+) create mode 100644 apps/web/src/lib/auth/auth-middleware.test.ts diff --git a/apps/web/src/lib/auth/auth-middleware.test.ts b/apps/web/src/lib/auth/auth-middleware.test.ts new file mode 100644 index 00000000000..9e9032d759c --- /dev/null +++ b/apps/web/src/lib/auth/auth-middleware.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; + +const isLocalModeMock = mock(() => true); +const hasAssistantsMock = mock(() => false); +mock.module("@/lib/local-mode", () => ({ + isLocalMode: isLocalModeMock, + hasAssistants: hasAssistantsMock, + getLocalGatewayUrl: () => undefined, +})); + +import { authMiddleware } from "./auth-middleware"; +import { useAuthStore, type AuthUser } from "@/stores/auth-store"; +import { routes } from "@/utils/routes"; + +const initialAuthState = useAuthStore.getState(); +const fakeUser = { id: "user-123" } as AuthUser; + +async function runMiddleware(pathname: string): Promise { + const args = { + request: new Request(`http://localhost${pathname}`), + context: { set: () => {}, get: () => null }, + } as unknown as Parameters[0]; + const next = (async () => new Response()) as Parameters< + typeof authMiddleware + >[1]; + // The middleware signals an unauthenticated/onboarding redirect by *throwing* + // a Response, so surface that as the resolved value for assertions. + try { + await authMiddleware(args, next); + } catch (thrown) { + if (thrown instanceof Response) return thrown; + throw thrown; + } + throw new Error("expected a redirect to be thrown"); +} + +const tick = (): Promise => new Promise((r) => setTimeout(r, 0)); + +beforeEach(() => { + isLocalModeMock.mockImplementation(() => true); + hasAssistantsMock.mockImplementation(() => false); + useAuthStore.setState(initialAuthState, true); +}); + +afterEach(() => { + useAuthStore.setState(initialAuthState, true); +}); + +describe("authMiddleware — local-mode onboarding fork", () => { + test("waits for the platform-session probe before choosing hosting vs welcome", async () => { + useAuthStore.setState({ + isLoggedIn: true, + isLoading: false, + user: fakeUser, + hasPlatformSession: false, + platformSessionResolved: false, + }); + + let settled: Response | null = null; + const pending = runMiddleware(routes.home).then((res) => { + settled = res; + }); + + // Probe still in flight: the middleware must not have decided yet, so a + // returning platform user isn't prematurely sent to the welcome flow. + await tick(); + expect(settled).toBeNull(); + + // Probe settles with a live platform session. + useAuthStore.setState({ + hasPlatformSession: true, + platformSessionResolved: true, + }); + await pending; + + expect(settled).not.toBeNull(); + expect(settled!.status).toBe(302); + expect(settled!.headers.get("Location")).toBe(routes.onboarding.hosting); + }); + + test("routes to welcome once resolved with no platform session", async () => { + useAuthStore.setState({ + isLoggedIn: true, + isLoading: false, + user: fakeUser, + hasPlatformSession: false, + platformSessionResolved: true, + }); + + const res = await runMiddleware(routes.home); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe(routes.onboarding.welcome); + }); + + test("routes to hosting when a resolved platform session exists", async () => { + useAuthStore.setState({ + isLoggedIn: true, + isLoading: false, + user: fakeUser, + hasPlatformSession: true, + platformSessionResolved: true, + }); + + const res = await runMiddleware(routes.home); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe(routes.onboarding.hosting); + }); +}); diff --git a/apps/web/src/lib/auth/auth-middleware.ts b/apps/web/src/lib/auth/auth-middleware.ts index 328464c9756..35ef0820cf6 100644 --- a/apps/web/src/lib/auth/auth-middleware.ts +++ b/apps/web/src/lib/auth/auth-middleware.ts @@ -41,6 +41,12 @@ export const authMiddleware: MiddlewareFunction = async ({ request, context }, n if (isLocalMode() && !hasAssistants()) { const url = new URL(request.url); if (!url.pathname.includes("/onboarding/") && !url.pathname.includes("/account")) { + // The hosting-vs-welcome fork keys off `hasPlatformSession`, which is set + // by a fire-and-forget probe that may still be in flight here (the local + // gateway auth paths return before the platform session is known). Reading + // it early reports an ambiguous `false` and sends a returning platform + // user to the new-user welcome flow. Wait for the probe to settle first. + await waitForPlatformSessionResolved(); const { hasPlatformSession } = useAuthStore.getState(); throw redirect(hasPlatformSession ? routes.onboarding.hosting : routes.onboarding.welcome); } @@ -64,3 +70,18 @@ function waitForAuthReady(): Promise { } }); } + +function waitForPlatformSessionResolved(): Promise { + return new Promise((resolve) => { + const unsubscribe = useAuthStore.subscribe((state) => { + if (state.platformSessionResolved) { + unsubscribe(); + resolve(); + } + }); + if (useAuthStore.getState().platformSessionResolved) { + unsubscribe(); + resolve(); + } + }); +}