diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80600ae5..1ac19827 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,10 @@ jobs: run: bun install --frozen-lockfile - name: Run TypeScript type checking - run: bunx turbo run check-types --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph' + run: bunx turbo run check-types --filter='@repo/lib' --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph' + + - name: Run shared library tests + run: bun test packages/lib - name: Run Biome CI (format & lint on changed files) run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched diff --git a/apps/web/app/oauth/consent/page.tsx b/apps/web/app/oauth/consent/page.tsx index c12887ca..321762a7 100644 --- a/apps/web/app/oauth/consent/page.tsx +++ b/apps/web/app/oauth/consent/page.tsx @@ -1,8 +1,17 @@ "use client" -import { authClient, useSession } from "@lib/auth" +import { authClient } from "@lib/auth" +import { useAuth } from "@lib/auth-context" +import { createAuthSessionScope } from "@lib/scoped-auth-state" import { useSearchParams } from "next/navigation" -import { Suspense, useCallback, useMemo, useState } from "react" +import { + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react" import { CardShell, ConsentCard, @@ -16,20 +25,28 @@ const API_URL = function OAuthConsentContent() { const params = useSearchParams() - const { data: session } = useSession() - const { data: organizations } = authClient.useListOrganizations() + const { organizations, user } = useAuth() const [submitting, setSubmitting] = useState<"approve" | "deny" | null>(null) const [done, setDone] = useState<"approved" | "denied" | null>(null) const [error, setError] = useState(null) const [availableTags, setAvailableTags] = useState([]) const [tagsLoading, setTagsLoading] = useState(false) + const [tagsLoaded, setTagsLoaded] = useState(false) + const tagsRequestRef = useRef(null) + const submitRequestRef = useRef(null) + + useEffect(() => { + return () => { + tagsRequestRef.current?.abort() + submitRequestRef.current?.abort() + } + }, []) const orgs = useMemo( () => (organizations ?? []).map((o) => ({ id: o.id, name: o.name })), [organizations], ) - const activeOrgId = session?.session.activeOrganizationId ?? null const clientId = params.get("client_id") ?? "" const plugin = clientId ? (OAUTH_PLUGINS[clientId] ?? null) : null const appLabel = plugin?.name ?? "An application" @@ -43,25 +60,46 @@ function OAuthConsentContent() { const onEnterOrg = useCallback( async (orgId: string) => { setError(null) - if (orgId !== activeOrgId) { - try { - await authClient.organization.setActive({ organizationId: orgId }) - } catch (err) { - setError("Couldn't switch to that organization. Try again.") - throw err - } - } + tagsRequestRef.current?.abort() + tagsRequestRef.current = null + setTagsLoading(false) + setTagsLoaded(false) setAvailableTags([]) + try { + const result = await authClient.organization.setActive({ + organizationId: orgId, + }) + if (result.error || result.data?.id !== orgId) { + throw new Error( + result.error?.message ?? "Organization switch failed", + ) + } + } catch (err) { + setError("Couldn't switch to that organization. Try again.") + throw err + } }, - [activeOrgId], + [], ) const onScopedOpen = useCallback(() => { - if (tagsLoading || availableTags.length > 0) return + if (tagsLoading || tagsLoaded) return + const controller = new AbortController() + tagsRequestRef.current?.abort() + tagsRequestRef.current = controller setTagsLoading(true) - fetch(`${API_URL}/v3/container-tags/list`, { credentials: "include" }) + fetch(`${API_URL}/v3/container-tags/list`, { + credentials: "include", + signal: controller.signal, + }) .then((r) => (r.ok ? r.json() : null)) .then((d) => { + if ( + tagsRequestRef.current !== controller || + controller.signal.aborted + ) { + return + } const list = (d?.containerTags ?? d?.tags ?? d ?? []) as unknown[] const names = (Array.isArray(list) ? list : []) .map((t) => @@ -75,8 +113,15 @@ function OAuthConsentContent() { setAvailableTags(Array.from(new Set(names))) }) .catch(() => {}) - .finally(() => setTagsLoading(false)) - }, [tagsLoading, availableTags.length]) + .finally(() => { + if (tagsRequestRef.current !== controller) return + tagsRequestRef.current = null + if (!controller.signal.aborted) { + setTagsLoading(false) + setTagsLoaded(true) + } + }) + }, [tagsLoading, tagsLoaded]) const onSubmit = useCallback( async (accept: boolean, scope: ConsentScope) => { @@ -89,6 +134,9 @@ function OAuthConsentContent() { ) return } + const controller = new AbortController() + submitRequestRef.current?.abort() + submitRequestRef.current = controller setSubmitting(accept ? "approve" : "deny") setError(null) try { @@ -106,7 +154,9 @@ function OAuthConsentContent() { containerTags: scope.scopeType === "scoped" ? scope.tags : [], expiresDays: scope.expiresDays, }), + signal: controller.signal, }) + if (controller.signal.aborted) return if (!scopeRes.ok) { const scopeData = (await scopeRes.json().catch(() => ({}))) as { error?: string @@ -127,7 +177,9 @@ function OAuthConsentContent() { Accept: "application/json", }, body: JSON.stringify({ accept, oauth_query: oauthQuery }), + signal: controller.signal, }) + if (controller.signal.aborted) return const data = (await res.json().catch(() => ({}))) as { url?: string redirectURI?: string @@ -152,6 +204,7 @@ function OAuthConsentContent() { "Authorization failed.", ) } + if (controller.signal.aborted) return // Many clients use a loopback or custom-scheme redirect URI that hands // off without replacing this tab, but the server still has to provide it. const redirectUrl = data.url ?? data.redirectURI ?? data.redirect_uri @@ -180,9 +233,14 @@ function OAuthConsentContent() { setDone(accept ? "approved" : "denied") if (redirectUrl) window.location.href = redirectUrl } catch (err) { + if (controller.signal.aborted) return console.error("OAuth consent failed:", err) setError(err instanceof Error ? err.message : "Authorization failed.") setSubmitting(null) + } finally { + if (submitRequestRef.current === controller) { + submitRequestRef.current = null + } } }, [clientId], @@ -230,13 +288,20 @@ function OAuthConsentContent() { orgs={orgs} submitting={submitting} tagsLoading={tagsLoading} - userEmail={session?.user?.email} + userEmail={user?.email} verified={!!plugin} /> ) } export default function OAuthConsentPage() { + const { isSessionPending, session, user } = useAuth() + const sessionScope = createAuthSessionScope({ + isPending: isSessionPending, + sessionId: session?.id, + userId: user?.id, + }) + return ( } > - + ) } diff --git a/bun.lock b/bun.lock index 89abf2e7..a101331d 100644 --- a/bun.lock +++ b/bun.lock @@ -309,6 +309,9 @@ "tailwind-merge": "^3.3.1", "zod": "^3.25.76", }, + "devDependencies": { + "happy-dom": "^20.9.0", + }, }, "packages/memory-graph": { "name": "@supermemory/memory-graph", diff --git a/packages/lib/auth-context.test.tsx b/packages/lib/auth-context.test.tsx new file mode 100644 index 00000000..a8b63ab4 --- /dev/null +++ b/packages/lib/auth-context.test.tsx @@ -0,0 +1,444 @@ +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + it, + mock, +} from "bun:test" +import { Window } from "happy-dom" + +type TestOrganization = { + id: string + name: string + slug: string + createdAt: Date +} + +type TestSession = { + session: { + id: string + activeOrganizationId: string | null + } + user: { + id: string + } +} + +type OrganizationListResult = { + data: TestOrganization[] + error: null +} + +const browserWindow = new Window({ url: "https://app.supermemory.ai" }) +const installedGlobals = [ + "window", + "document", + "navigator", + "Node", + "HTMLElement", + "Event", + "MutationObserver", + "localStorage", +] as const +const originalDescriptors = new Map( + installedGlobals.map((name) => [ + name, + Object.getOwnPropertyDescriptor(globalThis, name), + ]), +) +const originalActEnvironmentDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + "IS_REACT_ACT_ENVIRONMENT", +) + +for (const name of installedGlobals) { + Object.defineProperty(globalThis, name, { + configurable: true, + value: browserWindow[name], + }) +} +Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { + configurable: true, + value: true, +}) + +let currentSession: TestSession | null = null +let isSessionPending = false +let listRequests: Array<{ + resolve: (result: OrganizationListResult) => void + reject: (error: Error) => void +}> = [] +let legacyOrganizations: TestOrganization[] = [] + +const organizationById = new Map() + +mock.module("./auth", () => ({ + authClient: { + useListOrganizations: () => ({ + data: legacyOrganizations, + isPending: false, + refetch: async () => ({ data: legacyOrganizations, error: null }), + }), + organization: { + list: () => + new Promise((resolve, reject) => { + listRequests.push({ resolve, reject }) + }), + getFullOrganization: async () => ({ + data: + organizationById.get( + currentSession?.session.activeOrganizationId ?? "", + ) ?? null, + error: null, + }), + setActive: async () => ({ data: null, error: null }), + }, + }, + useSession: () => ({ + data: currentSession, + isPending: isSessionPending, + }), +})) + +const [{ act }, { createRoot }, { AuthProvider, useAuth }] = await Promise.all([ + import("react"), + import("react-dom/client"), + import("./auth-context"), +]) +const mountedRoots: Array> = [] +const mountedContainers: HTMLElement[] = [] + +const accountAOrganization: TestOrganization = { + id: "organization-a", + name: "Private Account A", + slug: "account-a", + createdAt: new Date("2026-01-01T00:00:00Z"), +} +const accountBOrganization: TestOrganization = { + id: "organization-b", + name: "Private Account B", + slug: "account-b", + createdAt: new Date("2026-01-02T00:00:00Z"), +} + +function sessionFor( + sessionId: string, + userId: string, + organizationId: string, +): TestSession { + return { + session: { id: sessionId, activeOrganizationId: organizationId }, + user: { id: userId }, + } +} + +beforeEach(() => { + currentSession = null + isSessionPending = false + listRequests = [] + legacyOrganizations = [] + organizationById.clear() + organizationById.set(accountAOrganization.id, accountAOrganization) + organizationById.set(accountBOrganization.id, accountBOrganization) + localStorage.clear() +}) + +afterEach(async () => { + await act(async () => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + for (const container of mountedContainers.splice(0)) container.remove() +}) + +afterAll(() => { + for (const name of installedGlobals) { + const descriptor = originalDescriptors.get(name) + if (descriptor) Object.defineProperty(globalThis, name, descriptor) + else Reflect.deleteProperty(globalThis, name) + } + if (originalActEnvironmentDescriptor) { + Object.defineProperty( + globalThis, + "IS_REACT_ACT_ENVIRONMENT", + originalActEnvironmentDescriptor, + ) + } else { + Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT") + } + void browserWindow.close() +}) + +describe("AuthProvider organization scoping", () => { + it("keeps a cold session in the restoring state until auth resolves", async () => { + const snapshots: Array<{ + sessionId: string | null + organizationIds: string[] | null + isRestoring: boolean + }> = [] + + function Probe() { + const { session, organizations, isRestoring } = useAuth() + snapshots.push({ + sessionId: session?.id ?? null, + organizationIds: organizations?.map(({ id }) => id) ?? null, + isRestoring, + }) + return null + } + + const container = document.createElement("div") + document.body.append(container) + const root = createRoot(container) + mountedContainers.push(container) + mountedRoots.push(root) + isSessionPending = true + + await act(async () => { + root.render( + + + , + ) + }) + expect(snapshots.at(-1)).toEqual({ + sessionId: null, + organizationIds: null, + isRestoring: true, + }) + expect(listRequests).toHaveLength(0) + + currentSession = sessionFor("session-a", "user-a", accountAOrganization.id) + isSessionPending = false + await act(async () => { + root.render( + + + , + ) + }) + expect(snapshots.at(-1)).toEqual({ + sessionId: "session-a", + organizationIds: null, + isRestoring: true, + }) + expect(listRequests).toHaveLength(1) + }) + + it("finishes restoring when the organization request rejects", async () => { + const snapshots: Array<{ + organizationIds: string[] | null + activeOrganizationId: string | null + isRestoring: boolean + }> = [] + + function Probe() { + const { organizations, org, isRestoring } = useAuth() + snapshots.push({ + organizationIds: organizations?.map(({ id }) => id) ?? null, + activeOrganizationId: org?.id ?? null, + isRestoring, + }) + return null + } + + const container = document.createElement("div") + document.body.append(container) + const root = createRoot(container) + mountedContainers.push(container) + mountedRoots.push(root) + currentSession = sessionFor("session-a", "user-a", accountAOrganization.id) + + await act(async () => { + root.render( + + + , + ) + }) + expect(listRequests).toHaveLength(1) + + await act(async () => { + listRequests[0]?.reject(new Error("offline")) + await Promise.resolve() + }) + expect(snapshots.at(-1)).toEqual({ + organizationIds: [], + activeOrganizationId: null, + isRestoring: false, + }) + }) + + it("does not let an older same-session list replace a newer refetch", async () => { + const observedOrganizationIds: Array = [] + let refetchOrganizations: (() => Promise) | null = null + + function Probe() { + const auth = useAuth() + refetchOrganizations = auth.refetchOrganizations + observedOrganizationIds.push( + auth.organizations?.map(({ id }) => id) ?? null, + ) + return null + } + + const container = document.createElement("div") + document.body.append(container) + const root = createRoot(container) + mountedContainers.push(container) + mountedRoots.push(root) + currentSession = sessionFor("session-a", "user-a", accountAOrganization.id) + + await act(async () => { + root.render( + + + , + ) + }) + expect(listRequests).toHaveLength(1) + await act(async () => { + void refetchOrganizations?.() + await Promise.resolve() + }) + expect(listRequests).toHaveLength(2) + + await act(async () => { + listRequests[1]?.resolve({ + data: [accountAOrganization, accountBOrganization], + error: null, + }) + await Promise.resolve() + }) + expect(observedOrganizationIds.at(-1)).toEqual([ + accountAOrganization.id, + accountBOrganization.id, + ]) + + await act(async () => { + listRequests[0]?.resolve({ data: [accountAOrganization], error: null }) + await Promise.resolve() + }) + expect(observedOrganizationIds.at(-1)).toEqual([ + accountAOrganization.id, + accountBOrganization.id, + ]) + }) + + it("hides account A before account B's organizations load", async () => { + const snapshots: Array<{ + organizationIds: string[] | null + activeOrganizationId: string | null + isRestoring: boolean + }> = [] + + function Probe() { + const { organizations, org, isRestoring } = useAuth() + snapshots.push({ + organizationIds: organizations?.map(({ id }) => id) ?? null, + activeOrganizationId: org?.id ?? null, + isRestoring, + }) + return null + } + + const container = document.createElement("div") + document.body.append(container) + const root = createRoot(container) + mountedContainers.push(container) + mountedRoots.push(root) + legacyOrganizations = [accountAOrganization] + currentSession = sessionFor("session-a", "user-a", accountAOrganization.id) + + await act(async () => { + root.render( + + + , + ) + }) + await act(async () => { + listRequests[0]?.resolve({ data: [accountAOrganization], error: null }) + await Promise.resolve() + }) + expect(snapshots.at(-1)).toEqual({ + organizationIds: [accountAOrganization.id], + activeOrganizationId: accountAOrganization.id, + isRestoring: false, + }) + + currentSession = sessionFor("session-b", "user-b", accountBOrganization.id) + await act(async () => { + root.render( + + + , + ) + }) + + expect(snapshots.at(-1)).toEqual({ + organizationIds: null, + activeOrganizationId: null, + isRestoring: true, + }) + expect(listRequests).toHaveLength(2) + + await act(async () => { + listRequests[1]?.resolve({ data: [accountBOrganization], error: null }) + await Promise.resolve() + }) + expect(snapshots.at(-1)).toEqual({ + organizationIds: [accountBOrganization.id], + activeOrganizationId: accountBOrganization.id, + isRestoring: false, + }) + }) + + it("ignores a late organization response from the previous account", async () => { + const observedOrganizationIds: Array = [] + + function Probe() { + observedOrganizationIds.push( + useAuth().organizations?.map(({ id }) => id) ?? null, + ) + return null + } + + const container = document.createElement("div") + document.body.append(container) + const root = createRoot(container) + mountedContainers.push(container) + mountedRoots.push(root) + currentSession = sessionFor("session-a", "user-a", accountAOrganization.id) + await act(async () => { + root.render( + + + , + ) + }) + + currentSession = sessionFor("session-b", "user-b", accountBOrganization.id) + await act(async () => { + root.render( + + + , + ) + }) + expect(listRequests).toHaveLength(2) + + await act(async () => { + listRequests[1]?.resolve({ data: [accountBOrganization], error: null }) + await Promise.resolve() + }) + expect(observedOrganizationIds.at(-1)).toEqual([accountBOrganization.id]) + + await act(async () => { + listRequests[0]?.resolve({ data: [accountAOrganization], error: null }) + await Promise.resolve() + }) + expect(observedOrganizationIds.at(-1)).toEqual([accountBOrganization.id]) + }) +}) diff --git a/packages/lib/auth-context.tsx b/packages/lib/auth-context.tsx index acd15e88..ab552f1e 100644 --- a/packages/lib/auth-context.tsx +++ b/packages/lib/auth-context.tsx @@ -6,14 +6,22 @@ import { useCallback, useContext, useEffect, + useRef, useState, } from "react" import { authClient, useSession } from "./auth" +import { + createAuthSessionScope, + isOAuthConsentPath, + readScopedAuthValue, + scopedAuthValueForResponse, + type ScopedAuthValue, +} from "./scoped-auth-state" type Organization = typeof authClient.$Infer.ActiveOrganization type SessionData = NonNullable["data"]> type OrganizationListItem = NonNullable< - ReturnType["data"] + Awaited>["data"] >[number] const STORAGE_KEY = "supermemory-consumer-last-org-slug" @@ -53,88 +61,169 @@ const AuthContext = createContext(undefined) export function AuthProvider({ children }: { children: ReactNode }) { const { data: session, isPending: isSessionPending } = useSession() - const [org, setOrg] = useState(null) - const [isRestoring, setIsRestoring] = useState(true) - const { - data: orgsData, - refetch: refetchOrgsQuery, - isPending: orgsPending, - } = authClient.useListOrganizations() + const sessionScope = createAuthSessionScope({ + isPending: isSessionPending, + sessionId: session?.session.id, + userId: session?.user.id, + }) + const currentSessionScopeRef = useRef(sessionScope) + currentSessionScopeRef.current = sessionScope + const organizationsRequestRef = useRef(0) + const [organizationsState, setOrganizationsState] = useState | null>(null) + const [organizationState, setOrganizationState] = + useState | null>(null) - const organizations = - session?.session == null ? null : orgsPending ? null : (orgsData ?? []) - - const refetchOrganizations = useCallback( - () => Promise.resolve(refetchOrgsQuery()), - [refetchOrgsQuery], + const organizationsRead = readScopedAuthValue( + sessionScope, + organizationsState, ) + const organizationRead = readScopedAuthValue(sessionScope, organizationState) + const organizations = organizationsRead.ready ? organizationsRead.data : null + const org = organizationRead.ready ? organizationRead.data : null + const isRestoring = + isSessionPending || + Boolean( + session?.session && + (!sessionScope || !organizationsRead.ready || !organizationRead.ready), + ) + + const refetchOrganizations = useCallback(async () => { + const requestedScope = currentSessionScopeRef.current + if (!requestedScope) return null + const requestId = ++organizationsRequestRef.current + const commitOrganizations = (data: OrganizationListItem[]) => { + if (requestId !== organizationsRequestRef.current) return + const nextState = scopedAuthValueForResponse( + currentSessionScopeRef.current, + requestedScope, + data, + ) + if (nextState) setOrganizationsState(nextState) + } + + try { + const result = await authClient.organization.list() + commitOrganizations(result.data ?? []) + return result + } catch { + commitOrganizations([]) + return null + } + }, []) const setActiveOrg = useCallback(async (slug: string) => { if (!slug) return + const requestedScope = currentSessionScopeRef.current + if (!requestedScope) return const res = await authClient.organization.setActive({ organizationSlug: slug, }) - setOrg(res?.data ?? null) - localStorage.setItem(STORAGE_KEY, slug) + if (res.error || !res.data) { + throw new Error(res.error?.message ?? "Organization switch failed") + } + const nextState = scopedAuthValueForResponse( + currentSessionScopeRef.current, + requestedScope, + res.data, + ) + if (nextState) { + setOrganizationState(nextState) + localStorage.setItem(STORAGE_KEY, slug) + } }, []) const clearActiveOrg = useCallback(async () => { + const requestedScope = currentSessionScopeRef.current + if (!requestedScope) return try { await authClient.organization.setActive({ organizationId: null }) } catch {} - setOrg(null) - try { - localStorage.removeItem(STORAGE_KEY) - } catch {} + const nextState = scopedAuthValueForResponse( + currentSessionScopeRef.current, + requestedScope, + null, + ) + if (nextState) { + setOrganizationState(nextState) + try { + localStorage.removeItem(STORAGE_KEY) + } catch {} + } }, []) const updateOrgMetadata = useCallback((partial: Record) => { - setOrg((prev) => { - if (!prev) return prev + const currentScope = currentSessionScopeRef.current + if (!currentScope) return + setOrganizationState((prev) => { + if (prev?.scope !== currentScope || !prev.data) return prev return { - ...prev, - metadata: { - ...prev.metadata, - ...partial, + scope: currentScope, + data: { + ...prev.data, + metadata: { + ...prev.data.metadata, + ...partial, + }, }, } }) }, []) const refetchActiveOrg = useCallback(async () => { + const requestedScope = currentSessionScopeRef.current + if (!requestedScope) return null const full = await authClient.organization.getFullOrganization() const nextOrg = full?.data ?? null - setOrg(nextOrg) - return nextOrg + const nextState = scopedAuthValueForResponse( + currentSessionScopeRef.current, + requestedScope, + nextOrg, + ) + if (!nextState) return null + setOrganizationState(nextState) + return nextState.data }, []) useEffect(() => { if (isSessionPending) return - - if (!session?.session) { - setIsRestoring(false) - setOrg(null) + if (!sessionScope) { + setOrganizationsState(null) + setOrganizationState(null) return } - if (orgsPending || orgsData === undefined) { - setIsRestoring(true) - return - } + void refetchOrganizations() + }, [isSessionPending, refetchOrganizations, sessionScope]) - const orgs = orgsData ?? [] + useEffect(() => { + if (isSessionPending) return + if (!session?.session || !sessionScope || organizations === null) return + + const requestedScope = sessionScope + const orgs = organizations let cancelled = false + const commitOrganization = (nextOrg: Organization | null) => { + if (cancelled) return + const nextState = scopedAuthValueForResponse( + currentSessionScopeRef.current, + requestedScope, + nextOrg, + ) + if (nextState) setOrganizationState(nextState) + } const run = async () => { try { // OAuth consent owns org selection for the authorization transaction. const shouldRestoreSavedOrg = typeof window === "undefined" || - window.location.pathname !== "/oauth/consent" + !isOAuthConsentPath(window.location.pathname) if (orgs.length === 0) { - if (!cancelled) setOrg(null) + commitOrganization(null) return } @@ -148,7 +237,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (match) { if (activeOrgId === match.id) { const full = await authClient.organization.getFullOrganization() - if (!cancelled) setOrg(full?.data ?? null) + commitOrganization(full?.data ?? null) } else { await setActiveOrg(requestedSlug) } @@ -161,7 +250,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (!one) return if (activeOrgId === one.id) { const full = await authClient.organization.getFullOrganization() - if (!cancelled) setOrg(full?.data ?? null) + commitOrganization(full?.data ?? null) } else { await setActiveOrg(one.slug) } @@ -175,7 +264,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (match) { if (activeOrgId === match.id) { const full = await authClient.organization.getFullOrganization() - if (!cancelled) setOrg(full?.data ?? null) + commitOrganization(full?.data ?? null) } else { await setActiveOrg(savedSlug) } @@ -189,17 +278,16 @@ export function AuthProvider({ children }: { children: ReactNode }) { const fromList = orgs.find((o) => o.id === activeOrgId) if (fromList) { const full = await authClient.organization.getFullOrganization() - if (!cancelled) setOrg(full?.data ?? null) + commitOrganization(full?.data ?? null) return } } const full = await authClient.organization.getFullOrganization() - if (!cancelled) setOrg(full?.data ?? null) + commitOrganization(full?.data ?? null) } catch (error) { console.error("Failed to restore organization:", error) - } finally { - if (!cancelled) setIsRestoring(false) + commitOrganization(null) } } @@ -207,7 +295,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { return () => { cancelled = true } - }, [isSessionPending, session, orgsData, orgsPending, setActiveOrg]) + }, [ + isSessionPending, + organizations, + session?.session, + sessionScope, + setActiveOrg, + ]) useEffect(() => { if (typeof window === "undefined") return diff --git a/packages/lib/package.json b/packages/lib/package.json index c83e27db..8b1f6318 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -23,5 +23,8 @@ "sonner": "^2.0.5", "tailwind-merge": "^3.3.1", "zod": "^3.25.76" + }, + "devDependencies": { + "happy-dom": "^20.9.0" } } diff --git a/packages/lib/scoped-auth-state.test.ts b/packages/lib/scoped-auth-state.test.ts new file mode 100644 index 00000000..420c6278 --- /dev/null +++ b/packages/lib/scoped-auth-state.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "bun:test" +import { + createAuthSessionScope, + isOAuthConsentPath, + readScopedAuthValue, + scopedAuthValueForResponse, +} from "./scoped-auth-state" + +const accountAScope = createAuthSessionScope({ + isPending: false, + sessionId: "session-a", + userId: "user-a", +}) +const accountBScope = createAuthSessionScope({ + isPending: false, + sessionId: "session-b", + userId: "user-b", +}) + +if (!accountAScope || !accountBScope) { + throw new Error("Test scopes must be defined") +} + +describe("scoped auth state", () => { + it("hides the previous account's data immediately", () => { + const accountAOrganizations = { + scope: accountAScope, + data: [{ id: "private-account-a-org" }], + } + + expect(readScopedAuthValue(accountBScope, accountAOrganizations)).toEqual({ + ready: false, + }) + expect(readScopedAuthValue(null, accountAOrganizations)).toEqual({ + ready: false, + }) + }) + + it("accepts only responses for the current session scope", () => { + expect( + scopedAuthValueForResponse(accountBScope, accountAScope, [ + "late-account-a-org", + ]), + ).toBeNull() + + const accountBOrganizations = scopedAuthValueForResponse( + accountBScope, + accountBScope, + ["account-b-org"], + ) + expect(readScopedAuthValue(accountBScope, accountBOrganizations)).toEqual({ + ready: true, + data: ["account-b-org"], + }) + }) + + it("requires a settled authenticated session", () => { + expect( + createAuthSessionScope({ + isPending: true, + sessionId: "stale-session", + userId: "stale-user", + }), + ).toBeNull() + expect( + createAuthSessionScope({ + isPending: false, + sessionId: null, + userId: null, + }), + ).toBeNull() + }) + + it("refetches when the same user starts a new session", () => { + const first = createAuthSessionScope({ + isPending: false, + sessionId: "first-session", + userId: "same-user", + }) + const second = createAuthSessionScope({ + isPending: false, + sessionId: "second-session", + userId: "same-user", + }) + + expect(first).not.toBe(second) + }) + + it("serializes identifiers without delimiter collisions", () => { + const first = createAuthSessionScope({ + isPending: false, + sessionId: "session|user", + userId: "scope", + }) + const second = createAuthSessionScope({ + isPending: false, + sessionId: "session", + userId: "user|scope", + }) + + expect(first).not.toBe(second) + }) + + it("recognizes canonical and trailing-slash OAuth consent routes", () => { + expect(isOAuthConsentPath("/oauth/consent")).toBe(true) + expect(isOAuthConsentPath("/oauth/consent/")).toBe(true) + expect(isOAuthConsentPath("/brain")).toBe(false) + }) +}) diff --git a/packages/lib/scoped-auth-state.ts b/packages/lib/scoped-auth-state.ts new file mode 100644 index 00000000..8922a39f --- /dev/null +++ b/packages/lib/scoped-auth-state.ts @@ -0,0 +1,40 @@ +export interface ScopedAuthValue { + scope: string + data: T +} + +export type ScopedAuthRead = { ready: false } | { ready: true; data: T } + +export function createAuthSessionScope({ + isPending, + sessionId, + userId, +}: { + isPending: boolean + sessionId: string | null | undefined + userId: string | null | undefined +}): string | null { + if (isPending || !sessionId || !userId) return null + return JSON.stringify([sessionId, userId]) +} + +export function readScopedAuthValue( + currentScope: string | null, + value: ScopedAuthValue | null, +): ScopedAuthRead { + if (!currentScope || value?.scope !== currentScope) return { ready: false } + return { ready: true, data: value.data } +} + +export function scopedAuthValueForResponse( + currentScope: string | null, + requestedScope: string, + data: T, +): ScopedAuthValue | null { + if (currentScope !== requestedScope) return null + return { scope: requestedScope, data } +} + +export function isOAuthConsentPath(pathname: string): boolean { + return pathname === "/oauth/consent" || pathname === "/oauth/consent/" +}