diff --git a/apps/web/package.json b/apps/web/package.json index eb37872..054c429 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -40,6 +40,7 @@ "tailwindcss": "catalog:", "typescript": "catalog:", "vite": "catalog:", + "vitest": "catalog:", "vite-tsconfig-paths": "^6.1.1" } } diff --git a/apps/web/src/components/chat/chat-composer.tsx b/apps/web/src/components/chat/chat-composer.tsx index a68544e..6c0fad8 100644 --- a/apps/web/src/components/chat/chat-composer.tsx +++ b/apps/web/src/components/chat/chat-composer.tsx @@ -11,7 +11,8 @@ import { useChatComposer } from "@/hooks/chat/use-chat-composer"; import type { ChatComposerProps } from "@/lib/chat/types"; export const ChatComposer = ({ error, onSend, status }: ChatComposerProps) => { - const busy = status === "submitted" || status === "streaming"; + const busy = + status === "connecting" || status === "submitted" || status === "streaming"; const composer = useChatComposer({ busy, onSend }); let statusContent: ReactNode = ( @@ -23,7 +24,7 @@ export const ChatComposer = ({ error, onSend, status }: ChatComposerProps) => { statusContent = ( <> - Zopu is responding + {status === "connecting" ? "Preparing Zopu" : "Zopu is responding"} ); } diff --git a/apps/web/src/hooks/chat/use-chat-agent.test.ts b/apps/web/src/hooks/chat/use-chat-agent.test.ts new file mode 100644 index 0000000..487ba70 --- /dev/null +++ b/apps/web/src/hooks/chat/use-chat-agent.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { useChatAgent } from "./use-chat-agent"; + +const mocks = vi.hoisted(() => ({ + agent: { + error: undefined, + historyReady: true, + messages: [], + sendMessage: vi.fn(() => Promise.resolve()), + status: "idle" as const, + }, + organization: {} as { error?: Error; organizationId?: string }, + useFlueAgent: vi.fn(), +})); + +vi.mock("@flue/react", () => ({ + useFlueAgent: mocks.useFlueAgent, +})); +vi.mock("@/hooks/use-personal-organization", () => ({ + usePersonalOrganization: () => mocks.organization, +})); + +describe("useChatAgent", () => { + beforeEach(() => { + mocks.organization = {}; + mocks.agent.sendMessage.mockClear(); + mocks.useFlueAgent.mockReset(); + mocks.useFlueAgent.mockReturnValue(mocks.agent); + }); + + test("does not use the agent before organization bootstrap completes", async () => { + const chat = useChatAgent(); + + expect(mocks.useFlueAgent).toHaveBeenCalledWith({ + id: undefined, + live: "sse", + name: "zopu", + }); + expect(chat.status).toBe("connecting"); + await expect(chat.sendMessage("too early")).rejects.toThrow( + "Personal organization is still being prepared" + ); + expect(mocks.agent.sendMessage).not.toHaveBeenCalled(); + }); + + test("uses the ensured organization id for the agent session", async () => { + mocks.organization = { organizationId: "org-a" }; + + const chat = useChatAgent(); + expect(mocks.useFlueAgent).toHaveBeenCalledWith({ + id: "org-a", + live: "sse", + name: "zopu", + }); + + await chat.sendMessage("ready"); + expect(mocks.agent.sendMessage).toHaveBeenCalledWith("ready"); + }); +}); diff --git a/apps/web/src/hooks/chat/use-chat-agent.ts b/apps/web/src/hooks/chat/use-chat-agent.ts index b0845fb..e90fb25 100644 --- a/apps/web/src/hooks/chat/use-chat-agent.ts +++ b/apps/web/src/hooks/chat/use-chat-agent.ts @@ -1,36 +1,38 @@ -import { api } from "@code/backend/convex/_generated/api"; import { useFlueAgent } from "@flue/react"; -import { useQuery } from "convex/react"; +import { usePersonalOrganization } from "@/hooks/use-personal-organization"; import { CHAT_AGENT } from "@/lib/chat/constants"; import type { ChatAgentState } from "@/lib/chat/types"; -import { setSendRequestId } from "@/root"; export const useChatAgent = (): ChatAgentState => { - // The global Zopu agent instance id is the user's current personal - // organization id. This is the hard tenancy boundary: the server rejects an - // instance id that is not the caller's own organization. - const organization = useQuery(api.organizations.getCurrent); + // The agent session is not created until the authenticated user's personal + // organization has been ensured and its id is available. + const organization = usePersonalOrganization(); const agent = useFlueAgent({ ...CHAT_AGENT, - id: organization?._id, + id: organization.organizationId, }); const sendMessage = async (message: string): Promise => { - // Generate a stable idempotency request id once per send, immediately - // before the message is submitted. It is reused across fetch/stream - // retries for this send and reset only on the next send, so the server - // can capture normalized evidence keyed by (organization, request id) - // without creating duplicates. - setSendRequestId(crypto.randomUUID()); + if (!organization.organizationId) { + throw ( + organization.error ?? + new Error("Personal organization is still being prepared") + ); + } await agent.sendMessage(message); }; + let { status } = agent; + if (!organization.organizationId) { + status = organization.error ? "error" : "connecting"; + } + return { - error: agent.error, + error: organization.error ?? agent.error, historyReady: agent.historyReady, messages: agent.messages, sendMessage, - status: agent.status, + status, }; }; diff --git a/apps/web/src/hooks/use-personal-organization.test.ts b/apps/web/src/hooks/use-personal-organization.test.ts new file mode 100644 index 0000000..3a1dc23 --- /dev/null +++ b/apps/web/src/hooks/use-personal-organization.test.ts @@ -0,0 +1,77 @@ +import type { EffectCallback } from "react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { usePersonalOrganization } from "./use-personal-organization"; + +const mocks = vi.hoisted(() => ({ + cleanup: undefined as (() => void) | undefined, + ensurePersonalOrganization: vi.fn(), + session: { + data: { session: { id: "session-a" } }, + } as { data: { session: { id: string } } | null }, + state: null as { + error?: Error; + organizationId?: string; + sessionId: string; + } | null, +})); + +vi.mock("@code/auth/web", () => ({ + authClient: { useSession: () => mocks.session }, +})); +vi.mock("@code/backend/convex/_generated/api", () => ({ + api: { organizations: { ensurePersonalOrganization: "ensure-org" } }, +})); +vi.mock("convex/react", () => ({ + useMutation: () => mocks.ensurePersonalOrganization, +})); +vi.mock("react", () => ({ + useEffect: (effect: EffectCallback) => { + mocks.cleanup?.(); + const cleanup = effect(); + mocks.cleanup = typeof cleanup === "function" ? cleanup : undefined; + }, + useState: () => [ + mocks.state, + (next: { error?: Error; organizationId?: string; sessionId: string }) => { + mocks.state = next; + }, + ], +})); + +describe("usePersonalOrganization", () => { + beforeEach(() => { + mocks.cleanup?.(); + mocks.cleanup = undefined; + mocks.ensurePersonalOrganization.mockReset(); + mocks.session = { data: { session: { id: "session-a" } } }; + mocks.state = null; + }); + + test("an authenticated session ensures its org before exposing the id", async () => { + const ensured = Promise.withResolvers<{ _id: string }>(); + mocks.ensurePersonalOrganization.mockReturnValue(ensured.promise); + + expect(usePersonalOrganization()).toEqual({}); + expect(mocks.ensurePersonalOrganization).toHaveBeenCalledTimes(1); + + ensured.resolve({ _id: "org-a" }); + await ensured.promise; + await Promise.resolve(); + + expect(usePersonalOrganization()).toEqual({ + error: undefined, + organizationId: "org-a", + }); + }); + + test("logout cannot expose the prior session organization", async () => { + mocks.ensurePersonalOrganization.mockResolvedValue({ _id: "org-a" }); + usePersonalOrganization(); + await Promise.resolve(); + await Promise.resolve(); + + mocks.session = { data: null }; + expect(usePersonalOrganization()).toEqual({}); + }); +}); diff --git a/apps/web/src/hooks/use-personal-organization.ts b/apps/web/src/hooks/use-personal-organization.ts new file mode 100644 index 0000000..67bd811 --- /dev/null +++ b/apps/web/src/hooks/use-personal-organization.ts @@ -0,0 +1,70 @@ +import { authClient } from "@code/auth/web"; +import { api } from "@code/backend/convex/_generated/api"; +import type { Id } from "@code/backend/convex/_generated/dataModel"; +import { useMutation } from "convex/react"; +import { useEffect, useState } from "react"; + +interface OrganizationBootstrapState { + readonly error?: Error; + readonly organizationId?: Id<"organizations">; + readonly sessionId: string; +} + +export interface PersonalOrganizationState { + readonly error?: Error; + readonly organizationId?: Id<"organizations">; +} + +/** Ensure the authenticated session has its personal tenancy boundary. */ +export const usePersonalOrganization = (): PersonalOrganizationState => { + const { data: session } = authClient.useSession(); + const sessionId = session?.session.id; + const ensurePersonalOrganization = useMutation( + api.organizations.ensurePersonalOrganization + ); + const [bootstrap, setBootstrap] = useState( + null + ); + + useEffect(() => { + if (!sessionId) { + return; + } + + let active = true; + const bootstrapOrganization = async (): Promise => { + try { + const organization = await ensurePersonalOrganization(); + if (active) { + setBootstrap({ + organizationId: organization._id, + sessionId, + }); + } + } catch (caughtError: unknown) { + if (active) { + setBootstrap({ + error: + caughtError instanceof Error + ? caughtError + : new Error(String(caughtError)), + sessionId, + }); + } + } + }; + void bootstrapOrganization(); + + return () => { + active = false; + }; + }, [ensurePersonalOrganization, sessionId]); + + if (!sessionId || bootstrap?.sessionId !== sessionId) { + return {}; + } + return { + error: bootstrap.error, + organizationId: bootstrap.organizationId, + }; +}; diff --git a/apps/web/src/hooks/use-project-workspace.ts b/apps/web/src/hooks/use-project-workspace.ts index 1a47016..15ad87c 100644 --- a/apps/web/src/hooks/use-project-workspace.ts +++ b/apps/web/src/hooks/use-project-workspace.ts @@ -1,14 +1,14 @@ import { api } from "@code/backend/convex/_generated/api"; import type { Id } from "@code/backend/convex/_generated/dataModel"; +import { useFlueClient } from "@flue/react"; import { useAction, useMutation, useQuery } from "convex/react"; import { useState } from "react"; -import { flueClient } from "@/root"; - const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error); export const useProjectWorkspace = () => { + const flueClient = useFlueClient(); const projects = useQuery(api.projects.list); const [selectedProjectId, setSelectedProjectId] = useState | null>(null); diff --git a/apps/web/src/lib/flue-transport.test.ts b/apps/web/src/lib/flue-transport.test.ts new file mode 100644 index 0000000..93b758f --- /dev/null +++ b/apps/web/src/lib/flue-transport.test.ts @@ -0,0 +1,115 @@ +import { createFlueClient } from "@flue/sdk"; +import { describe, expect, test } from "vitest"; + +import { createFlueFetch } from "./flue-transport"; + +const BASE_URL = new URL("https://flue.example/api/"); + +describe("createFlueFetch", () => { + test("overlapping SDK agent sends keep distinct request IDs", async () => { + const firstResponse = Promise.withResolvers(); + const secondResponse = Promise.withResolvers(); + const bothStarted = Promise.withResolvers(); + const capturedHeaders: Headers[] = []; + let requestCount = 0; + let requestId = 0; + + const fetchImpl: typeof fetch = (input, init) => { + capturedHeaders.push(new Headers(init?.headers)); + requestCount += 1; + if (requestCount === 2) { + bothStarted.resolve(true); + } + if (requestCount === 1) { + return firstResponse.promise; + } + if (requestCount === 2) { + return secondResponse.promise; + } + throw new Error(`Unexpected request: ${String(input)}`); + }; + const client = createFlueClient({ + baseUrl: BASE_URL.toString(), + fetch: createFlueFetch({ + baseUrl: BASE_URL, + fetchImpl, + generateRequestId: () => `request-${(requestId += 1)}`, + }), + headers: { authorization: "Bearer current-jwt" }, + }); + + const firstSend = client.agents.send("zopu", "org-a", { + message: "first", + }); + const secondSend = client.agents.send("zopu", "org-a", { + message: "second", + }); + await bothStarted.promise; + + expect(capturedHeaders).toHaveLength(2); + expect(capturedHeaders[0]?.get("x-zopu-request-id")).toBe("request-1"); + expect(capturedHeaders[1]?.get("x-zopu-request-id")).toBe("request-2"); + expect(capturedHeaders[0]?.get("authorization")).toBe("Bearer current-jwt"); + expect(capturedHeaders[1]?.get("authorization")).toBe("Bearer current-jwt"); + + firstResponse.resolve( + Response.json( + { + offset: "1", + streamUrl: "http://internal/streams/first", + submissionId: "submission-1", + }, + { status: 202 } + ) + ); + secondResponse.resolve( + Response.json( + { + offset: "2", + streamUrl: "http://internal/streams/second", + submissionId: "submission-2", + }, + { status: 202 } + ) + ); + + await expect(Promise.all([firstSend, secondSend])).resolves.toEqual([ + { + offset: "1", + streamUrl: "https://flue.example/streams/first", + submissionId: "submission-1", + }, + { + offset: "2", + streamUrl: "https://flue.example/streams/second", + submissionId: "submission-2", + }, + ]); + }); + + test("history and stream observation requests remain untagged", async () => { + const capturedHeaders: Headers[] = []; + const fetchImpl: typeof fetch = (_input, init) => { + capturedHeaders.push(new Headers(init?.headers)); + return Promise.resolve(new Response(null, { status: 204 })); + }; + const flueFetch = createFlueFetch({ + baseUrl: BASE_URL, + fetchImpl, + generateRequestId: () => "must-not-be-used", + }); + + await flueFetch("https://flue.example/api/agents/zopu/org-a?view=history", { + headers: { authorization: "Bearer current-jwt" }, + method: "GET", + }); + await flueFetch("https://flue.example/api/agents/zopu/org-a?view=updates", { + headers: { authorization: "Bearer current-jwt" }, + method: "GET", + }); + + expect(capturedHeaders).toHaveLength(2); + expect(capturedHeaders[0]?.has("x-zopu-request-id")).toBe(false); + expect(capturedHeaders[1]?.has("x-zopu-request-id")).toBe(false); + }); +}); diff --git a/apps/web/src/lib/flue-transport.ts b/apps/web/src/lib/flue-transport.ts new file mode 100644 index 0000000..ac838a1 --- /dev/null +++ b/apps/web/src/lib/flue-transport.ts @@ -0,0 +1,82 @@ +interface FlueFetchOptions { + readonly baseUrl: URL; + readonly fetchImpl?: typeof fetch; + readonly generateRequestId?: () => string; +} + +/** + * Build the Flue transport used by the browser client. + * + * The installed Flue SDK does not expose per-call headers on `agents.send`. + * Its custom `fetch` seam does receive the resolved URL and method, so request + * IDs are attached here to each agent admission POST. History and Durable + * Streams observation requests are GETs and intentionally remain untagged. + */ +export const createFlueFetch = ({ + baseUrl, + fetchImpl = fetch, + generateRequestId = crypto.randomUUID, +}: FlueFetchOptions): typeof fetch => { + const basePath = baseUrl.pathname.replace(/\/+$/u, ""); + + return async (input, init) => { + const inputUrl = + typeof input === "string" || input instanceof URL ? input : input.url; + const requestUrl = new URL(inputUrl, baseUrl); + const method = ( + init?.method ?? (input instanceof Request ? input.method : "GET") + ).toUpperCase(); + const relativePath = requestUrl.pathname.startsWith(`${basePath}/`) + ? requestUrl.pathname.slice(basePath.length) + : requestUrl.pathname; + const pathSegments = relativePath.split("/").filter(Boolean); + const isAgentAdmission = + method === "POST" && + pathSegments.length === 3 && + pathSegments[0] === "agents"; + + let requestInit = init; + if (isAgentAdmission) { + const headers = new Headers( + input instanceof Request ? input.headers : undefined + ); + for (const [key, value] of new Headers(init?.headers).entries()) { + headers.set(key, value); + } + headers.set("x-zopu-request-id", generateRequestId()); + requestInit = { ...init, headers }; + } + + const response = await fetchImpl(input, requestInit); + if ( + !response.ok || + !response.headers.get("content-type")?.includes("application/json") + ) { + return response; + } + + const body = (await response.clone().json()) as unknown; + if ( + typeof body !== "object" || + body === null || + !("streamUrl" in body) || + typeof body.streamUrl !== "string" + ) { + return response; + } + + const streamUrl = new URL(body.streamUrl); + streamUrl.protocol = baseUrl.protocol; + streamUrl.host = baseUrl.host; + const headers = new Headers(response.headers); + headers.set("location", streamUrl.toString()); + return Response.json( + { ...body, streamUrl: streamUrl.toString() }, + { + headers, + status: response.status, + statusText: response.statusText, + } + ); + }; +}; diff --git a/apps/web/src/root.tsx b/apps/web/src/root.tsx index 3040c5b..eb98fe3 100644 --- a/apps/web/src/root.tsx +++ b/apps/web/src/root.tsx @@ -1,10 +1,11 @@ -import { authClient, WebAuthProvider } from "@code/auth/web"; +import { useConvexAccessToken, WebAuthProvider } from "@code/auth/web"; import { env } from "@code/env/web"; import { Toaster } from "@code/ui/components/sonner"; import { FlueProvider } from "@flue/react"; import "./index.css"; import { createFlueClient } from "@flue/sdk"; +import { useMemo } from "react"; import { isRouteErrorResponse, Links, @@ -16,6 +17,7 @@ import { import type { Route } from "./+types/root"; import { ThemeProvider } from "./components/theme-provider"; +import { createFlueFetch } from "./lib/flue-transport"; export const links: Route.LinksFunction = () => [ { href: "https://fonts.googleapis.com", rel: "preconnect" }, @@ -31,87 +33,33 @@ export const links: Route.LinksFunction = () => [ ]; const flueBaseUrl = new URL(env.VITE_FLUE_URL); +const flueFetch = createFlueFetch({ baseUrl: flueBaseUrl }); -// --------------------------------------------------------------------------- -// Authenticated Flue request headers. -// -// The access token is the Better Auth/Convex JWT, resolved once per session -// through the installed `convex.token` plugin endpoint and cached. The stable -// per-send request id is generated by the chat hook immediately before a user -// sends a message and is reused across fetch/stream retries for that send, so -// it is a reliable idempotency key for server-side evidence capture. It is -// reset only when a new message is sent. -// --------------------------------------------------------------------------- - -let cachedAccessToken: string | null = null; -let currentSendRequestId: string | null = null; - -/** Set the idempotency request id for the next send. Called by the chat hook. */ -export const setSendRequestId = (id: string): void => { - currentSendRequestId = id; -}; - -const resolveAccessToken = async (): Promise => { - if (cachedAccessToken) { - return cachedAccessToken; - } - const { data } = await authClient.convex.token({ - fetchOptions: { throw: false }, - }); - cachedAccessToken = data?.token ?? null; - return cachedAccessToken; -}; - -const resolveFlueHeaders = async (): Promise> => { - const headers: Record = {}; - const accessToken = await resolveAccessToken(); - if (accessToken) { - headers.authorization = `Bearer ${accessToken}`; - } - if (currentSendRequestId) { - headers["x-zopu-request-id"] = currentSendRequestId; - } - return headers; -}; - -const flueFetch: typeof fetch = async (input, init) => { - const response = await fetch(input, init); - if ( - !response.ok || - !response.headers.get("content-type")?.includes("application/json") - ) { - return response; - } - - const body = (await response.clone().json()) as unknown; - if ( - typeof body !== "object" || - body === null || - !("streamUrl" in body) || - typeof body.streamUrl !== "string" - ) { - return response; - } - - const streamUrl = new URL(body.streamUrl); - streamUrl.protocol = flueBaseUrl.protocol; - streamUrl.host = flueBaseUrl.host; - const headers = new Headers(response.headers); - headers.set("location", streamUrl.toString()); - return Response.json( - { ...body, streamUrl: streamUrl.toString() }, - { - headers, - status: response.status, - statusText: response.statusText, - } +const AuthenticatedFlueProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const resolveAccessToken = useConvexAccessToken(); + const client = useMemo( + () => + createFlueClient({ + baseUrl: flueBaseUrl.toString(), + fetch: flueFetch, + headers: async () => { + const accessToken = await resolveAccessToken(); + const headers: Record = {}; + if (accessToken) { + headers.authorization = `Bearer ${accessToken}`; + } + return headers; + }, + }), + [resolveAccessToken] ); + + return {children}; }; -export const flueClient = createFlueClient({ - baseUrl: flueBaseUrl.toString(), - fetch: flueFetch, - headers: resolveFlueHeaders, -}); export const Layout = ({ children }: { children: React.ReactNode }) => ( @@ -129,8 +77,8 @@ export const Layout = ({ children }: { children: React.ReactNode }) => ( ); const App = () => ( - - + + ( - - + + ); export default App; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 41cab95..b4317ca 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -1,7 +1,7 @@ { "include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"], "compilerOptions": { - "lib": ["DOM", "DOM.Iterable", "ES2022"], + "lib": ["DOM", "DOM.Iterable", "ES2024"], "types": ["node", "vite/client"], "target": "ES2022", "module": "ES2022", diff --git a/bun.lock b/bun.lock index 50e06ab..e142821 100644 --- a/bun.lock +++ b/bun.lock @@ -140,6 +140,7 @@ "typescript": "catalog:", "vite": "catalog:", "vite-tsconfig-paths": "^6.1.1", + "vitest": "catalog:", }, }, "packages/agents": { @@ -186,6 +187,7 @@ "@types/react": "~19.2.17", "typescript": "catalog:", "vite": "catalog:", + "vitest": "catalog:", }, }, "packages/backend": { diff --git a/packages/auth/package.json b/packages/auth/package.json index 1401c6c..ce61425 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -32,6 +32,7 @@ "@code/config": "workspace:*", "@types/react": "~19.2.17", "vite": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/auth/src/web/hooks.ts b/packages/auth/src/web/hooks.ts index 2ee230b..692fac7 100644 --- a/packages/auth/src/web/hooks.ts +++ b/packages/auth/src/web/hooks.ts @@ -1,9 +1,10 @@ import { useForm } from "@tanstack/react-form"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useRef } from "react"; import { toast } from "sonner"; import { signInSchema, signUpSchema } from "../shared/forms"; import { authClient } from "./auth-client"; +import { createSessionTokenResolver } from "./session-token-resolver"; interface AuthFormOptions { readonly onSuccess?: () => void; @@ -56,41 +57,27 @@ export const useSignUpForm = ({ onSuccess }: AuthFormOptions = {}) => }); /** - * Resolve the current Better Auth/Convex JWT access token for the signed-in - * session. Uses the installed `convex.token` plugin endpoint (the supported - * API) and caches the result so repeated fetches within a session reuse it. - * Returns null when the user is not authenticated. + * Resolve the Better Auth/Convex JWT for the current signed-in session. The + * installed `convex.token` plugin endpoint remains the token source; changing + * or clearing the Better Auth session invalidates the cached value first. */ export const useConvexAccessToken = (): (() => Promise) => { - const cachedTokenRef = useRef(null); - const pendingRef = useRef | null>(null); - const [, forceRender] = useState(0); + const { data: session } = authClient.useSession(); + const sessionId = session?.session.id ?? null; + const resolverRef = useRef | null>(null); + resolverRef.current ??= createSessionTokenResolver(); + const resolveForSession = resolverRef.current; - return useCallback(async (): Promise => { - if (cachedTokenRef.current) { - return cachedTokenRef.current; - } - if (pendingRef.current) { - return pendingRef.current; - } - pendingRef.current = (async (): Promise => { - try { + return useCallback( + () => + resolveForSession(sessionId, async () => { const { data } = await authClient.convex.token({ fetchOptions: { throw: false }, }); - const token = data?.token ?? null; - cachedTokenRef.current = token; - if (!token) { - forceRender((n) => n + 1); - } - return token; - } catch { - cachedTokenRef.current = null; - return null; - } finally { - pendingRef.current = null; - } - })(); - return await pendingRef.current; - }, []); + return data?.token ?? null; + }), + [resolveForSession, sessionId] + ); }; diff --git a/packages/auth/src/web/session-token-resolver.test.ts b/packages/auth/src/web/session-token-resolver.test.ts new file mode 100644 index 0000000..b41dd6a --- /dev/null +++ b/packages/auth/src/web/session-token-resolver.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test, vi } from "vitest"; + +import { createSessionTokenResolver } from "./session-token-resolver"; + +describe("createSessionTokenResolver", () => { + test("logout clears the cached JWT before another session resolves", async () => { + const resolveToken = createSessionTokenResolver(); + const loadFirstToken = vi.fn(() => Promise.resolve("jwt-for-user-a")); + + await expect(resolveToken("session-a", loadFirstToken)).resolves.toBe( + "jwt-for-user-a" + ); + await expect( + resolveToken("session-a", () => Promise.resolve("unexpected-token")) + ).resolves.toBe("jwt-for-user-a"); + expect(loadFirstToken).toHaveBeenCalledTimes(1); + + const loadWhileLoggedOut = vi.fn(() => Promise.resolve("stale-jwt")); + await expect(resolveToken(null, loadWhileLoggedOut)).resolves.toBeNull(); + expect(loadWhileLoggedOut).not.toHaveBeenCalled(); + + const loadSecondToken = vi.fn(() => Promise.resolve("jwt-for-user-b")); + await expect(resolveToken("session-b", loadSecondToken)).resolves.toBe( + "jwt-for-user-b" + ); + expect(loadSecondToken).toHaveBeenCalledTimes(1); + }); + + test("a direct user switch never returns the previous session token", async () => { + const resolveToken = createSessionTokenResolver(); + + await expect( + resolveToken("session-a", () => Promise.resolve("jwt-for-user-a")) + ).resolves.toBe("jwt-for-user-a"); + await expect( + resolveToken("session-b", () => Promise.resolve("jwt-for-user-b")) + ).resolves.toBe("jwt-for-user-b"); + }); +}); diff --git a/packages/auth/src/web/session-token-resolver.ts b/packages/auth/src/web/session-token-resolver.ts new file mode 100644 index 0000000..1fc1122 --- /dev/null +++ b/packages/auth/src/web/session-token-resolver.ts @@ -0,0 +1,59 @@ +type TokenLoader = () => Promise; + +/** Cache one Better Auth/Convex JWT per concrete session. */ +export const createSessionTokenResolver = () => { + let cachedSessionId: string | null = null; + let cachedToken: string | null = null; + let pending: { + readonly sessionId: string; + readonly promise: Promise; + } | null = null; + + return async ( + sessionId: string | null, + loadToken: TokenLoader + ): Promise => { + if (!sessionId) { + cachedSessionId = null; + cachedToken = null; + pending = null; + return null; + } + + if (cachedSessionId !== sessionId) { + cachedSessionId = sessionId; + cachedToken = null; + pending = null; + } + if (cachedToken) { + return cachedToken; + } + if (pending?.sessionId === sessionId) { + return pending.promise; + } + + const requestedSessionId = sessionId; + const promise = (async (): Promise => { + try { + const token = await loadToken(); + if (cachedSessionId === requestedSessionId) { + cachedToken = token; + } + return token; + } catch { + if (cachedSessionId === requestedSessionId) { + cachedToken = null; + } + return null; + } + })(); + pending = { promise, sessionId: requestedSessionId }; + try { + return await promise; + } finally { + if (pending?.promise === promise) { + pending = null; + } + } + }; +};