Build your first feature: ownership

Stage 2 of 3. Continue in the app from 1. Build the API. Its projects API should already store descriptions and pass its tests. Keep the existing database and rows.

By the end of this stage, signed-out callers are rejected and each account can read and change only its own projects. You will verify those rules through use-case and HTTP tests before adding screens in 3. Connect the UI.

FilesChange
features/projects/schemas.ts and infra/db/schema/projects.tsAdd the owner field
features/projects/ports.ts and infra/projects/*-project-repository.tsAssign owners and filter lists
features/projects/policy.ts, ports/index.ts, and infra/port-wiring.tsDefine and register the ownership rule
features/projects/use-cases/ and contracts.tsEnforce identity and declare authorization errors
features/projects/tests/Verify access through both adapters and HTTP
drizzle/Generate and apply the owner-column migration

Give projects an owner

The API responds, but the generated resource has no authorization rules yet. Add them before making projects available to users. Continue editing this resource; do not rerun the generator over your changes.

Organize the files after the API edits before following the next patches:

bunx @biomejs/biome check --write .

The server chooses the owner from the authenticated session. Clients send a name and description; they cannot assign or transfer ownership.

CallerListRead, update, or delete an existing project
Signed out401401
OwnerTheir own projectsAllowed
Another userTheir own projects403

We return 403 for a known project owned by someone else. The response contains no project fields. A missing ID returns 404.

Add userId to the response schema and database table. It is nullable because projects created while trying the API do not have owners yet. Those old rows stay in the database, appear in nobody's list, and fail ownership checks. New projects always get the signed-in user's ID. Assigning legacy rows to accounts would be a separate, deliberate data migration.

Add an optional description to the update body too. Omitting it preserves the stored description; sending an empty string clears its text.

--- a/features/projects/schemas.ts
+++ b/features/projects/schemas.ts
@@ -5,4 +5,5 @@
   name: z.string().min(1),
   description: z.string().nullable(),
+  userId: z.string().nullable(),
   version: z.number().int().min(1),
   createdAt: z.string().datetime(),
@@ -106,4 +107,5 @@
 export const UpdateProjectBodySchema = z.object({
   name: z.string().trim().min(1).max(120),
+  description: z.string().max(500).optional(),
   version: z.number().int().min(1),
 });
--- a/infra/db/schema/projects.ts
+++ b/infra/db/schema/projects.ts
@@ -5,4 +5,5 @@
   name: text("name").notNull(),
   description: text("description"),
+  userId: text("user_id"),
   version: integer("version").notNull(),
   createdAt: text("created_at").notNull(),

Pass ownership through the port

NewProject is an internal repository input. The HTTP create schema still has no userId. The list method requires the user's ID separately from client-supplied filters, so the repository filters by owner before applying pagination.

--- a/features/projects/ports.ts
+++ b/features/projects/ports.ts
@@ -22,7 +22,9 @@
 };

+export type NewProject = CreateProjectInput & { userId: string };
+
 export interface ProjectRepository {
-  list(query: ListProjectsQuery): Promise<ListProjectsResult>;
-  create(input: CreateProjectInput): Promise<Project>;
+  list(query: ListProjectsQuery, userId: string): Promise<ListProjectsResult>;
+  create(input: NewProject): Promise<Project>;
   findById(id: string): Promise<Project | null>;
   update(input: UpdateProjectInput): Promise<Project | null>;

Update both implementations of that port. Keep the generated cursor, sort, and version checks; add ownership to create and list, and description to update.

Update the SQLite and in-memory adapters
--- a/infra/projects/drizzle-project-repository.ts
+++ b/infra/projects/drizzle-project-repository.ts
@@ -19,4 +19,5 @@
     name: row.name,
     description: row.description,
+    userId: row.userId,
     version: row.version,
     createdAt: row.createdAt,
@@ -50,6 +51,9 @@
 }

-function projectListWhere(query: ListProjectsQuery): SQL<unknown> | undefined {
-  const filters: SQL<unknown>[] = [];
+function projectListWhere(
+  query: ListProjectsQuery,
+  userId: string,
+): SQL<unknown> | undefined {
+  const filters: SQL<unknown>[] = [eq(schema.projects.userId, userId)];
   const cursor = projectCursorFilter(query);

@@ -82,6 +86,6 @@
 ): ProjectRepository {
   return {
-    async list(query) {
-      const where = projectListWhere(query);
+    async list(query, userId) {
+      const where = projectListWhere(query, userId);
       const rows = await db
         .select()
@@ -106,4 +110,5 @@
           name: input.name,
           description: input.description ?? null,
+          userId: input.userId,
           version: 1,
           createdAt: now,
@@ -132,4 +137,5 @@
         .set({
           name: input.name,
+          description: input.description,
           version: input.version + 1,
           updatedAt: new Date().toISOString(),
--- a/infra/projects/in-memory-project-repository.ts
+++ b/infra/projects/in-memory-project-repository.ts
@@ -14,4 +14,5 @@
     name: project.name,
     description: project.description,
+    userId: project.userId,
     version: project.version,
     createdAt: project.createdAt,
@@ -72,7 +73,8 @@

   return {
-    async list(query) {
+    async list(query, userId) {
       const name = query.name?.toLocaleLowerCase();
       const allProjects = Array.from(projects.values())
+        .filter((project) => project.userId === userId)
         .filter(
           (project) => !name || project.name.toLocaleLowerCase().includes(name),
@@ -94,4 +96,5 @@
         name: input.name,
         description: input.description ?? null,
+        userId: input.userId,
         version: 1,
         createdAt: now,
@@ -114,4 +117,5 @@
         ...existing,
         name: input.name,
+        description: input.description ?? existing.description,
         version: existing.version + 1,
         updatedAt: new Date().toISOString(),

Define the ownership rule

Create features/projects/policy.ts:

// features/projects/policy.ts
import { definePolicy, deny } from "@beignet/core/ports";
import type { AuthSession } from "@/ports/auth";
import type { Project } from "./schemas";

export const projectPolicy = definePolicy({
  "projects.access": (ctx: { auth: AuthSession | null }, project: Project) => {
    if (ctx.auth && project.userId === ctx.auth.user.id) return true;
    return deny("Only the owner can access this project.");
  },
});

Register the policy in both the gate's type and runtime configuration. Keep the existing todo policy and the gate's onDeny handler, which maps denied decisions to the app's Forbidden catalog error.

--- a/ports/index.ts
+++ b/ports/index.ts
@@ -7,4 +7,5 @@
   UnitOfWorkPort,
 } from "@beignet/core/ports";
+import type { projectPolicy } from "@/features/projects/policy";
 import type { AuthorizationContext, todoPolicy } from "@/features/todos/policy";
 import type { TodoRepository } from "@/features/todos/ports";
@@ -18,10 +19,13 @@
 };

-export type AppGate = BoundGate<[typeof todoPolicy]>;
+export type AppGate = BoundGate<[typeof todoPolicy, typeof projectPolicy]>;

 export type AppPorts = {
   auth: AuthPort;
   errorReporter: ErrorReporterPort;
-  gate: GatePort<AuthorizationContext, [typeof todoPolicy]>;
+  gate: GatePort<
+    AuthorizationContext,
+    [typeof todoPolicy, typeof projectPolicy]
+  >;
   idempotency: IdempotencyPort;
   logger: LoggerPort;
--- a/infra/port-wiring.ts
+++ b/infra/port-wiring.ts
@@ -1,4 +1,5 @@
 import { createNoopErrorReporter } from "@beignet/core/error-reporting";
 import { createGate, definePorts } from "@beignet/core/ports";
+import { projectPolicy } from "@/features/projects/policy";
 import { appError } from "@/features/shared/errors";
 import { todoPolicy } from "@/features/todos/policy";
@@ -6,5 +7,5 @@

 const gate = createGate({
-  policies: [todoPolicy],
+  policies: [todoPolicy, projectPolicy],
   onDeny(decision) {
     return appError("Forbidden", {

Enforce it in every use case

Require a session inside all five use cases, so direct calls from scripts or tests have the same requirement as HTTP calls. requireUserId(ctx) throws a framework 401 when there is no session.

Create attaches that ID to the repository input. List passes it to the owner filter. Read, update, and delete load the project and authorize access before returning fields or writing anything. Update checks ownership before reporting version conflicts.

Update the five use cases
--- a/features/projects/use-cases/create-project.ts
+++ b/features/projects/use-cases/create-project.ts
@@ -1,3 +1,4 @@
 import "@beignet/core/server-only";
+import { requireUserId } from "@beignet/core/ports";
 import { useCase } from "../../../lib/use-case";
 import { CreateProjectInputSchema, ProjectSchema } from "../schemas";
@@ -8,5 +9,6 @@
   .output(ProjectSchema)
   .run(async ({ ctx, input }) => {
-    const project = await ctx.ports.projects.create(input);
+    const userId = requireUserId(ctx);
+    const project = await ctx.ports.projects.create({ ...input, userId });

     return project;
--- a/features/projects/use-cases/list-projects.ts
+++ b/features/projects/use-cases/list-projects.ts
@@ -1,4 +1,5 @@
 import "@beignet/core/server-only";
 import { normalizeCursorPage } from "@beignet/core/pagination";
+import { requireUserId } from "@beignet/core/ports";
 import { useCase } from "../../../lib/use-case";
 import {
@@ -13,4 +14,5 @@
   .output(ListProjectsOutputSchema)
   .run(async ({ ctx, input }) => {
+    const userId = requireUserId(ctx);
     const page = normalizeCursorPage(input, {
       defaultLimit: 20,
@@ -18,10 +20,13 @@
     });

-    return ctx.ports.projects.list({
-      page,
-      cursor: page.cursor ? decodeProjectCursor(page.cursor) : null,
-      name: input.name,
-      sortBy: input.sortBy,
-      sortDirection: input.sortDirection,
-    });
+    return ctx.ports.projects.list(
+      {
+        page,
+        cursor: page.cursor ? decodeProjectCursor(page.cursor) : null,
+        name: input.name,
+        sortBy: input.sortBy,
+        sortDirection: input.sortDirection,
+      },
+      userId,
+    );
   });
--- a/features/projects/use-cases/get-project.ts
+++ b/features/projects/use-cases/get-project.ts
@@ -1,3 +1,4 @@
 import "@beignet/core/server-only";
+import { requireUserId } from "@beignet/core/ports";
 import { useCase } from "../../../lib/use-case";
 import { appError } from "../../shared/errors";
@@ -9,4 +10,5 @@
   .output(ProjectSchema)
   .run(async ({ ctx, input }) => {
+    requireUserId(ctx);
     const project = await ctx.ports.projects.findById(input.id);
     if (!project) {
@@ -16,4 +18,5 @@
     }

+    await ctx.gate.authorize("projects.access", project);
     return project;
   });
--- a/features/projects/use-cases/update-project.ts
+++ b/features/projects/use-cases/update-project.ts
@@ -1,3 +1,4 @@
 import "@beignet/core/server-only";
+import { requireUserId } from "@beignet/core/ports";
 import { useCase } from "../../../lib/use-case";
 import { appError } from "../../shared/errors";
@@ -9,4 +10,5 @@
   .output(ProjectSchema)
   .run(async ({ ctx, input }) => {
+    requireUserId(ctx);
     const existing = await ctx.ports.projects.findById(input.id);
     if (!existing) {
@@ -15,4 +17,6 @@
       });
     }
+    await ctx.gate.authorize("projects.access", existing);
+
     if (existing.version !== input.version) {
       throw appError("ProjectConflict", {
--- a/features/projects/use-cases/delete-project.ts
+++ b/features/projects/use-cases/delete-project.ts
@@ -1,3 +1,4 @@
 import "@beignet/core/server-only";
+import { requireUserId } from "@beignet/core/ports";
 import { z } from "zod";
 import { useCase } from "../../../lib/use-case";
@@ -10,4 +11,10 @@
   .output(z.void())
   .run(async ({ ctx, input }) => {
+    requireUserId(ctx);
+    const project = await ctx.ports.projects.findById(input.id);
+    if (!project)
+      throw appError("ProjectNotFound", { details: { id: input.id } });
+    await ctx.gate.authorize("projects.access", project);
+
     const deleted = await ctx.ports.projects.delete(input.id);
     if (!deleted) {

Declare the authentication requirement and expected errors on the contract group:

--- a/features/projects/contracts.ts
+++ b/features/projects/contracts.ts
@@ -21,7 +21,11 @@
 });

-const projects = defineContractGroup().namespace("projects").responses({
-  500: ErrorResponseSchema,
-});
+const projects = defineContractGroup()
+  .namespace("projects")
+  .meta({ auth: "required" })
+  .errors({ Unauthorized: errors.Unauthorized, Forbidden: errors.Forbidden })
+  .responses({
+    500: ErrorResponseSchema,
+  });

 export const listProjects = projects

The existing route group and server/routes.ts registrations stay in place. The authenticated Next layout protects the screens; the use cases protect the data even when someone calls the API directly.

Verify access before adding UI

The generated CRUD test used anonymous callers. Give its seed rows an owner, provide a signed-in session, and use the real registered gate. Keep its existing CRUD, pagination, conflict, and route assertions.

Update the generated projects test
--- a/features/projects/tests/projects.test.ts
+++ b/features/projects/tests/projects.test.ts
@@ -5 +4,0 @@
-  createTestAnonymousActor,
@@ -7,0 +7 @@
+  createTestUserActor,
@@ -38,0 +39 @@
+        userId: "user_test",
@@ -46,0 +48 @@
+        userId: "user_test",
@@ -54,0 +57 @@
+        userId: "user_test",
@@ -64,0 +68 @@
+        gate: initialPorts.gate,
@@ -66 +70,8 @@
-          getSession: async () => null,
+          getSession: async () => ({
+            user: {
+              id: "user_test",
+              name: "Test User",
+              email: "test@example.com",
+            },
+            session: { id: "session_test" },
+          }),
@@ -82 +93 @@
-      actor: createTestAnonymousActor(),
+      actor: createTestUserActor("user_test"),
@@ -83,0 +95,3 @@
+      auth: await testFixture.ports.auth.getSession(
+        new Request("http://localhost"),
+      ),

Add features/projects/tests/access.test.ts. It runs the access checks against both the in-memory adapter and a temporary SQLite database created from your checked-in migrations. It also calls HTTP routes so a route cannot accidentally skip the use-case rules.

Add the ownership and access tests
// features/projects/tests/access.test.ts
import { createStaticAuth } from "@beignet/core/ports";
import { defineRoutes } from "@beignet/core/server";
import {
  createTestAnonymousActor,
  createTestContextFactory,
  createTestPorts,
  createTestUserActor,
} from "@beignet/core/testing";
import { createInMemoryDevtools } from "@beignet/devtools";
import { createTestApp } from "@beignet/web/testing";
import type { AppContext } from "@/app-context";
import { createTestDatabase } from "@/infra/db/test-database";
import { initialPorts } from "@/infra/port-wiring";
import { createInMemoryProjectRepository } from "@/infra/projects/in-memory-project-repository";
import { describe, expect, it } from "@/lib/beignet-test";
import type { AuthRequest, AuthSessionMetadata, AuthUser } from "@/ports/auth";
import { appContext } from "@/server/context";
import {
  createProject,
  deleteProject,
  getProject,
  listProjects,
  updateProject,
} from "../contracts";
import type { ProjectRepository } from "../ports";
import { projectRoutes } from "../routes";
import {
  createProjectUseCase,
  deleteProjectUseCase,
  getProjectUseCase,
  listProjectsUseCase,
  updateProjectUseCase,
} from "../use-cases";

async function userContext(projects: ProjectRepository, userId: string | null) {
  const auth = userId
    ? {
        user: { id: userId, name: userId, email: `${userId}@example.com` },
        session: { id: `session_${userId}` },
      }
    : null;
  const fixture = createTestPorts<AppContext["ports"]>({
    base: initialPorts,
    overrides: {
      projects,
      gate: initialPorts.gate,
      auth: createStaticAuth<AuthUser, AuthSessionMetadata, AuthRequest>(auth),
      devtools: createInMemoryDevtools(),
    },
  });
  return createTestContextFactory<AppContext, AppContext["ports"]>({
    ports: fixture.ports,
    auth,
    actor: userId ? createTestUserActor(userId) : createTestAnonymousActor(),
  })();
}

for (const adapter of ["memory", "sqlite"]) {
  describe(`${adapter} project ownership`, () => {
    it("protects reads and writes through use cases and HTTP", async () => {
      const database =
        adapter === "sqlite" ? await createTestDatabase() : undefined;
      const projects =
        database?.repositories.projects ?? createInMemoryProjectRepository();
      try {
        const alice = await userContext(projects, "alice");
        const bob = await userContext(projects, "bob");
        const anonymous = await userContext(projects, null);
        const project = await createProjectUseCase.run({
          ctx: alice,
          input: { name: "Private", description: "Alice's project" },
        });
        expect(project.userId).toBe("alice");
        expect(
          (await listProjectsUseCase.run({ ctx: alice, input: {} })).items,
        ).toEqual([project]);
        expect(
          (await listProjectsUseCase.run({ ctx: bob, input: {} })).items,
        ).toEqual([]);
        await expect(
          createProjectUseCase.run({
            ctx: anonymous,
            input: { name: "Anonymous" },
          }),
        ).rejects.toMatchObject({ code: "UNAUTHORIZED" });
        await expect(
          listProjectsUseCase.run({ ctx: anonymous, input: {} }),
        ).rejects.toMatchObject({ code: "UNAUTHORIZED" });
        await expect(
          getProjectUseCase.run({ ctx: bob, input: { id: project.id } }),
        ).rejects.toMatchObject({ code: "FORBIDDEN" });
        await expect(
          updateProjectUseCase.run({
            ctx: bob,
            input: { id: project.id, name: "Stolen", version: project.version },
          }),
        ).rejects.toMatchObject({ code: "FORBIDDEN" });
        await expect(
          deleteProjectUseCase.run({ ctx: bob, input: { id: project.id } }),
        ).rejects.toMatchObject({ code: "FORBIDDEN" });
        expect(await projects.findById(project.id)).toEqual(project);
        const saved = await updateProjectUseCase.run({
          ctx: alice,
          input: {
            id: project.id,
            name: "Updated",
            description: "Saved",
            version: project.version,
          },
        });
        expect(saved.description).toBe("Saved");
        await expect(
          updateProjectUseCase.run({
            ctx: alice,
            input: { id: project.id, name: "Stale", version: project.version },
          }),
        ).rejects.toMatchObject({ code: "PROJECT_CONFLICT" });

        for (const ctx of [bob, anonymous]) {
          const app = await createTestApp({
            ports: ctx.ports,
            context: appContext,
            routes: defineRoutes<AppContext>([projectRoutes]),
          });
          try {
            const code = ctx.auth ? "FORBIDDEN" : "UNAUTHORIZED";
            await expect(
              app.request(getProject, { path: { id: project.id } }),
            ).rejects.toMatchObject({ code });
            await expect(
              app.request(updateProject, {
                path: { id: project.id },
                body: { name: "Blocked", version: saved.version },
              }),
            ).rejects.toMatchObject({ code });
            await expect(
              app.request(deleteProject, { path: { id: project.id } }),
            ).rejects.toMatchObject({ code });
            if (!ctx.auth) {
              await expect(
                app.request(listProjects, { query: {} }),
              ).rejects.toMatchObject({ code });
              await expect(
                app.request(createProject, { body: { name: "Blocked" } }),
              ).rejects.toMatchObject({ code });
            }
          } finally {
            await app.stop();
          }
        }
        await deleteProjectUseCase.run({
          ctx: alice,
          input: { id: project.id },
        });
        expect(await projects.findById(project.id)).toBeNull();
      } finally {
        await database?.close();
      }
    });
  });
}

Generate and apply the owner-column migration, then run the tests:

bun run format
bun beignet db generate
bun beignet db migrate
bun beignet db status
bun run test
bun run typecheck

Both adapters should pass. Check that the second user gets an empty list and FORBIDDEN on another user's read, update, and delete; signed-out requests get UNAUTHORIZED. Failed writes must leave the saved project unchanged. A stale owner edit gets PROJECT_CONFLICT.

Checkpoint: an authorized feature

Run the complete app checks:

bun beignet check

The access tests prove that owners can create, list, read, update, and delete their projects, another account cannot read or change them, and signed-out callers are rejected. The unowned records from stage one stay in the database but appear in nobody's list.

You can stop here with an authorized API. Keep working in this app and continue with 3. Connect the UI.