Trovella Wiki

Active Org Selection

How the session's active organization is set, switched, and consumed by the tRPC middleware chain.

The active organization determines which tenant's data a user sees and modifies. It is stored on the session record and read server-side on every request -- never from client input.

How It Works

The session table has an activeOrganizationId column that holds the ID of the organization the user is currently working in. This value flows through three layers:

session.activeOrganizationId (database)
  -> ctx.session.session.activeOrganizationId (tRPC context creation)
    -> tenantProcedure reads it, passes to withTenantContext
      -> RLS filters all queries to that organization

1. Session Creation (null)

When Better Auth creates a session after Google OAuth, activeOrganizationId is null. The user is authenticated but has no organization context yet.

2. Auto-Selection (bootstrapping)

ensureActiveOrganization in packages/auth/src/server.ts runs on every DashboardPage render. If activeOrganizationId is null, it selects the user's first membership and writes the org ID to the session:

export async function ensureActiveOrganization(
  sessionId: string,
  userId: string,
  activeOrganizationId: string | null | undefined,
) {
  if (activeOrganizationId) return activeOrganizationId;

  // Find first membership
  const memberships = await adapter.findMany({
    model: "member",
    where: [{ field: "userId", value: userId }],
    limit: 1,
  });

  if (memberships.length === 0) return null;

  // Update the session
  await adapter.update({
    model: "session",
    where: [{ field: "id", value: sessionId }],
    update: { activeOrganizationId: orgId },
  });

  return orgId;
}

For the full bootstrapping flow, see Bootstrapping.

3. tRPC Consumption

createContext in packages/api/src/context.ts reads the session (with cookie cache disabled for fresh reads) and passes it into the tRPC context. Then tenantProcedure extracts activeOrganizationId:

const orgId = ctx.session.session.activeOrganizationId;

if (!orgId) {
  throw new TRPCError({
    code: "PRECONDITION_FAILED",
    message: "No active organization selected",
  });
}

return withTenantContext(orgId, ctx.session.user.id, (tx) =>
  next({ ctx: { session: ctx.session, organizationId: orgId, db: tx } }),
);

The PRECONDITION_FAILED error should never occur in normal operation because ensureActiveOrganization runs before the page renders. If it does occur, it indicates a race condition or a direct API call without proper session setup.

Organization Switching

The Better Auth client (packages/auth/src/client.ts) includes the organizationClient plugin, which provides methods for switching the active organization. The client is configured in both the auth package and re-exported in the web app:

// packages/auth/src/client.ts
export const authClient = createAuthClient({
  plugins: [organizationClient()],
});

Better Auth's organization plugin provides authClient.organization.setActive({ organizationId }), which updates activeOrganizationId on the server-side session. After calling this, subsequent requests will use the new organization's context.

Organization switching UI is not yet implemented in the MVP (only personal organizations exist). When multi-org support is added, the switching flow will be:

  1. User selects a different organization from a UI picker
  2. Client calls authClient.organization.setActive({ organizationId })
  3. Server updates session.activeOrganizationId
  4. Client invalidates cached tRPC queries (TanStack Query)
  5. Subsequent requests use the new organization context through tenantProcedure

Security Model

The active organization is never sent as a request parameter from the client. The tRPC middleware reads it from the server-side session, which is stored in PostgreSQL and identified by a signed cookie. This prevents:

  • Tenant spoofing -- a client cannot claim to be in a different organization by manipulating request data
  • Session fixation -- the organization is tied to the authenticated session, not to a URL parameter or header

Even if activeOrganizationId were changed to point to an organization the user is not a member of, authorizedProcedure would reject the request because the member lookup would fail:

const memberRecord = await ctx.db.query.member.findFirst({
  where: and(eq(member.userId, ctx.session.user.id), eq(member.organizationId, ctx.organizationId)),
});

if (!memberRecord) {
  throw new TRPCError({
    code: "FORBIDDEN",
    message: "Not a member of this organization",
  });
}

This is a defense-in-depth approach: the session controls which org is active, and authorizedProcedure verifies membership on every request.

MCP Tools: A Different Path

MCP tools do not use tRPC and do not have access to the session. Instead, they resolve the organization ID from the authenticated user's memberships using resolveOrganizationId. The same principle applies: the organization is determined server-side from the user's data, not from client input.

See Data & Storage -- Procedure Chain for how MCP tools handle tenant context.

On this page