Skip to content

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.

New files, all yours:

packages/db/src/schema/notes.ts the table
packages/api-contract/src/notes.ts the procedures and their schemas
apps/api/src/rpc/notes.ts the handlers
apps/web/src/routes/(app)/app/[workspace]/notes/ the page
apps/api/src/rpc/notes.test.ts unit test
apps/api/src/rpc/notes.db.test.ts tenancy test against Postgres

and the product files the kit created for you to fill in:

packages/db/src/schema/product.ts exports the table
packages/db/drizzle-product/ its migration, generated
packages/auth/src/product-permissions.ts which role may read, create and delete notes
packages/api-contract/src/product.ts adds the procedures to the contract
apps/api/src/rpc/product.ts mounts the handlers
apps/web/src/lib/product-workspace.ts the workspace tab
apps/web/messages/{en,de}/product.json the page's strings

You edit no kit file; kit releases never change these. Where your code goes lists every product file and what reads it.

The organization is the tenant. Every procedure that touches a note proves two things before its handler runs:

  1. The caller is a member of the organization, with the permission the action needs. Anyone else gets FORBIDDEN.
  2. 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 checks input.workspaceId against 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 as orgScoped does; 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).

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. authorId is set to NULL instead when the author’s account is deleted: the note belongs to the organization, not to the person.
  • note_workspace_created_idx serves the list (one workspace, newest first). note_organization_idx keeps the cascade from scanning the table when an organization is deleted.
  • Columns are camelCase in TypeScript and snake_case in Postgres (casing in packages/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:generate would 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.

Generate the migration from the schema, read it, then apply it to your development database:

Terminal window
bun run db:generate --name=notes
bun run db:migrate

db: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.

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.

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() })),
},
};
  • protectedProcedure declares UNAUTHORIZED and FORBIDDEN, so every client sees them typed. Each procedure adds the errors it can answer on top: here the NOT_FOUND the tenant guard answers with the message declared for it.
  • isSingleLine refuses line breaks and control characters in the title; isPlainText lets the body keep tabs and line breaks and refuses the rest, U+0000 included, which Postgres cannot store. Both report params.reason, which the app’s forms translate. A refinement of yours does the same with a reason of its own, and its copy goes in productIssueMessages (apps/web/src/lib/product-forms.ts).
  • A delete’s summary names 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 a GET and 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.

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 };
}),
};
  • authed resolves the session (cookie or bearer token) and answers UNAUTHORIZED without one. authed.notes.* exists because the contract declares it, and errors.FORBIDDEN() because the procedure does.
  • The guard runs after authed and after the input is validated, and hands the handler context.member: the caller’s membership, with organizationId, userId, role and can(). orgScopedRow adds the loaded note as context.row. orgScoped also hands on the workspace input.workspaceId names, checked, as context.workspace: a Workspace where the input requires a workspaceId, as notes.list’s does, and Workspace | null where it may be left out, so check it for null there. orgScoped on a procedure whose input has no organizationId does not compile.
  • mayDelete(member.can, 'note', note.authorId, member.userId) lets the author through with note: ['delete'] and anyone with deleteAny. A note whose author’s account is gone (authorId is NULL) needs deleteAny. The guard asks for no permission, so mayDelete alone decides, as in documents.delete: whose note it is, only the handler can tell, and a guard that asked for delete would also refuse an API key, which can hold only deleteAny (Going further).
  • recordAudit writes 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, once productLabels.auditCategories (apps/web/src/lib/product-labels.ts) has note: m.product_audit_category_note. Its metadata.workspaceId keeps the title from a reader of the log who may not see the workspace, who gets the entry with metadata: 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.

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.

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 (.pick keeps them), so the browser refuses exactly what the API would. zodForm translates the refusals; applyApiError puts 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.ts with apiFor(fetch) and seed the query with initialData, as the documents and jobs pages do.
  • canDelete asks mayDelete with the member’s role, the same rule the handler applies with member.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.

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:

Terminal window
RUN_DB_TESTS=1 bun test apps/api/src/rpc/notes.db.test.ts

Without RUN_DB_TESTS=1 it reports its tests as skipped, which is what bun run test shows.

Terminal window
bun run lint:fix # formats what you pasted; then lint must pass as is
bun run lint
bun run check
bun run test
RUN_DB_TESTS=1 bun test apps/api/src/rpc/notes.db.test.ts

bun 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:

  1. Submit without a title. The field shows the translated “required” message and no request is made.
  2. Add a note. It appears at the top of the list; /app/organization/audit-log lists note.create.
  3. 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 FORBIDDEN if asked to delete another’s. An owner or admin sees Delete on every note.
  • REST and OpenAPI come with the contract. The three procedures are already GET and POST /api/v1/workspaces/{workspaceId}/notes and DELETE /api/v1/notes/{id}, listed under the notes tag in /api/v1/openapi.json and the docs’ interactive API reference. bun run api:openapi writes 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, and orgAccess instead of authed in the router. Add the grants a key may hold to productApiKeyScopes in packages/auth/src/product-permissions.ts. On orgAccess there is no context.session: member.userId is null for a key, so authorId: context.member.userId stores none and the key, nobody’s author, deletes only with deleteAny. Open the delete with .meta({ apiKey: { note: ['deleteAny'] } }); its guard names no permission, so a key holding deleteAny reaches mayDelete(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 in PRODUCT_WEBHOOK_EVENT_TYPES (packages/api-contract/src/product-webhooks.ts), type its payload by extending WebhookEventMap from @repo/db, and call emitEvent(note.organizationId, 'note.created', { id: note.id, workspaceId: note.workspaceId }) after recordAudit. Webhooks has the steps.

  • Attachments on a note are stored files. Register a note.attachment kind in apps/api/src/files/product.ts, upload with uploadFile from #lib/uploads.js, and claim the file with claimStoredFile in the transaction that inserts the note_attachment row. With the own_stored_file trigger 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 same mayDelete rule as notes.delete.

  • A plan limit (“50 notes on Free”) is an entry in limitDefinitions in 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’s PlanLimitsSchema, the LIMIT_REACHED kinds and /app/billing follow from it, and bun run check fails until every plan has a value for notes, productUsageCounters (packages/db/src/product-usage.ts) counts them, and productLabels.limits (apps/web/src/lib/product-labels.ts) names the limit in English and German. Then count and insert inside withLimit(input.organizationId, 'notes', { current, write }), as workspaces.create does in apps/api/src/rpc/router.ts, and spread LimitReachedError into the procedure’s errors. 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: CursorSchema from ./procedures in your contract file, paginate and decodeCursor in apps/api/src/rpc/cursor.ts, as documents.list does.

  • A live feed of a workspace’s notes is an async-generator procedure shaped like jobs.stream: a slot from slotCounter, released in a finally, and refreshMember(context, member, { note: ['read'] }, input.workspaceId) every 30 seconds, ending the stream when it answers null. 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. Add update and updateAny to the statement, update to every role and updateAny to owners and admins, then guard it as the delete is, with orgScopedRow(noteById), and let the handler through only for the author with update or anyone with updateAny: 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, update alone 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 in productLabels.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 productWorkspaceHome in apps/web/src/lib/product-workspace-pages.ts returning ${base}/notes: the sidebar, the home page’s cards, the command palette and a new workspace go there. productHiddenWorkspaceTabs in 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 in productNavLinks (apps/web/src/lib/product.ts), which the sidebar and the command palette show. Add its first path segment to productReservedWorkspaceSlugs in packages/api-contract/src/product-workspace.ts, so that no workspace can be called reports. 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 in productSettingsSections (apps/web/src/lib/product-settings.ts) instead of a link: the settings dialog lists it after the kit’s sections, needs shows it only to the roles that may open it, and its slug is reserved the same way. Settings of your own has the rules.