Skip to content

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:

CallerListRead, update, or delete a note
Signed out401401
OwnerOnly their notesAllowed
Another accountOnly their own notes403

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

bash
bun beignet make resource notes --authorization
bun run format

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

ResponsibilityFiles
Record ownershipfeatures/notes/schemas.ts, infra/db/schema/notes.ts
Filter lists and persist the ownerfeatures/notes/ports.ts, infra/notes/
Enforce identity and accessfeatures/notes/policy.ts, features/notes/use-cases.ts
Describe HTTP errors and verify behaviorfeatures/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.

features/notes/schemas.ts
infra/db/schema/notes.ts

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.

features/notes/ports.ts

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.

infra/notes/in-memory-note-repository.ts
infra/notes/drizzle-note-repository.ts

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:

typescript
// 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.

features/notes/use-cases.ts

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:

features/notes/contracts.ts

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:

features/notes/tests/notes.test.ts
features/notes/tests/policy.test.ts

Generate and review the migration, then apply it to your local database:

bash
bun run format
bun beignet db generate
bun beignet db migrate
bun beignet db status
bun run format
bun run test
bun beignet check

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

bash
curl -i http://localhost:3000/api/notes

Expect 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.

typescript
// 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();
		}
	});
}
bash
bun run format
bun run test features/notes/tests/ownership.test.ts
bun beignet check

Expect 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 with 403, leaving the note unchanged.
  • Supplying userId in 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 return 404.

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

SymptomCheck
A signed-in request returns 401Send 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 noteCheck 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 notesApply the owner predicate in the repository before pagination, and derive its argument from the authenticated user.
Tests pass even with a broken policySupply 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 failsApply 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

text
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.