Trovella Wiki

tRPC Server Setup

How the tRPC server is initialized, context is created, and procedures build the middleware chain.

This page covers the server-side tRPC setup: how the tRPC instance is initialized, how request context is created, and how the four procedure levels are defined.

tRPC Initialization

The tRPC instance is created in packages/api/src/trpc.ts:

const t = initTRPC.context<Context>().create({
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError: error.cause instanceof ZodError ? z.treeifyError(error.cause) : null,
      },
    };
  },
});

export const router = t.router;

The Context type comes from packages/api/src/context.ts. The error formatter adds structured Zod validation errors to every response -- see Error Handling for details.

Context Creation

Every tRPC request starts with createContext, which extracts the session from request headers and creates a structured 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 };
}

Key details:

  • session may be null for unauthenticated requests (health checks, public endpoints)
  • disableCookieCache: true forces a fresh session lookup on every request
  • The logger is bound with userId and tenantId from the session, so every downstream log entry includes these fields automatically

The Four Procedure Levels

Each procedure level extends the one above it, adding more guarantees to the context.

publicProcedure

Adds request logging (path, type, duration). No authentication required. Used only for the health check endpoint.

export const publicProcedure = t.procedure.use(async ({ ctx, path, type, next }) => {
  const start = performance.now();
  const result = await next();
  const durationMs = Math.round(performance.now() - start);

  if (result.ok) {
    ctx.logger.info({ path, type, durationMs }, "tRPC request completed");
  } else {
    ctx.logger.error(
      { path, type, durationMs, error: result.error.message },
      "tRPC request failed",
    );
  }

  return result;
});

protectedProcedure

Guarantees ctx.session is non-null. Throws UNAUTHORIZED if no session exists. Used for user-scoped operations that do not need tenant context (PAT management).

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

tenantProcedure

Extends protectedProcedure. Extracts the active organization from the session and wraps downstream execution in withTenantContext, which opens an RLS-scoped database transaction. After this middleware, ctx.db is a tenant-isolated transaction.

Rarely used directly -- prefer authorizedProcedure which adds CASL on top.

authorizedProcedure

Extends tenantProcedure. Looks up the user's member record, builds a CASL ability based on their role and org type, and creates a pre-bound AI helper. This is the procedure all feature routers use.

After authorizedProcedure, the context provides:

PropertyTypeSource
ctx.sessionSessionprotectedProcedure
ctx.organizationIdstringtenantProcedure
ctx.dbTransactiontenantProcedure (RLS-scoped)
ctx.memberMemberauthorizedProcedure
ctx.abilityAppAbilityauthorizedProcedure
ctx.aiAIHelperauthorizedProcedure
ctx.loggerLoggercreateContext

For a detailed walkthrough of how each middleware layer builds the context, see Data & Storage -- Procedure Chain.

The Root Router

The appRouter in packages/api/src/router.ts merges all feature routers into a single router:

export const appRouter = router({
  health: publicProcedure.query(() => ({ status: "ok" })),
  aiLogs: aiLogsRouter,
  hybridSearch: hybridSearchRouter,
  organization: organizationRouter,
  member: memberRouter,
  pat: patRouter,
  researchArtifact: researchArtifactRouter,
  researchPlan: researchPlanRouter,
  skillExecution: skillExecutionRouter,
});

export type AppRouter = typeof appRouter;

The AppRouter type is exported and consumed by the client-side tRPC setup to provide end-to-end type safety. See Client Configuration.

The Next.js Route Handler

The appRouter is served via a Next.js catch-all API route at apps/web/src/app/api/trpc/[trpc]/route.ts:

function handler(req: Request) {
  return fetchRequestHandler({
    endpoint: "/api/trpc",
    req,
    router: appRouter,
    createContext: ({ req }) => createContext({ headers: req.headers }),
    onError({ error, path, type }) {
      if (CLIENT_ERROR_CODES.has(error.code)) return;
      Sentry.withScope((scope) => {
        scope.setTag("trpc.path", path);
        scope.setTag("trpc.type", type);
        scope.setTag("trpc.code", error.code);
        Sentry.captureException(error);
      });
    },
  });
}

const GET = handler;
const POST = handler;
export { GET, POST };

The handler uses tRPC's fetchRequestHandler adapter. Both GET and POST are handled by the same function. The onError callback reports unexpected errors to Sentry while filtering out expected client errors (UNAUTHORIZED, FORBIDDEN, NOT_FOUND, BAD_REQUEST, PARSE_ERROR, PRECONDITION_FAILED). See Error Handling for details.

Package Exports

The @repo/api package exports everything consumers need from packages/api/src/index.ts:

export { type Actions, defineAbilityFor, type OrgType, type Subjects } from "./abilities";
export { type Context, createContext } from "./context";
export { type AppRouter, appRouter } from "./router";
export {
  type AppAbility,
  authorizedProcedure,
  protectedProcedure,
  publicProcedure,
  router,
  tenantProcedure,
} from "./trpc";

The primary consumers are:

  • apps/web -- imports appRouter, createContext, and AppRouter type for the route handler and client setup
  • Test files -- import appRouter for createCaller-based integration tests

On this page