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
organizationIdcomes from the server-side session, not from client input. This prevents clients from spoofing a tenant ID. - The entire downstream handler (including
authorizedProcedureand the router handler) runs inside thewithTenantContexttransaction. ctx.dbis 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:
| Property | Type | Description |
|---|---|---|
ctx.session | Session | Authenticated user session |
ctx.organizationId | string | Active organization ID |
ctx.db | Database | Tenant-scoped transaction (RLS active) |
ctx.member | Member | The user's membership record |
ctx.ability | AppAbility | CASL ability for permission checks |
ctx.ai | AIHelper | Pre-bound AI helper with org/user context |
ctx.logger | Logger | Request-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.