Protect a resource
Build a private Notes API in the SQLite starter. Each account can create notes, list its own notes, and read, update, or delete only the notes it owns. You will verify the rules through use cases and HTTP against both repository adapters.
Before you start
Use the Next.js and SQLite app from Quickstart, with working sign-in and Todos. Run commands from the app root. You do not need the Projects tutorial, a Notes screen, or another provider.
This guide creates a new notes resource. If your app already has that feature,
choose another name and adapt the examples; do not regenerate over its source.
For an existing resource, preserve its authorization and data model and use
Adapt this to your app.
The access rules will be:
| Caller | List | Read, update, or delete a note |
|---|---|---|
| Signed out | 401 | 401 |
| Owner | Only their notes | Allowed |
| Another account | Only their own notes | 403 |
A signed-in caller receives 404 for a missing ID and 403 for someone else's
note. That distinction reveals whether an ID exists. If your application must
hide that, use a consistent 404 response for inaccessible and missing records.
Generate the resource
bun beignet make resource notes --authorization
bun run formatThe generator creates contracts, use cases, a policy, repository adapters, and tests. It registers the policy with the app's gate and adds authorization checks to mutation use cases. The generated policy requires a signed-in actor for writes; it does not assign ownership, filter lists, or protect reads. Complete the edits below before exposing this resource to users.
Keep the generated registrations in ports/index.ts, infra/port-wiring.ts,
infra/db/repositories.ts, and server/routes.ts. Your changes belong in:
| Responsibility | Files |
|---|---|
| Record ownership | features/notes/schemas.ts, infra/db/schema/notes.ts |
| Filter lists and persist the owner | features/notes/ports.ts, infra/notes/ |
| Enforce identity and access | features/notes/policy.ts, features/notes/use-cases.ts |
| Describe HTTP errors and verify behavior | features/notes/contracts.ts, features/notes/tests/ |
Store the owner
Add userId to the response schema and database table. Keep it out of
CreateNoteInputSchema and UpdateNoteBodySchema: callers supply a name, and
the use case chooses the owner from the session.
2 unmodified lines34567892 unmodified linesexport const NoteSchema = z.object({ id: z.uuid(), name: z.string().min(1), userId: z.string().min(1), version: z.number().int().min(1), createdAt: z.string().datetime(), updatedAt: z.string().datetime(),2 unmodified lines34567892 unmodified linesexport const notes = sqliteTable("notes", { id: text("id").primaryKey(), name: text("name").notNull(), userId: text("user_id").notNull(), version: integer("version").notNull(), createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(),The column is required because this is a new, empty table. For an existing table, plan an owner backfill before making the column required; do not assign all existing records to whichever account runs the migration.
Filter lists in the repository
Make the owner a required argument to list and a required field in the
repository's create input. The repository port accepts application data, while
the HTTP input remains limited to the fields callers may set.
21 unmodified lines2223242526252627282921 unmodified lines};
export interface NoteRepository { list(query: ListNotesQuery): Promise<ListNotesResult>; create(input: CreateNoteInput): Promise<Note>; list(query: ListNotesQuery, userId: string): Promise<ListNotesResult>; create(input: CreateNoteInput & { userId: string }): Promise<Note>; findById(id: string): Promise<Note | null>; update(input: UpdateNoteInput): Promise<Note | null>; delete(id: string): Promise<boolean>;Persist and return userId in both adapters. Filter by owner before sorting
and taking a page, so another account's records cannot occupy page slots or
leak through search results.
11 unmodified lines1213141516171852 unmodified lines717273737475767778798010 unmodified lines9192939495969711 unmodified lines return { id: note.id, name: note.name, userId: note.userId, version: note.version, createdAt: note.createdAt, updatedAt: note.updatedAt,52 unmodified lines );
return { async list(query) { async list(query, userId) { const name = query.name?.toLocaleLowerCase(); const allNotes = Array.from(notes.values()) .filter((note) => note.userId === userId) .filter((note) => !name || note.name.toLocaleLowerCase().includes(name)) .sort((left, right) => compareNotes(left, right, query)) .filter((note) => isAfterNoteCursor(note, query));10 unmodified lines const note: Note = { id: crypto.randomUUID(), name: input.name, userId: input.userId, version: 1, createdAt: now, updatedAt: now,16 unmodified lines1718192021222321 unmodified lines4546474748484950515253545524 unmodified lines8081827980838485868715 unmodified lines10310410510610710810916 unmodified lines return { id: row.id, name: row.name, userId: row.userId, version: row.version, createdAt: row.createdAt, updatedAt: row.updatedAt,21 unmodified lines ) as SQL<unknown>;}
function noteListWhere(query: ListNotesQuery): SQL<unknown> | undefined { const filters: SQL<unknown>[] = [];function noteListWhere( query: ListNotesQuery, userId: string,): SQL<unknown> | undefined { const filters: SQL<unknown>[] = [eq(schema.notes.userId, userId)]; const cursor = noteCursorFilter(query);
if (query.name) {24 unmodified lines db: DrizzleSqliteDatabase<typeof schema>,): NoteRepository { return { async list(query) { const where = noteListWhere(query); async list(query, userId) { const where = noteListWhere(query, userId); const rows = await db .select() .from(schema.notes)15 unmodified lines .values({ id: crypto.randomUUID(), name: input.name, userId: input.userId, version: 1, createdAt: now, updatedAt: now,Keep the existing name filter, cursor predicates, and version checks.
findById still loads by ID; the use cases below authorize the loaded record
before returning or changing it. Repository methods are internal dependencies,
not entrypoints for callers to bypass those use cases.
Define the ownership policy
Replace the generated policy with this complete file. The same ownership rule applies to reading, updating, and deleting a note:
// features/notes/policy.ts
import { type ActivityActor, definePolicy, deny } from "@beignet/core/ports";
import type { Note } from "./schemas";
export type AuthorizationContext = { actor: ActivityActor };
function signedIn(ctx: AuthorizationContext) {
return ctx.actor.type === "user" || deny("You must be signed in.");
}
function ownsNote(ctx: AuthorizationContext, note: Note) {
if (ctx.actor.type !== "user") return deny("You must be signed in.");
return (
ctx.actor.id === note.userId || deny("Only the owner can access this note.")
);
}
export const notePolicy = definePolicy({
"notes.create": signedIn,
"notes.view": ownsNote,
"notes.update": ownsNote,
"notes.delete": ownsNote,
});The policy receives the current actor and the stored note. Neither the browser
nor the request body gets to choose the owner used for this comparison.
The generated gate maps a denied decision to the app's Forbidden catalog error.
Enforce access in every use case
Use the starter's requireUser helper to require a session before any read or
write. It throws the app's Unauthorized error when the caller is signed out.
Pass that user's ID into list and create operations, and authorize individual
notes after loading them.
1234513 unmodified lines19202122232425262728262728293031322930313233343536373839401 unmodified line42434445464742484950513 unmodified lines555657585960611 unmodified line636465666768692 unmodified lines7273747576777825 unmodified lines104105106107108109110import "@beignet/core/server-only";import { requireUser } from "@/lib/auth";import { normalizeCursorPage } from "@beignet/core/pagination";import { useCase } from "../../lib/use-case";import {13 unmodified lines .input(ListNotesInputSchema) .output(ListNotesOutputSchema) .run(async ({ ctx, input }) => { const user = requireUser(ctx); const page = normalizeCursorPage(input, { defaultLimit: 20, maxLimit: 100, });
return ctx.ports.notes.list( return ctx.ports.notes.list({ page, cursor: page.cursor ? decodeNoteCursor(page.cursor) : null, name: input.name, sortBy: input.sortBy, sortDirection: input.sortDirection, }); { page, cursor: page.cursor ? decodeNoteCursor(page.cursor) : null, name: input.name, sortBy: input.sortBy, sortDirection: input.sortDirection, }, user.id, ); });
export const createNoteUseCase = useCase1 unmodified line .input(CreateNoteInputSchema) .output(NoteSchema) .run(async ({ ctx, input }) => { const user = requireUser(ctx); await ctx.gate.authorize("notes.create");
const note = await ctx.ports.notes.create(input); const note = await ctx.ports.notes.create({ ...input, userId: user.id });
return note; });3 unmodified lines .input(NoteIdInputSchema) .output(NoteSchema) .run(async ({ ctx, input }) => { requireUser(ctx); const note = await ctx.ports.notes.findById(input.id); if (!note) { throw appError("NoteNotFound", {1 unmodified line }); }
await ctx.gate.authorize("notes.view", note); return note; });
2 unmodified lines .input(UpdateNoteInputSchema) .output(NoteSchema) .run(async ({ ctx, input }) => { requireUser(ctx); const existing = await ctx.ports.notes.findById(input.id); if (!existing) { throw appError("NoteNotFound", {25 unmodified lines .input(NoteIdInputSchema) .output(z.void()) .run(async ({ ctx, input }) => { requireUser(ctx); const existing = await ctx.ports.notes.findById(input.id); if (!existing) { throw appError("NoteNotFound", {Keep the generated update and delete gate calls. Update authorization runs before the version-conflict check, so another account cannot use a stale version to learn the note's current version. The existing optimistic concurrency check continues to reject stale writes by the owner.
These rules live in use cases so a direct call from a script or another workflow cannot bypass them. A service actor does not become a note owner automatically; background work needs its own explicit authorization design.
Describe the protected HTTP reads
The generator already declares authentication and catalog errors on create, update, and delete contracts. Add them to list and get as well:
35 unmodified lines363738394041424313 unmodified lines5758595860616263646566676835 unmodified lines sortDirection: query.string(), }), ) .meta({ auth: "required" }) .errors({ Unauthorized: errors.Unauthorized }) .responses({ 200: ListNotesOutputSchema, });13 unmodified linesexport const getNote = notes .get("/api/notes/:id") .pathParams(NoteIdInputSchema) .errors({ NoteNotFound: errors.NoteNotFound }) .meta({ auth: "required", authorization: { ability: "notes.view" } }) .errors({ Unauthorized: errors.Unauthorized, Forbidden: errors.Forbidden, NoteNotFound: errors.NoteNotFound, }) .responses({ 200: NoteSchema, });The starter's server/context.ts resolves the session for each request.
Contract metadata documents the requirements; it does not replace the identity
and policy checks in the use cases. Keep route handlers as the generated thin
bindings to those use cases.
Update fixtures and migrate
Keep the generated CRUD and policy tests. Give their seeded notes an owner and supply the same user in the use-case test's auth session. Extend the policy matrix to cover the protected read as well:
35 unmodified lines36373839403 unmodified lines44454647483 unmodified lines525354555626 unmodified lines838485868735 unmodified lines id: "00000000-0000-4000-8000-000000000101", name: "Alpha Note", userId: "user_test", version: 1, createdAt: "2024-01-01T00:00:00.000Z",3 unmodified lines id: "00000000-0000-4000-8000-000000000102", name: "Beta Note", userId: "user_test", version: 1, createdAt: "2024-01-02T00:00:00.000Z",3 unmodified lines id: "00000000-0000-4000-8000-000000000103", name: "Gamma Note", userId: "user_test", version: 1, createdAt: "2024-01-03T00:00:00.000Z",26 unmodified lines AppContext["ports"] >({ auth: { user: { id: "user_test", name: "Test User" } }, ports: testFixture.ports, actor: createTestUserActor("user_test"),7 unmodified lines8910111213141 unmodified line161718181920212211 unmodified lines34353636373839404142434445464748495043515253547 unmodified lines return { id: "00000000-0000-4000-8000-000000000001", name: "Test Note", userId: "user_test", version: 1, createdAt: now, updatedAt: now,1 unmodified line}
describe("notePolicy", () => { it("documents generated notes authorization rules", async () => { it("allows the owner to create, read, update, and delete notes", async () => { const tester = createPolicyTester({ policies: [notePolicy], });11 unmodified lines expected: "allow", }, { name: "authenticated actor can update note", name: "owner can read note", ctx, ability: "notes.view", subject: note, expected: "allow", }, { name: "owner can update note", ctx, ability: "notes.update", subject: note, expected: "allow", }, { name: "authenticated actor can delete note", name: "owner can delete note", ctx, ability: "notes.delete", subject: note,Generate and review the migration, then apply it to your local database:
bun run format
bun beignet db generate
bun beignet db migrate
bun beignet db status
bun run format
bun run test
bun beignet checkExpect the database status to be current and the generated tests to pass. Commit
the migration with the application change. Restart bun run dev if it is already
running, then check the HTTP boundary without a session cookie:
curl -i http://localhost:3000/api/notesExpect 401. Sign in through the app and open
localhost:3000/api/notes in the same browser.
Expect a successful response with an empty items array for the new account.
The next test exercises creation and access by a second account without needing
a Notes UI.
Verify owner, other-account, and anonymous access
Add this test. It uses the real policy, use cases, contracts, and context blueprint. It supplies a test auth port for each identity and repeats the assertions against memory and an isolated SQLite database using your checked-in migrations.
// features/notes/tests/ownership.test.ts
import { createUseCaseTester } from "@beignet/core/application";
import {
createAnonymousActor,
createStaticAuth,
createUserActor,
} from "@beignet/core/ports";
import { defineRoutes } from "@beignet/core/server";
import {
createTestContextFactory,
createTestPorts,
} from "@beignet/core/testing";
import { createTestApp } from "@beignet/web/testing";
import type { AppContext } from "@/app-context";
import { createTestDatabase } from "@/infra/db/test-database";
import { createInMemoryNoteRepository } from "@/infra/notes/in-memory-note-repository";
import { initialPorts } from "@/infra/port-wiring";
import { expect, test } from "@/lib/beignet-test";
import { appContext } from "@/server/context";
import {
createNote,
deleteNote,
getNote,
listNotes,
updateNote,
} from "../contracts";
import type { NoteRepository } from "../ports";
import { noteRoutes } from "../routes";
import {
createNoteUseCase,
deleteNoteUseCase,
getNoteUseCase,
listNotesUseCase,
updateNoteUseCase,
} from "../use-cases";
function fixture(userId: string | null, notes: NoteRepository) {
const auth = userId ? { user: { id: userId, name: userId } } : null;
const { ports } = createTestPorts<AppContext["ports"]>({
base: initialPorts,
overrides: { notes, gate: initialPorts.gate, auth: createStaticAuth(auth) },
});
const context = createTestContextFactory<AppContext, AppContext["ports"]>({
ports,
actor: userId ? createUserActor(userId) : createAnonymousActor(),
auth,
});
return { ports, tester: createUseCaseTester<AppContext>(context) };
}
for (const backend of ["memory", "sqlite"]) {
test(`${backend}: ownership holds in use cases and HTTP`, async () => {
const database = backend === "sqlite" ? await createTestDatabase() : null;
const notes =
database?.repositories.notes ?? createInMemoryNoteRepository();
const owner = fixture("owner", notes);
const other = fixture("other", notes);
const anonymous = fixture(null, notes);
const openApp = (ports: AppContext["ports"]) =>
createTestApp({
ports,
context: appContext,
routes: defineRoutes<AppContext>([noteRoutes]),
});
const [ownerApp, otherApp, anonymousApp] = await Promise.all([
openApp(owner.ports),
openApp(other.ports),
openApp(anonymous.ports),
]);
try {
const note = await owner.tester.run(createNoteUseCase, { name: "Alpha" });
const theirs = await other.tester.run(createNoteUseCase, {
name: "Beta",
});
const last = await owner.tester.run(createNoteUseCase, { name: "Gamma" });
expect(note.userId).toBe("owner");
const first = await owner.tester.run(listNotesUseCase, {
limit: 1,
sortBy: "name",
sortDirection: "asc",
});
expect(first.items).toEqual([note]);
expect(first.page.hasMore).toBe(true);
const second = await owner.tester.run(listNotesUseCase, {
limit: 1,
sortBy: "name",
sortDirection: "asc",
cursor: first.page.nextCursor!,
});
expect(second.items).toEqual([last]);
expect(second.page.hasMore).toBe(false);
expect(
(await owner.tester.run(listNotesUseCase, { name: "Beta" })).items,
).toEqual([]);
await expect(
anonymous.tester.run(listNotesUseCase, {}),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
await expect(
anonymous.tester.run(createNoteUseCase, { name: "Blocked" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
for (const [caller, code] of [
[anonymous, "UNAUTHORIZED"],
[other, "FORBIDDEN"],
] as const) {
await expect(
caller.tester.run(getNoteUseCase, { id: note.id }),
).rejects.toMatchObject({ code });
await expect(
caller.tester.run(updateNoteUseCase, {
id: note.id,
name: "Changed",
version: 999,
}),
).rejects.toMatchObject({ code });
await expect(
caller.tester.run(deleteNoteUseCase, { id: note.id }),
).rejects.toMatchObject({ code });
}
expect(await notes.findById(note.id)).toEqual(note);
expect(
await anonymousApp.safeRequest(listNotes, { query: {} }),
).toMatchObject({ ok: false, status: 401 });
expect(
await anonymousApp.safeRequest(createNote, {
body: { name: "Blocked" },
}),
).toMatchObject({ ok: false, status: 401 });
for (const [app, status] of [
[anonymousApp, 401],
[otherApp, 403],
] as const) {
for (const method of ["GET", "PATCH", "DELETE"]) {
const response = await app.fetch(
new Request(`http://beignet.test/api/notes/${note.id}`, {
method,
headers: { "content-type": "application/json" },
body:
method === "PATCH"
? JSON.stringify({ name: "Changed", version: 999 })
: undefined,
}),
);
expect(response.status).toBe(status);
}
}
expect((await otherApp.request(listNotes, { query: {} })).items).toEqual([
theirs,
]);
expect(await notes.findById(note.id)).toEqual(note);
expect(
await ownerApp.request(getNote, { path: { id: note.id } }),
).toEqual(note);
const forged = await ownerApp.fetch(
new Request("http://beignet.test/api/notes", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "Forged owner", userId: "other" }),
}),
);
expect(forged.status).toBe(201);
expect(await forged.json()).toMatchObject({ userId: "owner" });
const updated = await ownerApp.request(updateNote, {
path: { id: note.id },
body: { name: "Updated", version: note.version },
});
expect(updated).toMatchObject({
name: "Updated",
userId: "owner",
version: note.version + 1,
});
expect(
await ownerApp.safeRequest(updateNote, {
path: { id: note.id },
body: { name: "Stale", version: note.version },
}),
).toMatchObject({ ok: false, status: 409 });
await ownerApp.request(deleteNote, { path: { id: note.id } });
expect(
await ownerApp.safeRequest(getNote, { path: { id: note.id } }),
).toMatchObject({ ok: false, status: 404 });
} finally {
await Promise.all([
ownerApp.stop(),
otherApp.stop(),
anonymousApp.stop(),
]);
await database?.close();
}
});
}bun run format
bun run test features/notes/tests/ownership.test.ts
bun beignet checkExpect both ownership tests to pass. They verify that:
- Lists and name filters return only the current account's notes, including across cursor pages.
- Anonymous calls fail with
401; another account's direct reads, updates, and deletes fail with403, leaving the note unchanged. - Supplying
userIdin a create body cannot assign the note to another account. - The owner can read, update, and delete; stale updates return
409, and reads after deletion return404.
The tests run the HTTP pipeline with controlled sessions. They do not exercise Better Auth's sign-in cookies; the browser check above uses your running app's real session. See Test a feature for more testing patterns.
As a local regression check, temporarily remove the notes.update gate call
from updateNoteUseCase. The new tests should fail. Restore the call immediately
and rerun the checks before committing.
Troubleshooting
| Symptom | Check |
|---|---|
A signed-in request returns 401 | Send the app's session cookie. A terminal does not share the browser session; verify the auth provider and server/context.ts in a customized app. |
| Another account can read or change a note | Check that get, update, and delete authorize the stored note through the real policy. Authentication alone permits every signed-in account. |
| Lists or search expose other notes | Apply the owner predicate in the repository before pagination, and derive its argument from the authenticated user. |
| Tests pass even with a broken policy | Supply gate: initialPorts.gate so the fixture uses the app's real gate. Test doubles must not silently allow access. |
| The required owner column or fixture fails | Apply the migration and add userId to seeded records. Existing data needs an explicit owner backfill. |
Adapt this to your app
Keep existing auth setup and error mapping. Use your feature's owner field and repository, and preserve its transaction, pagination, and concurrency behavior. When adding the rule to an existing app, update every entrypoint that can read or mutate the resource, including exports and background workflows.
For workspace-owned records, use tenant-scoped repositories and membership-aware policies. Individual ownership, workspace membership, and roles are different rules; a caller-supplied workspace ID is not evidence of access. See Authorization for policy composition and gate APIs.
Keep ownership immutable unless you deliberately add a transfer workflow. Review indexes for owner-filtered queries, foreign keys, and account-deletion behavior when integrating the schema into your application's data lifecycle.
Work with your agent
Read https://beignetjs.com/markdown/agent-instructions.md and this app's local
instructions. Follow https://beignetjs.com/markdown/guides/protect-a-resource.md.
Identify the resource, auth setup, owner field, policy, repositories, and tests.
For a fresh starter, generate notes with --authorization and follow the guide.
For an existing resource, preserve its data, custom rules, and transaction wiring;
do not regenerate over it. Use installed package guidance when versions differ.
Derive the owner from the session, filter lists before pagination, and authorize
stored records in get, update, and delete use cases. Keep ownership out of caller
inputs. Test the owner, another account, and anonymous access through both use
cases and HTTP, including pagination, forged owner input, unchanged denied writes,
and SQLite persistence. Confirm the tests catch a removed ownership check, then
restore it. Run format and beignet check. Explain changed files, migration needs,
failed or skipped checks, and how I can repeat the verification.Continue with Test a feature, or return to Guides to choose another task. For a guided UI built on these concepts, follow the Projects tutorial.