Trovella Wiki

Procedure Chain

How tRPC middleware layers build the tenant-scoped database context that router handlers use.

The tRPC procedure chain is how most application code gets its database connection. Each middleware layer adds guarantees, so by the time a router handler runs, it has a fully tenant-scoped, CASL-authorized database transaction on ctx.db.

The Chain

publicProcedure        -> request logging only
  protectedProcedure   -> session validated (ctx.session guaranteed)
    tenantProcedure    -> withTenantContext called (ctx.db = tenant tx, ctx.organizationId set)
      authorizedProcedure -> member looked up, CASL ability built (ctx.ability, ctx.member, ctx.ai)

Every feature endpoint uses authorizedProcedure. The lower levels exist for edge cases (health checks, pre-auth endpoints).

Context Creation

Before any middleware runs, createContext in packages/api/src/context.ts extracts the session and creates a logger:

export async function createContext(opts: { headers: Headers }) {
  const session = await auth.api.getSession({
    headers: opts.headers,
    query: { disableCookieCache: true },
  });

  const logger = createRequestLogger(new Request("http://localhost", { headers: opts.headers }), {
    userId: session?.user.id,
    tenantId: session?.session.activeOrganizationId ?? undefined,
  });

  return { session, headers: opts.headers, logger };
}

At this point, ctx.session may be null (unauthenticated request).

protectedProcedure

Rejects requests without a session. After this middleware, ctx.session is guaranteed non-null.

export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  if (!ctx.session) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
  return next({ ctx: { session: ctx.session } });
});

tenantProcedure

Extracts the active organization from the session and opens a withTenantContext transaction. This is where the database connection becomes tenant-scoped.

export const tenantProcedure = protectedProcedure.use(async ({ ctx, next }) => {
  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,
      },
    }),
  );
});

Key details:

  • The organizationId comes from the server-side session, not from client input. This prevents clients from spoofing a tenant ID.
  • The entire downstream handler (including authorizedProcedure and the router handler) runs inside the withTenantContext transaction.
  • ctx.db is the transaction object with RLS active.

authorizedProcedure

Looks up the user's membership in the active organization, builds a CASL ability, and creates an AI helper. This is the procedure all feature routes use.

export const authorizedProcedure = tenantProcedure.use(async ({ ctx, next }) => {
  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",
    });
  }

  const org = await ctx.db.query.organization.findFirst({
    where: eq(organization.id, ctx.organizationId),
    columns: { type: true },
  });

  const ability = defineAbilityFor({
    userId: ctx.session.user.id,
    role: memberRecord.role,
    orgType: (org?.type ?? "personal") as OrgType,
  });

  const ai = createAIHelper(ctx.organizationId, ctx.session.user.id, ctx.logger);

  return next({
    ctx: { ...ctx, member: memberRecord, ability, ai },
  });
});

After authorizedProcedure, the context contains:

PropertyTypeDescription
ctx.sessionSessionAuthenticated user session
ctx.organizationIdstringActive organization ID
ctx.dbDatabaseTenant-scoped transaction (RLS active)
ctx.memberMemberThe user's membership record
ctx.abilityAppAbilityCASL ability for permission checks
ctx.aiAIHelperPre-bound AI helper with org/user context
ctx.loggerLoggerRequest-scoped structured logger

Writing a Router Handler

With authorizedProcedure, a router handler receives everything it needs:

export const memberRouter = router({
  list: authorizedProcedure.query(async ({ ctx }) => {
    // 1. Check CASL permission
    if (ctx.ability.cannot("read", "Member")) {
      throw new TRPCError({ code: "FORBIDDEN" });
    }

    // 2. Query using ctx.db (RLS is already active)
    const members = await ctx.db.query.member.findMany({
      with: {
        user: {
          columns: { id: true, name: true, email: true, image: true },
        },
      },
    });

    return members;
  }),
});

There is no need to filter by organization_id -- RLS handles it. The handler focuses on business logic and CASL checks.

MCP Tools: A Different Entry Point

MCP tools do not run through tRPC middleware. Instead, they call withTenantContext directly:

const organizationId = await resolveOrganizationId(auth.userId);
if (!organizationId) {
  /* error */
}

const result = await withTenantContext(organizationId, auth.userId, async (tx) => {
  return handleCreateResearchPlan(tx, params, organizationId, auth.userId);
});

The pattern is the same -- withTenantContext wraps the business logic -- but the organization ID comes from resolveOrganizationId (which looks up the user's active organization in the database) instead of from a tRPC session.

See the MCP tool file pattern for details on how each tool handles auth and tenant context.

On this page