API

Adding a procedure

A new endpoint, from router to test to client call.

Pick a builder

Every procedure starts from a builder in backend/src/trpc/trpc.ts. Each one adds a check on top of the one before.

BuilderLets through
publicProcedureAnyone
protectedProcedureAnyone signed in. ctx.session is non-null
acceptedTermsProcedureSigned in and has accepted the active terms
requirePermission("key")All of the above, plus the role has that feature
requireOrganizationPermission("key")Signed in, and has that feature in the organization named by input.organizationId

Every builder logs the call, so you never add logging by hand.

Write it

Add a file in backend/src/trpc/routers/. Validate input with Zod:

import { z } from "zod";
import { db } from "../../db/index.js";
import { notes } from "../../db/schema.js";
import { protectedProcedure, requirePermission, router } from "../trpc.js";

export const notesRouter = router({
  list: protectedProcedure.query(() => db.select().from(notes)),

  create: requirePermission("notes.create")
    .input(z.object({ body: z.string().min(1) }))
    .mutation(async ({ ctx, input }) => {
      const [note] = await db.insert(notes).values({ body: input.body, authorId: ctx.session.user.id }).returning();
      return note;
    }),
});

Take the user's id from ctx.session, never from input. A client-supplied id lets anyone act as anyone.

Register it

Add it to the root router in backend/src/trpc/router.ts:

export const appRouter = router({
  // ...
  notes: notesRouter,
});

The apps get the types with no extra step: trpc.notes.create.mutate({ body }).

Test it

Put the test beside the router. createCallerFactory calls procedures directly, with no HTTP:

import { beforeEach, expect, it } from "vitest";
import { contextFor, createTestUser } from "../../test/context.js";
import { resetDb } from "../../test/reset-db.js";
import { createCallerFactory } from "../trpc.js";
import { notesRouter } from "./notes.js";

const createCaller = createCallerFactory(notesRouter);

beforeEach(async () => {
  await resetDb();
});

it("refuses a role without notes.create", async () => {
  const user = await createTestUser({ role: "standard" });
  const caller = createCaller(contextFor(user));
  await expect(caller.create({ body: "hi" })).rejects.toMatchObject({ code: "FORBIDDEN" });
});

Run it with yarn test. See Testing.

Before you open the pull request

A new procedure usually means a new feature key. Run through the four permission questions in Checking permissions.