Your first tenant-scoped feature
Most of what you add to the kit belongs to a customer: rows that live in one organization’s
workspace and that nobody else may read. This guide builds the smallest such feature, notes in a
workspace (list, create, delete), through every layer from the table up. The shipped feature
built the same way is documents (apps/api/src/rpc/documents.ts); read it next. For a setting
that a platform operator manages for the whole deployment, follow the
worked feature example instead.
Start from a running copy: Quickstart through bun run dev:app, an account,
an organization and a workspace. Paths and commands are from the repository root. The // @paid
and @free comments in kit files are for the seller’s build of the kit’s free edition; ignore
them, and leave them out of your own code.
What you build
Section titled “What you build”New files, all yours:
packages/db/src/schema/notes.ts the tablepackages/api-contract/src/notes.ts the procedures and their schemasapps/api/src/rpc/notes.ts the handlersapps/web/src/routes/(app)/app/[workspace]/notes/ the pageapps/api/src/rpc/notes.test.ts unit testapps/api/src/rpc/notes.db.test.ts tenancy test against Postgresand the product files the kit created for you to fill in:
packages/db/src/schema/product.ts exports the tablepackages/db/drizzle-product/ its migration, generatedpackages/auth/src/product-permissions.ts which role may read, create and delete notespackages/api-contract/src/product.ts adds the procedures to the contractapps/api/src/rpc/product.ts mounts the handlersapps/web/src/lib/product-workspace.ts the workspace tabapps/web/messages/{en,de}/product.json the page's stringsYou edit no kit file; kit releases never change these. Where your code goes lists every product file and what reads it.
The tenant boundary
Section titled “The tenant boundary”The organization is the tenant. Every procedure that touches a note proves two things before its handler runs:
- The caller is a member of the organization, with the permission the action needs. Anyone
else gets
FORBIDDEN. - Every other id belongs to that organization, and to a part of it the caller may see. Proving membership of the organization the caller named is not enough: a member of organization A who sends A’s id with a workspace id of organization B would pass it. The foreign keys do not catch this either: a note row with A’s organization and B’s workspace satisfies both. And a workspace scoped to a team is that team’s, and the owners’ and admins’, not every member’s (Teams are access boundaries).
The tenant guard in apps/api/src/rpc/tenancy.ts does both, as middleware in front of the handler:
orgScoped({ note: ['create'] })is for a procedure whose input names its organization. It proves membership and the permission, then checksinput.workspaceIdagainst the organization and the caller’s teams.orgScopedRow(noteById)is for a procedure addressed by the note’s own id (DELETE /notes/{id}), whose input has no organization. It loads the row, proves membership of the row’s organization, and checks the workspace the note lives in. It takes a permission asorgScopeddoes; the delete leaves its permission to the handler, for the reason step 5 gives.
A workspace the caller may not see, another tenant’s or another team’s, answers NOT_FOUND, as if
it did not exist, and so does an id that exists nowhere. A note addressed by its id in another
organization answers FORBIDDEN. That is the kit’s convention for every procedure on organization
data (workspaces.update, documents.get, jobs.cancel).
apps/api/src/rpc/tenancy.test.ts, part of bun run test, holds every procedure to this, yours
included. It walks the router, and each procedure’s input at every depth: nested objects, lists,
records and the options of a union. It fails for a signed-in procedure that takes an id or
ids, or any field ending in Id or Ids, anywhere in its input without one of the guards, and
for one whose input it cannot read, such as a .transform() or z.unknown(), which it counts as
tenant data. orgScopedOptional, which system.flags runs, counts as a guard only when
organizationId is the procedure’s one tenant field: it checks nothing else. The test also fails
for a guarded procedure that can answer NOT_FOUND but does not declare it, so each notes
procedure declares NOT_FOUND. A procedure on a user’s own rows rather than an organization’s,
such as a user’s bookmarks, needs no guard: list it in
apps/api/src/rpc/product-tenancy-allowlist.ts, with the reason. Notes need no entry there.
Lists of ids: the guard does not read them
Section titled “Lists of ids: the guard does not read them”tenancy.test.ts proves that a guard runs, never what the handler does with the rest of the input.
A procedure that takes a list of ids (noteIds, a bulk archive) is checked by the guard for the
organization and the workspace it names, and not for the rows the list points at: a member of A who
sends A’s organization with B’s note ids passes it. Each such list goes through one of two helpers
from @repo/db, which take the member as their Viewer. Say notes gained a bulk archive,
notes.archive, whose input requires a workspaceId and takes noteIds, under a
note: ['update'] grant this guide’s statement does not have:
import { idsOutsideScope, inScope } from '@repo/db';
archive: authed.notes.archive .use(orgScoped({ note: ['update'] })) .handler(async ({ input, context, errors }) => { const workspaceId = context.workspace.id; // Another organization's note, another team's, or none at all: refuse before writing. const foreign = await idsOutsideScope({ from: notes, ...notes }, context.member, input.noteIds, { workspaceId, }); if (foreign.length > 0) throw errors.NOT_FOUND(); // And keep the write to them whatever happened in between. await db .update(notes) .set({ archived: true }) .where(inScope(notes, context.member, input.noteIds, workspaceId)); return { archived: input.noteIds.length }; }),inScope is a condition to AND into an UPDATE or DELETE: the viewer’s organization, the workspace
the guard checked (or, without one, a workspace the viewer may see), and the ids, bound as one
parameter however long the list. idsOutsideScope answers the ids that name no such row, once
each, in one query. A row in no workspace is outside on a table with a workspace column. Every
procedure that takes such a list has a database test that sends another organization’s id and
another team’s, and asserts NOT_FOUND, or that the foreign row is unchanged (step 8).
1. The table
Section titled “1. The table”Create packages/db/src/schema/notes.ts:
import { index, pgTable, text, timestamp } from 'drizzle-orm/pg-core';import { organization, user } from './auth';import { workspaces } from './workspaces';
/** Notes in a workspace. Every row names its organization, the tenant the API checks. */export const notes = pgTable( 'note', { id: text() .primaryKey() .$defaultFn(() => crypto.randomUUID()), organizationId: text() .notNull() .references(() => organization.id, { onDelete: 'cascade' }), workspaceId: text() .notNull() .references(() => workspaces.id, { onDelete: 'cascade' }), authorId: text().references(() => user.id, { onDelete: 'set null' }), title: text().notNull(), body: text().notNull().default(''), createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp({ withTimezone: true }) .notNull() .defaultNow() .$onUpdate(() => new Date()), }, (t) => [ index('note_workspace_created_idx').on(t.workspaceId, t.createdAt), index('note_organization_idx').on(t.organizationId), ],);
export type Note = typeof notes.$inferSelect;export type NewNote = typeof notes.$inferInsert;- Both foreign keys cascade, so deleting a workspace or an organization takes its notes with it.
authorIdis set toNULLinstead when the author’s account is deleted: the note belongs to the organization, not to the person. note_workspace_created_idxserves the list (one workspace, newest first).note_organization_idxkeeps the cascade from scanning the table when an organization is deleted.- Columns are camelCase in TypeScript and snake_case in Postgres (
casinginpackages/db/drizzle.config.ts). - The file imports the kit’s tables it references from their own files. Never re-export a kit
table from your schema:
db:generatewould create it a second time.
Export it from packages/db/src/schema/product.ts, in place of the stub’s export {};:
export * from './notes';packages/db/src/product.ts re-exports that file, so notes is now importable from @repo/db,
next to db, eq and the kit’s tables.
2. The migration
Section titled “2. The migration”Generate the migration from the schema, read it, then apply it to your development database:
bun run db:generate --name=notesbun run db:migratedb:generate diffs what schema/product.ts exports against your migration history and writes
packages/db/drizzle-product/0000_notes.sql (the number counts your migrations only, so the first
is 0000) and updates packages/db/drizzle-product/meta/. The SQL creates the table, its three
foreign keys and both indexes:
CREATE TABLE "note" ( "id" text PRIMARY KEY NOT NULL, "organization_id" text NOT NULL, "workspace_id" text NOT NULL, "author_id" text, "title" text NOT NULL, "body" text DEFAULT '' NOT NULL, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL);db:migrate applies the kit’s history, packages/db/drizzle/, then yours, each recorded in a
history table of its own. Commit the .sql file and meta/ together. Migrations are append-only:
once one has run anywhere, change the schema and generate another rather than editing it.
Production applies the same files with bun run db:migrate. A later kit release’s migrations go
into the kit’s history and never touch yours; UPGRADING.md (“Migrations”) covers the order on
fresh and existing databases.
3. Who may do what
Section titled “3. Who may do what”Roles are code, not rows, so a new resource is one statement plus a grant per role. Yours go in
packages/auth/src/product-permissions.ts. Replace its two stubs:
export const productStatement = { note: ['create', 'read', 'delete', 'deleteAny'],} as const;
export const productRoles = { owner: { note: ['create', 'read', 'delete', 'deleteAny'] }, admin: { note: ['create', 'read', 'delete', 'deleteAny'] }, member: { note: ['create', 'read', 'delete'] },} as const;delete lets the author delete their own note; deleteAny lets owners and admins delete anyone’s.
That is the pair the kit’s own statement gives documents (document: ['create', 'read', 'delete', 'deleteAny'] in permissions.ts), and mayDelete applies it in step 5 and step 7.
Nothing else registers it. packages/auth/src/permissions.ts adds productStatement after the
kit’s statements and each role’s entry after that role’s kit grants, and the auth server
(organization({ ac, roles })), hasPermission() in the browser and the tenant guard in the API
all read that file. A resource named like one of the kit’s or Better Auth’s, such as workspace
or team, fails bun run check.
4. The contract
Section titled “4. The contract”A feature’s procedures go in a file of their own next to packages/api-contract/src/product.ts,
built from the same builders and text rules the kit uses. Import them from ./procedures, never
from ./index: index.ts imports product.ts, so importing it back is a cycle that fails at
start-up. CursorSchema and SlugSchema are in ./procedures too. Create
packages/api-contract/src/notes.ts:
import { z } from 'zod';import { controlCharacterRule, isPlainText, isSingleLine, protectedProcedure } from './procedures';
export const NoteSchema = z.object({ id: z.string(), organizationId: z.string(), workspaceId: z.string(), authorId: z.string().nullable(), title: z.string(), body: z.string(), createdAt: z.date(), updatedAt: z.date(),});export type Note = z.infer<typeof NoteSchema>;
/** Exported so the form validates the very schema the API enforces. */export const NoteCreateInputSchema = z.object({ organizationId: z.string(), workspaceId: z.string(), title: z .string() .trim() .min(1, 'Give the note a title') .max(120) .refine(isSingleLine, controlCharacterRule), body: z.string().max(10_000).refine(isPlainText, controlCharacterRule).default(''),});export type NoteCreateInput = z.infer<typeof NoteCreateInputSchema>;
export const notesContract = { notes: { list: protectedProcedure .route({ method: 'GET', path: '/workspaces/{workspaceId}/notes', tags: ['notes'], summary: 'Notes of a workspace, newest first', }) .errors({ NOT_FOUND: { message: 'Workspace not found.' } }) .input(z.object({ organizationId: z.string(), workspaceId: z.string() })) .output(z.array(NoteSchema)),
create: protectedProcedure .route({ method: 'POST', path: '/workspaces/{workspaceId}/notes', tags: ['notes'], summary: 'Add a note to a workspace', }) .errors({ NOT_FOUND: { message: 'Workspace not found.' } }) .input(NoteCreateInputSchema) .output(NoteSchema),
delete: protectedProcedure .route({ method: 'DELETE', path: '/notes/{id}', tags: ['notes'], summary: 'Delete a note' }) .errors({ NOT_FOUND: { message: 'Note not found.' } }) .input(z.object({ id: z.string() })) .output(z.object({ id: z.string() })), },};protectedProceduredeclaresUNAUTHORIZEDandFORBIDDEN, so every client sees them typed. Each procedure adds the errors it can answer on top: here theNOT_FOUNDthe tenant guard answers with the message declared for it.isSingleLinerefuses line breaks and control characters in the title;isPlainTextlets the body keep tabs and line breaks and refuses the rest, U+0000 included, which Postgres cannot store. Both reportparams.reason, which the app’s forms translate. A refinement of yours does the same with a reason of its own, and its copy goes inproductIssueMessages(apps/web/src/lib/product-forms.ts).- A delete’s
summarynames every row it removes, by cascade too, and what it leaves. A note takes nothing with it, so'Delete a note'is whole; one with comments would say'Delete a note and its comments'. A client composing several writes reads it, and the REST reference shows it. - Every procedure has a
route, so it is a REST endpoint too. Fields that are not in the path come from the query string on aGETand from the JSON body otherwise.
Then add it to packages/api-contract/src/product.ts. Below the file’s comment, replace
export const productContract = {}; with:
import { notesContract } from './notes';
export * from './notes';
export const productContract = { ...notesContract,};index.ts spreads productContract into the contract after every kit procedure, so a key the kit
already uses fails bun run check.
5. The handlers
Section titled “5. The handlers”Create apps/api/src/rpc/notes.ts:
import { mayDelete } from '@repo/auth/permissions';import { and, db, desc, eq, notes, recordAudit } from '@repo/db';import { auditActor, authed } from './base';import { orgScoped, orgScopedRow } from './tenancy';
/** A note by its own id, for `orgScopedRow`: its organization and workspace decide access. */async function noteById(input: { id: string }) { const [row] = await db.select().from(notes).where(eq(notes.id, input.id)).limit(1); return row ?? null;}
export const notesRouter = { list: authed.notes.list.use(orgScoped({ note: ['read'] })).handler(({ input }) => db .select() .from(notes) .where( and( eq(notes.organizationId, input.organizationId), eq(notes.workspaceId, input.workspaceId), ), ) .orderBy(desc(notes.createdAt)), ),
create: authed.notes.create .use(orgScoped({ note: ['create'] })) .handler(async ({ input, context }) => { const [note] = await db .insert(notes) .values({ organizationId: input.organizationId, workspaceId: input.workspaceId, authorId: context.member.userId, title: input.title, body: input.body, }) .returning(); if (!note) throw new Error('The note insert returned no row.'); await recordAudit({ organizationId: note.organizationId, ...auditActor(context), action: 'note.create', targetType: 'note', targetId: note.id, metadata: { workspaceId: note.workspaceId, title: note.title }, }); return note; }),
delete: authed.notes.delete // No permission in the guard: `mayDelete` takes either `delete` (one's own) or `deleteAny`. .use(orgScopedRow(noteById)) .handler(async ({ context, errors }) => { const { member, row: note } = context; // Authors delete their own notes; owners and admins (`deleteAny`) anyone's. if (!mayDelete(member.can, 'note', note.authorId, member.userId)) { throw errors.FORBIDDEN(); } await db.delete(notes).where(eq(notes.id, note.id)); await recordAudit({ organizationId: note.organizationId, ...auditActor(context), action: 'note.delete', targetType: 'note', targetId: note.id, metadata: { workspaceId: note.workspaceId, title: note.title }, }); return { id: note.id }; }),};authedresolves the session (cookie or bearer token) and answersUNAUTHORIZEDwithout one.authed.notes.*exists because the contract declares it, anderrors.FORBIDDEN()because the procedure does.- The guard runs after
authedand after the input is validated, and hands the handlercontext.member: the caller’s membership, withorganizationId,userId,roleandcan().orgScopedRowadds the loaded note ascontext.row.orgScopedalso hands on the workspaceinput.workspaceIdnames, checked, ascontext.workspace: aWorkspacewhere the input requires aworkspaceId, asnotes.list’s does, andWorkspace | nullwhere it may be left out, so check it fornullthere.orgScopedon a procedure whose input has noorganizationIddoes not compile. mayDelete(member.can, 'note', note.authorId, member.userId)lets the author through withnote: ['delete']and anyone withdeleteAny. A note whose author’s account is gone (authorIdisNULL) needsdeleteAny. The guard asks for no permission, somayDeletealone decides, as indocuments.delete: whose note it is, only the handler can tell, and a guard that asked fordeletewould also refuse an API key, which can hold onlydeleteAny(Going further).recordAuditwrites the entry that owners and admins read on/app/organization/audit-log. It never throws: a failed audit write is logged and does not undo the action.auditActor(context)records who is acting: the user, and the platform admin behind an impersonated session, or the API key on a procedure you open to keys (Going further). The log’s filter names the actions’ first segment,note, onceproductLabels.auditCategories(apps/web/src/lib/product-labels.ts) hasnote: m.product_audit_category_note. Itsmetadata.workspaceIdkeeps the title from a reader of the log who may not see the workspace, who gets the entry withmetadata: null.- The list filters on the organization as well as the workspace. The guard makes that redundant today; a query that always names its tenant stays safe when a later change drops a check.
Mount the router in apps/api/src/rpc/product.ts. Below the file’s comment, replace
export const productRouter = {}; with:
import { notesRouter } from './notes';
export const productRouter = { notes: notesRouter,};router.ts spreads productRouter after the kit’s handlers, and the router implements the whole
contract, so bun run check fails while a procedure in the contract has no handler, or a handler
returns something its output schema does not describe.
6. The client package
Section titled “6. The client package”There is nothing to edit. @repo/api-client re-exports everything
packages/api-contract/src/product.ts exports, so the export * from './notes' there already
gives clients Note, NoteCreateInput and NoteCreateInputSchema. The typed client
(api.notes.list) and the TanStack Query helpers (orpc.notes.list) follow from the contract’s
type.
7. The page
Section titled “7. The page”A workspace’s tabs come from productWorkspaceTabs in apps/web/src/lib/product-workspace.ts,
which the workspace layout shows before Settings. Import the messages below the file’s comment,
and return the tab from the function in place of the stub’s empty list:
import { m } from '#lib/paraglide/messages.js';export function productWorkspaceTabs(base: string): ProductWorkspaceTab[] { return [{ href: `${base}/notes`, label: m.product_notes_title }];}base is the workspace’s own path, /app/<slug>. The label is the message itself, not its
result: the layout calls it as it renders, so it follows the user’s language.
The layout’s load has already resolved the workspace in the active organization, so the page
reads it from data.workspace. Create
apps/web/src/routes/(app)/app/[workspace]/notes/+page.svelte:
<script lang="ts"> import StickyNoteIcon from '@lucide/svelte/icons/sticky-note'; import { NoteCreateInputSchema, safe } from '@repo/api-client'; import { mayDelete } from '@repo/auth/permissions'; import { Alert, Button, EmptyState, FormField, Input, Skeleton, Textarea } from '@repo/ui'; import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'; import { toast } from 'svelte-sonner'; import { defaults, superForm } from 'sveltekit-superforms'; import { authClient } from '#lib/auth.js'; import { applyApiError, messageOf, spaForm, zodForm } from '#lib/forms.js'; import { m } from '#lib/paraglide/messages.js'; import { orpc } from '#lib/query.js'; import { browser } from '$app/env'; import type { PageProps } from './$types';
let { data }: PageProps = $props();
const scope = $derived({ organizationId: data.workspace.organizationId, workspaceId: data.workspace.id, });
const queryClient = useQueryClient(); const notes = createQuery(() => orpc.notes.list.queryOptions({ input: scope })); const refresh = () => queryClient.invalidateQueries({ queryKey: orpc.notes.key() }); const create = createMutation(() => orpc.notes.create.mutationOptions({ onSuccess: refresh })); const remove = createMutation(() => orpc.notes.delete.mutationOptions({ onSuccess: refresh }));
// Only hides the Delete buttons; the API decides again on every call. Authors delete their own // notes; owners and admins (`deleteAny`) anyone's. const activeMember = authClient.useActiveMember(); const canDelete = (note: { authorId: string | null }) => mayDelete($activeMember.data?.role, 'note', note.authorId, data.session.user.id);
// The fields the user fills in, with the contract's own rules for them. const NoteFormSchema = NoteCreateInputSchema.pick({ title: true, body: true });
const sf = superForm(defaults(zodForm(NoteFormSchema)), { ...spaForm, validators: zodForm(NoteFormSchema), async onUpdate({ form }) { if (!form.valid) return; const [error] = await safe(create.mutateAsync({ ...scope, ...form.data })); if (error) { applyApiError(form, error); return; } toast.success(m.product_notes_added()); sf.reset(); }, }); const { form, errors, enhance, submitting } = sf;
function deleteNote(id: string) { remove.mutate( { id }, { onSuccess: () => toast.success(m.product_notes_deleted()), onError: (error) => toast.error(messageOf(error)), }, ); }</script>
<svelte:head> <title>{m.product_notes_title()} · {data.workspace.name} · {m.common_app_name()}</title></svelte:head>
<div class="flex flex-col gap-6"> <h1 class="text-2xl font-semibold">{m.product_notes_title()}</h1>
<form class="flex max-w-xl flex-col gap-4" method="POST" novalidate use:enhance> <FormField label={m.product_notes_field_title()} name="title" errors={$errors.title}> {#snippet children({ id, describedBy, invalid })} <Input {id} name="title" bind:value={$form.title} {invalid} aria-describedby={describedBy} maxlength={120} disabled={$submitting} /> {/snippet} </FormField> <FormField label={m.product_notes_field_body()} name="body" errors={$errors.body}> {#snippet children({ id, describedBy, invalid })} <Textarea {id} name="body" bind:value={$form.body} {invalid} aria-describedby={describedBy} rows={4} disabled={$submitting} /> {/snippet} </FormField> {#if $errors._errors?.length} <Alert variant="error">{$errors._errors[0]}</Alert> {/if} <div> <!-- Off until hydration: before `use:enhance` attaches, a click would POST natively (405). --> <Button type="submit" disabled={!browser || $submitting}>{m.product_notes_add()}</Button> </div> </form>
{#if notes.isPending} <Skeleton class="h-20 w-full" /> {:else if notes.isError} <Alert variant="error">{m.product_notes_load_failed()}</Alert> {:else if notes.data.length === 0} <EmptyState title={m.product_notes_empty_title()} description={m.product_notes_empty_description()} > {#snippet icon()} <StickyNoteIcon /> {/snippet} </EmptyState> {:else} <ul class="flex flex-col gap-3"> {#each notes.data as note (note.id)} <li class="border-border flex items-start justify-between gap-4 rounded-lg border p-4"> <div class="min-w-0"> <p class="font-medium">{note.title}</p> {#if note.body} <p class="text-muted-foreground mt-1 text-sm whitespace-pre-line">{note.body}</p> {/if} </div> {#if canDelete(note)} <Button variant="ghost" size="sm" onclick={() => deleteNote(note.id)}> {m.common_delete()} </Button> {/if} </li> {/each} </ul> {/if}</div>- The form validates with the contract’s own rules (
.pickkeeps them), so the browser refuses exactly what the API would.zodFormtranslates the refusals;applyApiErrorputs a refusal from the API on its field, or above the button. orpc.notes.*derives the query key, the fetcher and the types from the contract. Queries do not run during server-side rendering, so the list renders a skeleton in the HTML and loads in the browser. To have the list in the HTML, fetch it in a+page.tswithapiFor(fetch)and seed the query withinitialData, as the documents and jobs pages do.canDeleteasksmayDeletewith the member’s role, the same rule the handler applies withmember.can, so a member sees Delete on their own notes only, and owners and admins on every note.
Every string the page renders comes from Paraglide, and yours go in the product catalogues, keyed
product_<page>_<element> and kept in Inlang’s order (_ before digits before letters), which
bun run --cwd apps/web i18n:add keeps. The kit’s stubs hold only $schema; with the page’s keys,
apps/web/messages/en/product.json reads:
{ "$schema": "https://inlang.com/schema/inlang-message-format", "product_notes_add": "Add note", "product_notes_added": "Note added", "product_notes_deleted": "Note deleted", "product_notes_empty_description": "Notes you add here are visible to everyone who can open this workspace.", "product_notes_empty_title": "No notes yet", "product_notes_field_body": "Note", "product_notes_field_title": "Title", "product_notes_load_failed": "Could not load the notes", "product_notes_title": "Notes"}and apps/web/messages/de/product.json, with the same keys:
{ "$schema": "https://inlang.com/schema/inlang-message-format", "product_notes_add": "Notiz hinzufügen", "product_notes_added": "Notiz hinzugefügt", "product_notes_deleted": "Notiz gelöscht", "product_notes_empty_description": "Notizen, die du hier hinzufügst, sieht jede Person, die diesen Workspace öffnen kann.", "product_notes_empty_title": "Noch keine Notizen", "product_notes_field_body": "Notiz", "product_notes_field_title": "Titel", "product_notes_load_failed": "Die Notizen konnten nicht geladen werden", "product_notes_title": "Notizen"}Paraglide compiles the catalogues into m.* functions before check, test and build. A key
the page uses that no catalogue has fails bun run check: there is no m.* function for it. A key
only one locale’s catalogue has still compiles, and the page shows the English text in its place
in German, or the key’s own name in English. bun run test catches that one:
apps/web/src/lib/i18n-parity.spec.ts fails and names the catalogue file and the missing key.
8. Tests
Section titled “8. Tests”Two kinds. A unit test runs in bun run test, with no database. Create
apps/api/src/rpc/notes.test.ts:
import { describe, expect, it } from 'bun:test';import { NoteCreateInputSchema } from '@repo/api-contract/product';import { hasPermission, mayDelete } from '@repo/auth/permissions';
describe('notes', () => { it("lets every member delete their own notes, and only owners and admins anyone's", () => { for (const role of ['owner', 'admin', 'member']) { expect(hasPermission(role, { note: ['read', 'create', 'delete'] })).toBe(true); } expect(hasPermission('owner', { note: ['deleteAny'] })).toBe(true); expect(hasPermission('admin', { note: ['deleteAny'] })).toBe(true); expect(hasPermission('member', { note: ['deleteAny'] })).toBe(false);
expect(mayDelete('member', 'note', 'bob', 'bob')).toBe(true); expect(mayDelete('member', 'note', 'alice', 'bob')).toBe(false); // A note whose author's account is gone is left to owners and admins. expect(mayDelete('member', 'note', null, 'bob')).toBe(false); expect(mayDelete('admin', 'note', 'bob', 'alice')).toBe(true); });
it('takes a one-line title and a body with paragraphs', () => { const scope = { organizationId: 'org', workspaceId: 'ws' }; const valid = (input: object) => NoteCreateInputSchema.safeParse({ ...scope, ...input }).success; expect(NoteCreateInputSchema.parse({ ...scope, title: ' Plan ' })).toMatchObject({ title: 'Plan', body: '', }); expect(valid({ title: 'Two\nlines' })).toBe(false); expect(valid({ title: 'Plan', body: 'One.\n\nTwo.' })).toBe(true); expect(valid({ title: 'Plan', body: 'Null \u0000 byte' })).toBe(false); });});Code in the API imports your contract’s exports from @repo/api-contract/product, the entry
product.ts is published under; @repo/api-contract itself exports only the kit’s. Clients import
the same names from @repo/api-client.
A database test proves the tenant boundary and the delete rule against real Postgres, through
the real handlers and their guards. Only the session lookup is replaced. Name it *.db.test.ts and
skip it unless DATABASE_URL is set and RUN_DB_TESTS is 1: the API’s unit tests always set
a placeholder DATABASE_URL, so the second variable is what opts in. CI’s database job runs every
*.db.test.ts that reads RUN_DB_TESTS, against a migrated database. Create
apps/api/src/rpc/notes.db.test.ts:
import { afterAll, beforeAll, describe, expect, it, type Mock, spyOn } from 'bun:test';import type { Session } from '@repo/auth/server';import type { TestPrefixClaim, TestTenant } from '@repo/db';import { sessionFor } from '../../test/sessions';
/** * Notes against real Postgres: a member of one organization cannot reach another's notes, and a * member deletes only their own. Run it from the repository root once the database is migrated: * `RUN_DB_TESTS=1 bun test apps/api/src/rpc/notes.db.test.ts`. */const enabled = Boolean(process.env.DATABASE_URL && process.env.RUN_DB_TESTS === '1');if (enabled) process.env.SKIP_ENV_VALIDATION = '1';
(enabled ? describe : describe.skip)('notes across tenants', () => { let database: typeof import('@repo/db'); let call: typeof import('@orpc/server').call; let notesRouter: typeof import('./notes').notesRouter; let getSession: Mock<typeof import('@repo/auth/server').auth.api.getSession>; let claim: TestPrefixClaim | undefined; let tenants: TestTenant[] = []; let alice: Session; // owner of A let bob: Session; // member of A let mallory: Session; // owner of B let orgB: string; /** Workspace A, named with its own organization. */ let inA: { organizationId: string; workspaceId: string }; const options = { context: { headers: new Headers() } }; const forbidden = { code: 'FORBIDDEN' }; const notFound = { code: 'NOT_FOUND' };
/** The calls that follow run as `session`. */ const signIn = (session: Session) => getSession.mockResolvedValue(session);
beforeAll(async () => { database = await import('@repo/db'); // Removes what an interrupted run left: every id below starts with the prefix. claim = await database.claimTestPrefix('notes-test-'); const { auth } = await import('@repo/auth/server'); ({ call } = await import('@orpc/server')); ({ notesRouter } = await import('./notes')); getSession = spyOn(auth.api, 'getSession');
const a = await database.createTestTenant(claim, { members: { alice: 'owner', bob: 'member' }, workspaces: { a: {} }, }); const b = await database.createTestTenant(claim, { members: { mallory: 'owner' } }); tenants = [a, b]; alice = sessionFor(a.users.alice); bob = sessionFor(a.users.bob); mallory = sessionFor(b.users.mallory); orgB = b.organizationId; inA = { organizationId: a.organizationId, workspaceId: a.workspaces.a }; }, 120_000);
afterAll(async () => { getSession?.mockRestore(); try { // Each organization takes its members, workspaces, notes and audit entries, then its people. for (const tenant of tenants) await tenant.cleanup(); } finally { await claim?.release(); } }, 120_000);
it('lets a member write and read notes, and audits the write', async () => { signIn(bob); const note = await call(notesRouter.create, { ...inA, title: 'Kickoff' }, options); expect(note).toMatchObject({ organizationId: inA.organizationId, authorId: bob.user.id, body: '', }); const listed = await call(notesRouter.list, inA, options); expect(listed.map(({ id }) => id)).toContain(note.id);
const { auditLogs, db, eq } = database; const [entry] = await db.select().from(auditLogs).where(eq(auditLogs.targetId, note.id)); expect(entry).toMatchObject({ action: 'note.create', actorId: bob.user.id }); });
it('answers another organization with FORBIDDEN or NOT_FOUND, never its notes', async () => { signIn(alice); const note = await call(notesRouter.create, { ...inA, title: 'Private' }, options);
signIn(mallory); // An organization she is not a member of. await expect(call(notesRouter.list, inA, options)).rejects.toMatchObject(forbidden); // Her own organization, with the other one's workspace. const elsewhere = { ...inA, organizationId: orgB }; await expect(call(notesRouter.list, elsewhere, options)).rejects.toMatchObject(notFound); await expect( call(notesRouter.create, { ...elsewhere, title: 'Planted' }, options), ).rejects.toMatchObject(notFound); // The note by its own id. const byId = { id: note.id }; await expect(call(notesRouter.delete, byId, options)).rejects.toMatchObject(forbidden);
signIn(alice); const listed = await call(notesRouter.list, inA, options); expect(listed.map(({ title }) => title)).not.toContain('Planted'); expect(listed.map(({ id }) => id)).toContain(note.id); });
it("lets authors delete their own notes, and owners and admins anyone's", async () => { signIn(bob); const own = await call(notesRouter.create, { ...inA, title: 'Mine' }, options); expect(await call(notesRouter.delete, { id: own.id }, options)).toEqual({ id: own.id }); const bobs = await call(notesRouter.create, { ...inA, title: 'Draft' }, options);
signIn(alice); const agenda = await call(notesRouter.create, { ...inA, title: 'Agenda' }, options);
signIn(bob); const alices = { id: agenda.id }; await expect(call(notesRouter.delete, alices, options)).rejects.toMatchObject(forbidden);
signIn(alice); const byId = { id: bobs.id }; expect(await call(notesRouter.delete, byId, options)).toEqual(byId); await expect(call(notesRouter.delete, byId, options)).rejects.toMatchObject(notFound); });});The test claims its prefix before it writes anything, then makes its people, organizations and
workspace under it with createTestTenant, and sessionFor turns a person into the session the
replaced lookup answers. cleanup() deletes each organization and its people afterwards; a run
stopped before its afterAll leaves them behind, and the next run’s claim removes them. So it is
safe to run against your development database. A procedure that takes a list of ids gets a case of
its own here: another organization’s id, and another team’s
(teams: { legal: ['bob'] }, workspaces: { legal: { team: 'legal' } }), answer NOT_FOUND and
leave the foreign row as it was. Run it from the repository root, where Bun reads DATABASE_URL
from .env, with the migration from step 2 applied:
RUN_DB_TESTS=1 bun test apps/api/src/rpc/notes.db.test.tsWithout RUN_DB_TESTS=1 it reports its tests as skipped, which is what bun run test shows.
Check your work
Section titled “Check your work”bun run lint:fix # formats what you pasted; then lint must pass as isbun run lintbun run checkbun run testRUN_DB_TESTS=1 bun test apps/api/src/rpc/notes.db.test.tsbun run test includes apps/api/src/rpc/tenancy.test.ts, which now walks the three notes
procedures too.
Then in the app (bun run dev:app), open a workspace’s Notes tab:
- Submit without a title. The field shows the translated “required” message and no request is made.
- Add a note. It appears at the top of the list;
/app/organization/audit-loglistsnote.create. - Sign in as a member of the organization, with that organization active. The page offers Delete
on the member’s own notes only, and the API answers
FORBIDDENif asked to delete another’s. An owner or admin sees Delete on every note.
Going further
Section titled “Going further”-
REST and OpenAPI come with the contract. The three procedures are already
GETandPOST /api/v1/workspaces/{workspaceId}/notesandDELETE /api/v1/notes/{id}, listed under thenotestag in/api/v1/openapi.jsonand the docs’ interactive API reference.bun run api:openapiwrites the document for client generators. With a bearer token (see API: RPC and REST):Terminal window curl -H "Authorization: Bearer $TOKEN" \"$API_URL/api/v1/workspaces/$WORKSPACE/notes?organizationId=$ORG"The interactive reference picks them up from the OpenAPI document. If you write a prose page for your API, it goes in
apps/docs/src/content/product/reference/api.md, and the API reference links to it. -
API keys reach a procedure only when both layers open it:
.meta({ apiKey: { note: ['read'] } })on the contract, andorgAccessinstead ofauthedin the router. Add the grants a key may hold toproductApiKeyScopesinpackages/auth/src/product-permissions.ts. OnorgAccessthere is nocontext.session:member.userIdisnullfor a key, soauthorId: context.member.userIdstores none and the key, nobody’s author, deletes only withdeleteAny. Open the delete with.meta({ apiKey: { note: ['deleteAny'] } }); its guard names no permission, so a key holdingdeleteAnyreachesmayDelete(member.can, …), which lets it through.auditActor(context)records the key. API keys has the details. -
A webhook event (
note.created) tells your customers’ systems about a new note. List it inPRODUCT_WEBHOOK_EVENT_TYPES(packages/api-contract/src/product-webhooks.ts), type its payload by extendingWebhookEventMapfrom@repo/db, and callemitEvent(note.organizationId, 'note.created', { id: note.id, workspaceId: note.workspaceId })afterrecordAudit. Webhooks has the steps. -
Attachments on a note are stored files. Register a
note.attachmentkind inapps/api/src/files/product.ts, upload withuploadFilefrom#lib/uploads.js, and claim the file withclaimStoredFilein the transaction that inserts thenote_attachmentrow. With theown_stored_filetrigger on that table, in a migration of yours, deleting the note, its workspace or its organization deletes the file too. Removing one attachment is a procedure of yours that deletes the link row, under the samemayDeleterule asnotes.delete. -
A plan limit (“50 notes on Free”) is an entry in
limitDefinitionsin the plan catalog,packages/billing/src/catalog.ts, a kit file you edit (Where your code goes):notes: { scope: 'organization', unit: 'count', label: { one: 'note', other: 'notes' } }. The contract’sPlanLimitsSchema, theLIMIT_REACHEDkinds and/app/billingfollow from it, andbun run checkfails until every plan has a value fornotes,productUsageCounters(packages/db/src/product-usage.ts) counts them, andproductLabels.limits(apps/web/src/lib/product-labels.ts) names the limit in English and German. Then count and insert insidewithLimit(input.organizationId, 'notes', { current, write }), asworkspaces.createdoes inapps/api/src/rpc/router.ts, and spreadLimitReachedErrorinto the procedure’serrors. That is the check itself, so nothing reminds you of it. Adding a limit has the steps, and Selling a feature on a plan gates a feature rather than a count. -
A long list pages with an opaque cursor:
CursorSchemafrom./proceduresin your contract file,paginateanddecodeCursorinapps/api/src/rpc/cursor.ts, asdocuments.listdoes. -
A live feed of a workspace’s notes is an async-generator procedure shaped like
jobs.stream: a slot fromslotCounter, released in afinally, andrefreshMember(context, member, { note: ['read'] }, input.workspaceId)every 30 seconds, ending the stream when it answersnull. The guard checked the workspace once, when the stream opened; passing its id makes each re-check end the stream too once the workspace moves to a team the member is not in. A stream of your own has the shape, and how to test it. -
Editing is one more procedure shaped like
delete. AddupdateandupdateAnyto the statement,updateto every role andupdateAnyto owners and admins, then guard it as the delete is, withorgScopedRow(noteById), and let the handler through only for the author withupdateor anyone withupdateAny:member.can({ note: ['updateAny'] }) || (note.authorId === member.userId && member.can({ note: ['update'] })),errors.FORBIDDEN()otherwise. Then update and audit. If every member may edit every note,updatealone does it, in the guard. -
A role of your own, such as a reviewer who reads notes and writes none, is an entry in
productExtraRoles(packages/auth/src/product-roles.ts) listing every grant it holds, kit and yours, with its label inproductLabels.roles. Roles of your own has the rules. -
Who sees which workspace beyond teams, such as clients who see only their own, is a rule in
packages/db/src/product-tenancy.ts: A visibility rule of your own. -
Opening a workspace at its notes, rather than its overview, is
productWorkspaceHomeinapps/web/src/lib/product-workspace-pages.tsreturning${base}/notes: the sidebar, the home page’s cards, the command palette and a new workspace go there.productHiddenWorkspaceTabsin the same file takes the kit’s Documents or Jobs page out of the workspace’s tabs and the command palette, if notes are all your workspaces hold. Where a workspace opens has the details. -
A page outside a workspace, such as
/app/reports, is a route of its own with a link inproductNavLinks(apps/web/src/lib/product.ts), which the sidebar and the command palette show. Add its first path segment toproductReservedWorkspaceSlugsinpackages/api-contract/src/product-workspace.ts, so that no workspace can be calledreports. A workspace’s address is/app/<slug>, and SvelteKit opens your page at/app/reports, so such a workspace’s overview would be out of reach. -
An organization’s settings of your own, such as
/app/integrations, are a section inproductSettingsSections(apps/web/src/lib/product-settings.ts) instead of a link: the settings dialog lists it after the kit’s sections,needsshows it only to the roles that may open it, and its slug is reserved the same way. Settings of your own has the rules.