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.
| Files | Change |
|---|---|
features/projects/schemas.ts and infra/db/schema/projects.ts | Add the owner field |
features/projects/ports.ts and infra/projects/*-project-repository.ts | Assign owners and filter lists |
features/projects/policy.ts, ports/index.ts, and infra/port-wiring.ts | Define and register the ownership rule |
features/projects/use-cases/ and contracts.ts | Enforce 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.
| Caller | List | Read, update, or delete an existing project |
|---|---|---|
| Signed out | 401 | 401 |
| Owner | Their own projects | Allowed |
| Another user | Their own projects | 403 |
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 = projectsThe 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 typecheckBoth 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 checkThe 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.