Skip to content

tRPC

Routers live in packages/core/src/server/trpc/routers/ and are composed into the root appRouter. The handler is mounted at /api/trpc/* by apps/api/src/index.ts, with Sentry fingerprinting per procedure.

Defined in packages/core/src/server/trpc/trpc.ts:

Procedure Guarantees
publicProcedure No auth. Anything a signed-out visitor may call.
protectedProcedure A signed-in user (enforceUser). ctx.user is non-null.
roleProtectedProcedure([roles]) Signed in and holding one of the roles (enforceRole). Adds ctx.callerRoles.

Pick the narrowest one that works. Hiding a button is not access control — the procedure is.

export const thingsRouter = router({
list: publicProcedure
.input(z.object({ eventId: z.string().uuid() }))
.query(async ({ input }) => { /* ... */ }),
update: roleProtectedProcedure(["admin", "superadmin"])
.input(updateThingSchema)
.mutation(async ({ ctx, input }) => {
const updated = await db
.update(things)
.set({ ...input, updatedAt: new Date().toISOString() })
.where(eq(things.id, input.id))
.returning();
await logAudit(ctx, {
action: "update",
entity: "thing",
entityId: input.id,
// before/after context
});
return updated[0];
}),
});

Three things that are not optional in a mutation: the Zod input, the manual updatedAt, and logAudit on every admin create, update and delete.

Use the trpc-router skill — it encodes the conventions above plus Sentry capture and registration in the root router. Doing it by hand means:

  1. New file in packages/core/src/server/trpc/routers/.
  2. Zod schemas for every input.
  3. Register it in the root router.
  4. If mobile needs it, add a /v1 adapter — never a second implementation.

createCaller gives you a typed server-side caller with a context you supply. That is exactly how the /v1 REST layer reuses the routers; see REST /v1.