Loading…
First business feature
Add workspace notes with D1 persistence, Effect adapters, permissions, an authenticated page, and REST.
Add workspace notes with D1 persistence, Effect adapters, permissions, an authenticated page, and REST.
Loading…
Build a small resource owned by a Workspace. A workspace note has text, an ID, and a creation time. Members can read and create notes. An outsider cannot read or write them, even with a guessed Workspace slug. The web app and external API use the same capability.
This tutorial adds two operations, create and list. The page shows the latest 30 notes; REST and MCP use cursor pagination. Notes have no paid entitlement and need no optional provider. Existing billing, suspension, authentication, and API-token rules continue to apply.
The baseline Starter already contains D1, the WorkspaceContext membership
resolver, authorization guards, audited mutations, the authenticated Workspace
shell, and the shared Effect HTTP contract. It does not contain notes.
You add the notes schema, capability, permission statements, page, transport
wiring, and verification cases. The complete changes are in
workspace-notes.patch.
The patch is executable source, including imports and registration edits. The
snippets below explain its key parts; they are excerpts, not extra edits to apply
after the patch. The companion
verification record
names the baseline and observed checks.
Use a disposable checkout to try the finished recipe. Keep your main development database separate. From the root of a clean checkout containing this tutorial:
git worktree add --detach ../first-business-feature-example HEAD
cd ../first-business-feature-example
vp install
cp .env.example .env
git apply --check docs/examples/first-business-feature/workspace-notes.patch
git apply docs/examples/first-business-feature/workspace-notes.patch
pnpm run db:generate
pnpm run db:migrate:local
pnpm run db:seedRun the commands at the repository root unless a command includes -C. The
migration name generated by Drizzle can differ. Commit both its SQL and snapshot
when you adopt the feature. The patch deliberately excludes generated migrations
so db:generate reads your checkout's current schema history.
If git apply --check fails after you change the Starter, inspect the failed hunks
and apply the corresponding edits manually. Do not force a partial patch and
continue. The steps below identify each registration point.
Add workspaceNotes to packages/db/src/schema.ts:
export const workspaceNotes = sqliteTable(
'workspace_notes',
{
id: id(),
workspaceId: workspaceRef(),
body: text('body').notNull(),
createdAt: isoCreatedAt()
},
(table) => [
index('workspace_notes_workspace_created_idx').on(
table.workspaceId,
table.createdAt,
table.id
)
]
)These helpers already exist in that file. workspaceRef() gives the row a
non-null foreign key that cascades when its Workspace is deleted. The composite
index supports Workspace filtering and newest-first pagination. Starter-owned
timestamps are ISO strings; Better Auth's tables use epoch timestamps.
Run pnpm run db:generate, inspect the generated workspace_notes table and
index, then run pnpm run db:migrate:local. Use the repository migration command.
Wrangler's flat migration command does not consume this repo's Drizzle folder
layout. Never reset a shared or deployed database to try a tutorial.
Create packages/capabilities/src/notes/workspace-notes.ts. This small module owns
the schemas, service, and both adapters. Its adjacent workspace-notes.AGENTS.md
records ownership and pagination rules. Add the ./notes/* export to
packages/capabilities/package.json so callers can import the module directly. CreateWorkspaceNote validates one
body field with a minimum of 1 and maximum of 500 characters. The output schema
reuses that field rather than defining a second text rule:
export const WorkspaceNote = Schema.Struct({
id: Schema.String,
body: CreateWorkspaceNote.fields.body,
createdAt: Schema.String
})
export type WorkspaceNote = typeof WorkspaceNote.Type
export class WorkspaceNotes extends Context.Service<
WorkspaceNotes,
{
readonly listPage: (
input?: ListPageInput
) => Effect.Effect<Page<WorkspaceNote>, CapabilityUnavailable, WorkspaceContext>
readonly create: (
input: typeof CreateWorkspaceNote.Type
) => Effect.Effect<WorkspaceNote, CapabilityUnavailable, WorkspaceContext>
}
>()('@b2b-saas-starter/capabilities/WorkspaceNotes') {}The input contains no user ID, Workspace ID, or slug. Each method reads the
request's verified WorkspaceContext. Identity comes from the session or API
token at the transport boundary. The public schema also omits the storage-only
Workspace ID.
The body accepts 1–500 characters, including whitespace. Change that schema if your product requires trimmed or non-whitespace text. Both transport boundaries use it. Creating a note generates a fresh ID every time; this operation is not idempotent, so callers must not retry it automatically.
SeedWorkspaceNotes owns an Effect Ref inside its Layer. It stores notes with
their Workspace IDs, filters before paging, and records workspace_note.created
through the shared AuditEventLog. Its state lasts for that Layer's lifetime.
Rebuilding the Seed runtime starts an empty notes collection.
LiveWorkspaceNotes resolves Database and AuditEventLog once while building
its Layer. Each operation resolves WorkspaceContext at call time. The create
method constructs the note with Effect time and the existing ID generator, then
commits the row and audit event together:
yield *
mutate({
matched: Effect.succeed(true),
auditEvent: createdEvent(ctx, note),
write: () => [
db.insert(workspaceNotes).values({
...note,
workspaceId: ctx.workspace.id
})
]
})Here mutate comes from auditedMutations. It compiles a D1 batch, including the
audit insert, and maps storage failures through orUnavailable('workspace-notes')
to CapabilityUnavailable. Do not use db.transaction() with D1. Do not write
the note and then independently write its audit event.
Add workspace_note.created and workspace_note to
governance/audit-event-taxonomy.ts. Mark the new event audit-only in
developer-platform/webhook-events.ts. The event stores the note ID and actor
provenance, never the note text. API Token writes retain actorType: 'api_token';
web writes retain the authenticated user.
The Live list query always includes
eq(workspaceNotes.workspaceId, ctx.workspace.id). It orders by createdAt and
id descending, fetches limit + 1 rows, and uses cutKeysetPage. Seed uses
seedKeysetPage with the same ordering. Both use the repository cursor format,
limit clamp, and empty-page behavior for malformed cursors.
In packages/capabilities/src/layers.ts:
WorkspaceNotes to CapabilityServices.SeedWorkspaceNotes to SeedCore. Its existing audit provision supplies
the shared Seed audit instance.LiveWorkspaceNotes to makeLiveCapabilitiesLayer's merge. Its existing
D1 and audit provisions satisfy the new adapter.Keep adapter selection in the existing runtime. The DB binding selects Live;
its absence selects Seed. Ordinary local development has persisted local D1 and
therefore uses Live. /demo remains a synthetic preview and gains no notes page.
Add workspaceNote: ['list', 'create'] to starterResources in
packages/authz/src/statements.ts. Keep the Better Auth default statements.
Then update roles.ts and the existing authorization matrix test:
| Principal | List notes | Create notes |
|---|---|---|
| Workspace owner or admin | Yes | Yes |
| Workspace member | Yes | Yes |
API Token with read | Yes | No |
API Token with write or admin | Yes | Yes |
| Non-member session | No | No |
| Token for a different Workspace | No | No |
Owner/admin roles already spread starterResources. Add both permissions
explicitly to memberRole, list to readScopeStatements, and list/create to
writeScopeRole. This grants access to notes only. It does not grant members
access to audit records, API-token administration, billing, or SSO.
Capabilities trust membership context; they do not decide role permissions. The web and API boundaries use their existing guards. Those guards also preserve suspension and strong-authentication requirements. A System Admin receives no Workspace bypass. Unknown Workspaces and non-member sessions return the same not-found result.
The patch adds these files:
File under apps/web/src | Purpose |
|---|---|
lib/server/workspace-notes.ts | Client-safe server functions, input schemas, and payload type |
lib/server/workspace-notes.effects.ts | Session, membership, permissions, and capability calls |
routes/workspaces.$workspaceSlug.notes.tsx | Loader and route component |
components/workspace-notes-page.tsx | Workspace shell, form, and latest notes |
The route inherits the existing /workspaces authentication gate. Each server
function still calls requireRequestSession() because a client can invoke it
without navigating through the page. The loader uses workspacePage with
{ workspaceNote: ['list'] }; the mutation uses:
const session = await requireRequestSession()
return runWorkspaceCapabilities(
input.workspaceSlug,
Effect.gen(function* () {
yield* requireWorkspacePermission({ workspaceNote: ['create'] })
const notes = yield* WorkspaceNotes
return yield* notes.create({ body: input.body })
}),
{ userId: session.user.id }
)runWorkspaceCapabilities resolves membership for that session and slug. Never
accept userId from the browser or construct a trusted WorkspaceContext in a
server function.
The client-safe module dynamically imports .effects.ts inside each handler.
The component imports only server-function stubs and types. The web app calls
capabilities in-process; it does not fetch its own REST API.
The component uses TanStack Form, viewerCan, shared controls, a hydration gate,
and useServerAction for safe errors and router invalidation after a successful
save. The route uses workspaceRouteError for load failures and the existing
strong-authentication notice. Permission checks in the browser describe
available actions; the server guard enforces them. Copy lives in both
packages/i18n/messages/notes/en.json and nb.json. The example uses the existing
Workspace shell and intentionally adds no global navigation item. Open its URL
directly, then place it in your product's navigation when adopting it.
Generate messages and start the web app on the tutorial port:
pnpm -C packages/i18n generate
BETTER_AUTH_URL=http://localhost:3104 \
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3104 \
pnpm -C apps/web dev --port 3104 --strictPortSign in as engineer@example.com with demo-starter-password and open
http://localhost:3104/workspaces/starter-lab/notes. Add a note, reload, and confirm
it remains. The privileged demo owner needs the Starter's existing strong-auth
ceremony; using the ordinary member keeps this exercise focused on the feature.
Change packages/api/src/index.ts before its implementation. Import the
capability schemas. Add GET /workspaces/:slug/notes to WorkspaceApi with
ListPageQuery and PageDto(WorkspaceNote). Add a workspace-notes group for
POST at that path with CreateWorkspaceNote and WorkspaceNote as its payload
and result.
Apply .middleware(BearerAuth) after adding the endpoints. Endpoints appended
after that call are not protected by it. Register the group in StarterApi and
add its rest_write entry to GROUP_BUCKETS. The GET uses the existing
Workspace group's rest_read bucket.
In apps/api/src/operations.ts, add the list and create rows with their
workspaceNote permissions. Add the capability to the read/mutation service
unions. In handlers.ts, bind GET through workspaceRead and POST through
workspaceWrites, then register the POST handler group in http.ts.
These wrappers authenticate the token, check its Workspace and permissions, resolve context, and attach request observability. They do not duplicate note storage behavior. The permission matrix discovers the catalog rows and checks that the HTTP contract has no unaccounted protected endpoint.
The Starter keeps supported mutations available through both REST and MCP. Add
the create projection in mcp-mutations.ts using CreateWorkspaceNote, the same
catalog operation, and all four annotation hints. It is a non-destructive,
non-idempotent write with no external side effect. The read catalog row supplies
list_workspace_notes. Add WorkspaceNotes to both captured service contexts in
mcp.ts so these operations have their runtime dependency. No second notes implementation is needed.
The resulting contract appears in /openapi.json and /reference; an Effect
HttpApiClient derives its types from the same StarterApi.
For a manual REST check, start the API in another terminal. Point Wrangler at the same local state directory that migrations, seeding, and the web app use. The separate inspector port avoids other development workers:
pnpm -C apps/api exec wrangler dev --local --port 8794 --inspector-port 9794 \
--persist-to ../../packages/db/.wrangler/stateThe local seed includes a read-only token:
NOTES_READ_TOKEN=bsk_seed_readonly000000
curl --fail-with-body \
-H "Authorization: Bearer $NOTES_READ_TOKEN" \
http://localhost:8794/workspaces/starter-lab/notes
unset NOTES_READ_TOKENTo create a note through REST, create a write API Token through the existing
privileged API-token UI and enter it locally. Existing plan limits still govern
API Token creation and eligibility.
read -r NOTES_API_TOKEN
curl --fail-with-body \
-H "Authorization: Bearer $NOTES_API_TOKEN" \
-H 'Content-Type: application/json' \
--data '{"body":"Created through REST"}' \
http://localhost:8794/workspaces/starter-lab/notes
unset NOTES_API_TOKENDo not commit tokens. The automated protocol check creates its own local credential in an isolated migrated D1 database and needs no provider account.
Stop the development server before the browser run. Run these in the example checkout after applying the patch and generating/migrating/seeding:
pnpm run typecheck
pnpm -C apps/api exec vp test run src/workspace-notes.test.ts src/permission-matrix.test.ts
pnpm -C apps/web exec playwright install chromium
pnpm -C apps/web build:e2e
E2E_PORT=3104 pnpm -C apps/web exec playwright test --config playwright.notes.config.ts
E2E_PORT=3104 pnpm run validateThe focused Playwright config starts the already-built preview so a cold build
does not consume the browser startup deadline. The full validate command also
includes the notes case in the ordinary suite.
The protocol test exercises the actual HTTP handler with migrated local D1, reads through a newly constructed handler, checks the stored note and audit provenance, and refuses missing credentials, invalid input, and a token targeting a different Workspace. Its Seed pass verifies create/list and read-only token refusal. The browser test signs in a real member, creates a note, reloads, and checks an independently authenticated outsider's access.
Playwright writes apps/web/playwright-report/notes.json, a persistence
screenshot under apps/web/test-results, and traces on failure. Keep those files
with the command and source revision when you verify your adopted version.
The companion verification record reports this tutorial's observed run and
limitations. A passing render alone is not persistence or authorization evidence.
Rename the resource and choose its actual permissions before growing the UI. Add update or delete only when your first use case needs them, with Workspace predicates on every write and the matching Seed behavior. Include new data in your product's export and retention policy before offering that policy to users. This exercise adds no export, retention deadline, paid quota, file storage, or background job for notes.
See Effect v4 backbone for the application layer and REST API for external clients.