Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions apps/mesh/src/core/context-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -632,9 +632,47 @@ async function authenticateRequest(
let organization: OrganizationContext | undefined;
let role: string | undefined;

if (session.session.activeOrganizationId) {
// Get full organization data (includes members with roles)
// Prefer the explicit x-org-id request header over the session's activeOrganizationId.
// The MCP client sends this header on every request with the org from the URL, enabling
// multiple browser tabs to operate in different orgs simultaneously without one tab's
// setActive() call clobbering another tab's session state.
const headerOrgId = req.headers.get("x-org-id");

if (headerOrgId) {
// Resolve org directly from DB and verify the session user is a member.
// This bypasses the shared session state entirely for per-request org resolution.
const membership = await timings.measure(
"auth_query_org_membership",
() =>
db
.selectFrom("member")
.innerJoin(
"organization",
"organization.id",
"member.organizationId",
)
.select([
"member.role",
"organization.id as orgId",
"organization.slug as orgSlug",
"organization.name as orgName",
])
.where("member.userId", "=", session.user.id)
.where("member.organizationId", "=", headerOrgId)
.executeTakeFirst(),
);

if (membership) {
organization = {
id: membership.orgId,
slug: membership.orgSlug,
name: membership.orgName,
};
role = membership.role;
}
} else if (session.session.activeOrganizationId) {
// Fall back to the session's active org when no explicit header is present.
// (Existing behavior for requests that don't send x-org-id.)
const orgData = await timings.measure(
"auth_get_full_organization",
() =>
Expand Down
8 changes: 7 additions & 1 deletion apps/mesh/src/web/components/organizations-home.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { authClient } from "@/web/lib/auth-client";
import { setCurrentOrgId } from "@/web/lib/org-store";
import { useNavigate } from "@tanstack/react-router";
import { EntityCard } from "@deco/ui/components/entity-card.tsx";
import { EntityGrid } from "@deco/ui/components/entity-grid.tsx";
Expand Down Expand Up @@ -40,11 +41,16 @@ function InvitationCard({ invitation }: { invitation: Invitation }) {
toast.error(result.error.message);
setIsAccepting(false);
} else {
// Set the new org as active to update session
// Set the new org as active to update session and get org data.
const setActiveResult = await authClient.organization.setActive({
organizationId: invitation.organizationId,
});

// Keep the per-tab org store in sync with the explicit switch.
if (setActiveResult?.data?.id) {
setCurrentOrgId(setActiveResult.data.id);
}

if (setActiveResult?.data?.slug) {
toast.success("Invitation accepted!");
navigate({ to: "/$org", params: { org: setActiveResult.data.slug } });
Expand Down
7 changes: 7 additions & 0 deletions apps/mesh/src/web/components/sidebar/footer/inbox.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { authClient } from "@/web/lib/auth-client";
import { setCurrentOrgId } from "@/web/lib/org-store";
import { MeshUserMenu } from "@/web/components/user-menu";
import { Button } from "@deco/ui/components/button.tsx";
import {
Expand Down Expand Up @@ -45,6 +46,12 @@ function InvitationItem({ invitation }: { invitation: Invitation }) {
const setActiveResult = await authClient.organization.setActive({
organizationId: invitation.organizationId,
});

// Keep the per-tab org store in sync with the explicit switch.
if (setActiveResult?.data?.id) {
setCurrentOrgId(setActiveResult.data.id);
}

toast.success("Invitation accepted!");
const slug = setActiveResult?.data?.slug;
window.location.href = slug ? `/${slug}` : "/";
Expand Down
12 changes: 10 additions & 2 deletions apps/mesh/src/web/layouts/shell-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useLocalStorage } from "@/web/hooks/use-local-storage";
import RequiredAuthLayout from "@/web/layouts/required-auth-layout";
import { authClient } from "@/web/lib/auth-client";
import { LOCALSTORAGE_KEYS } from "@/web/lib/localstorage-keys";
import { setCurrentOrgId } from "@/web/lib/org-store";
import {
ResizableHandle,
ResizablePanel,
Expand Down Expand Up @@ -187,10 +188,17 @@ function ShellLayoutContent() {
return null;
}

const { data } = await authClient.organization.setActive({
organizationSlug: org,
// Pure read — does not mutate the shared session's activeOrganizationId.
// This is the key fix for multi-tab org isolation: previously setActive()
// would overwrite the session for every other open tab.
const { data } = await authClient.organization.getFullOrganization({
query: { organizationSlug: org },
});

// Populate the per-tab org store so the auth client injects organizationId
// on all subsequent Better Auth org-management calls from this tab.
setCurrentOrgId(data?.id ?? null);
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Bug: setCurrentOrgId inside queryFn won't fire on cache hits, causing stale org context when revisiting an org.

With staleTime: Infinity + refetchOnMount: false, navigating org A → org B → back to org A returns cached data for A without executing queryFn. The org store keeps B's ID, so all subsequent auth-client calls target the wrong org.

Move the store sync out of queryFn into a useEffect that runs whenever the query data changes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/src/web/layouts/shell-layout.tsx, line 200:

<comment>Bug: `setCurrentOrgId` inside `queryFn` won't fire on cache hits, causing stale org context when revisiting an org.

With `staleTime: Infinity` + `refetchOnMount: false`, navigating org A → org B → back to org A returns cached data for A without executing `queryFn`. The org store keeps B's ID, so all subsequent auth-client calls target the wrong org.

Move the store sync out of `queryFn` into a `useEffect` that runs whenever the query data changes.</comment>

<file context>
@@ -187,10 +188,17 @@ function ShellLayoutContent() {
 
+      // Populate the per-tab org store so the auth client injects organizationId
+      // on all subsequent Better Auth org-management calls from this tab.
+      setCurrentOrgId(data?.id ?? null);
+
       return {
</file context>
Fix with Cubic


return {
org: data,
// Project slug comes from URL param, actual project data is fetched in project-layout
Expand Down
35 changes: 35 additions & 0 deletions apps/mesh/src/web/lib/auth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
magicLinkClient,
} from "better-auth/client/plugins";
import { ssoClient } from "@better-auth/sso/client";
import { getCurrentOrgId } from "./org-store";

export const authClient = createAuthClient({
plugins: [
Expand All @@ -17,4 +18,38 @@ export const authClient = createAuthClient({
ssoClient(),
magicLinkClient(),
],
fetchOptions: {
onRequest: (ctx) => {
const orgId = getCurrentOrgId();
if (!orgId) return;

const urlStr = typeof ctx.url === "string" ? ctx.url : ctx.url.toString();

// Only intercept organization-management routes.
// Skip /set-active — those calls carry their own explicit org ID in the
// body (the org the user is switching TO) and must not be overridden.
if (!urlStr.includes("/organization/") || urlStr.includes("/set-active"))
return;

if (ctx.method?.toUpperCase() === "GET") {
// Inject as query param — Better Auth reads organizationId from query
// for listMembers, listRoles, getFullOrganization, etc.
const url = new URL(urlStr, window.location.origin);
url.searchParams.set("organizationId", orgId);
ctx.url = url.toString();
} else if (
ctx.body &&
typeof ctx.body === "object" &&
!Array.isArray(ctx.body)
) {
// Inject into body — Better Auth reads organizationId from body for
// inviteMember, removeMember, createRole, updateRole, deleteRole, etc.
// Only inject if not already explicitly provided by the caller.
const body = ctx.body as Record<string, unknown>;
if (!body.organizationId) {
ctx.body = { ...body, organizationId: orgId };
}
}
},
},
});
17 changes: 17 additions & 0 deletions apps/mesh/src/web/lib/org-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Per-tab org ID store.
*
* Browser tabs each have their own JS execution context, so module-level state
* is naturally isolated per tab. We use this to track the org the current tab
* is operating in, so the auth client can inject it on every outbound request
* rather than relying on the shared server-side session's activeOrganizationId.
*/
let currentOrgId: string | null = null;

export function setCurrentOrgId(id: string | null): void {
currentOrgId = id;
}

export function getCurrentOrgId(): string | null {
return currentOrgId;
}