Build with Claude Code
Take a scaffolded Stack app to a working team blog — real access rules, a comments feature, an admin — by describing what you want to Claude Code, and prove along the way that the access-control engine keeps every generated feature secure.
This is the workflow Stack is designed for: you describe, the agent builds, the framework guards.
Time: about 30 minutes.
Prerequisites
- Node.js 18+ and pnpm (
npm install -g pnpm) - Claude Code installed (
npm install -g @anthropic-ai/claude-code) - No Stack knowledge required — that's what the tutorial is for
Agents vary. Claude Code won't produce byte-identical code every run — that's expected. At each Checkpoint below you'll find the canonical config this tutorial depends on. If your generated code differs in structure, that's fine; if it differs in behavior, align it with the checkpoint before continuing.
1. Scaffold the app
Create a project with the AI development tooling included:
npm create opensaas-app@latest team-blog --with-ai
cd team-blog
The scaffolder installs dependencies and generates from the config. Start the dev loop, which brings up the Dev database — a Postgres the stack runs for your project — and reconciles it with your config as you edit:
pnpm dev
Your app is at http://localhost:3000, and the generated admin UI is at http://localhost:3000/admin. Leave it running.
2. Meet the guardrails
Before describing any features, look at what you're standing on:
opensaas.config.ts— the single source of truth. The starter ships aUserlist and aPostlist that already has a draft/publishedstatus, apublishedAttimestamp (auto-set by a hook when a post is first published), and aninternalNotesfield with field-level access..opensaas/— generated from the config: TypeScript types and thegetContext()factory. Application code talks tocontext.db, never to Prisma directly, and everycontext.dboperation passes through the access-control engine.CLAUDE.md— the rules that teach Claude Code the framework: always go through the context, every list declares access control, denied operations fail silently.
That last file is the point of --with-ai: there is no unchecked path for the agent to take, and the agent has been told so.
Two things to notice in the config before we start:
- The access helpers (
isSignedIn,isAuthor,isOwner) are placeholders — they allreturn trueso you can explore the starter without auth. Wiring them up is our first feature. session.getSessionis a mock that returnsnull— every visitor is anonymous for now. That's enough for this tutorial; the Authentication guide replaces it with Better-auth.
Under those placeholders sits a hard default worth knowing: the engine denies by default. A list with no access rules isn't open — it's inaccessible. Nothing in Stack ships readable by accident.
3. Make the access rules real
Open the project in Claude Code:
claude
Describe the security you want in plain language:
Implement the access rules for real. Anonymous visitors can only see published posts; signed-in users can also see their own posts, drafts included. Creating a post requires being signed in. Only a post's author can update or delete it, and only the author can see or edit internalNotes. Keep user sign-up open, but only let users update or delete their own record. Sessions carry a userId.
Claude will rewrite the helper functions and access blocks, then run pnpm generate. Check the result against the checkpoint.
Checkpoint 1 — the access rules
The helpers should now use the session for real. isAuthor and isOwner are operation rules, so they return a row filter rather than false for a signed-in caller: a non-author gets no error, they simply match nothing. Each returns false for an anonymous caller, because the Where vocabulary refuses undefined rather than dropping it; { authorId: { equals: undefined } } would be an error, not an open read.
import type { AccessControl, FieldAccess } from '@opensaas/stack-core'
const isSignedIn: AccessControl = ({ session }) => !!session?.userId
const isAuthor: AccessControl = ({ session }) =>
session?.userId ? { authorId: { equals: session.userId } } : false
const isOwner: AccessControl = ({ session }) =>
session?.userId ? { id: { equals: session.userId } } : false
internalNotes needs a different shape. Field rules are boolean-only — they decide per fetched item and cannot scope rows — so they carry their own type, and an AccessControl rule is not assignable to one: its return type includes a filter, which is TS2322 here and an InvalidFieldAccessResultError at runtime. That is why the checkpoint has a separate FieldAccess object rather than reusing isAuthor:
const authorOnlyField: FieldAccess = {
read: ({ session, item }) => !!session?.userId && item.authorId === session.userId,
create: ({ session }) => !!session?.userId,
update: ({ session, item }) => !!session?.userId && item?.authorId === session.userId,
}
read's item is always present, because a read rule is only ever evaluated against an already-fetched row; create's is absent, because there is no row yet. Then:
internalNotes: text({
ui: { displayMode: 'textarea' },
access: authorOnlyField,
}),
Operation rules may return filters; field rules must return booleans. It's the one sharp edge in the access API, and the reason the checkpoint declares authorOnlyField separately instead of reusing isAuthor. Reusing it is a TS2322 at compile time, and if the types are bypassed, checkFieldAccess throws InvalidFieldAccessResultError — it does not default to allow.
And the Post list's operation access should be equivalent to the following, where query scopes an anonymous caller to published posts and widens a signed-in one to published posts plus their own:
access: {
operation: {
query: ({ session }) =>
session?.userId
? {
OR: [
{ status: { equals: 'published' } },
{ authorId: { equals: session.userId } },
],
}
: { status: { equals: 'published' } },
create: isSignedIn,
update: isAuthor,
delete: isAuthor,
},
},
Notice what these rules are: not middleware, not per-route checks — data rules, declared once, that the engine merges into every operation. A rule returns a boolean (allow/deny the operation) or a filter written in the Where vocabulary (scope which rows it can touch).
Refresh /admin: with the mock session still null, the admin now shows only published posts — the admin UI runs through the same secured context as everything else, so it can never show a session more than the rules allow.
4. Try to break it
Rules only count if they hold when code — anyone's code, any agent's code — comes at the database from a new direction. Create guardrails-check.ts in the project root.
The script seeds through sudo(), which bypasses access control for exactly this kind of trusted script and never for request handling, then reads the same list back as three different callers. Two details of the secured surface shape it. Lists are keyed by their PascalCase config key — db.Post, not db.post, which is a compile error. And every write returns Row | null, because a denied write is indistinguishable from a missing row; the seed throws on null so a failure surfaces here rather than as a confusing read further down.
import { getContext } from './.opensaas/context'
async function main() {
const seed = (await getContext()).sudo()
const alice = await seed.db.User.create({
data: { name: 'Alice', email: 'alice@example.com', password: 'correct-horse' },
})
const bob = await seed.db.User.create({
data: { name: 'Bob', email: 'bob@example.com', password: 'battery-staple' },
})
if (!alice || !bob) throw new Error('seeding the users failed')
const published = await seed.db.Post.create({
data: {
title: 'Hello world',
slug: 'hello-world',
status: 'published',
author: { connect: { id: alice.id } },
},
})
const draft = await seed.db.Post.create({
data: {
title: 'Secret draft',
slug: 'secret-draft',
status: 'draft',
author: { connect: { id: alice.id } },
},
})
if (!published || !draft) throw new Error('seeding the posts failed')
const anonymous = await getContext()
const asAlice = await getContext({ userId: alice.id })
const asBob = await getContext({ userId: bob.id })
const show = (posts: { title: string }[]) => posts.map((p) => p.title).join(', ')
console.log('anonymous sees:', show(await anonymous.db.Post.all()))
console.log('alice sees: ', show(await asAlice.db.Post.all()))
console.log('bob sees: ', show(await asBob.db.Post.all()))
const stolen = await asBob.db.Post.update({
where: { id: draft.id },
data: { title: 'Bob was here' },
})
console.log("bob updating alice's draft returns:", stolen)
}
main()
Run it:
npx tsx guardrails-check.ts
You should see:
anonymous sees: Hello world
alice sees: Hello world, Secret draft
bob sees: Hello world
bob updating alice's draft returns: null
Three things worth staring at:
- Nobody filtered anything in application code. The same
.all()returned different rows per caller, because the engine merged each session's access filter into the query before it hit the database. - Bob's attack returns
null, not an error. This is silent failure: a denied operation is indistinguishable from "not found", so Bob can't even probe whether Alice's draft exists. internalNotesnever leaves the server for anyone but the author — field-level access strips it from reads after the rows come back.
Ask Claude Code to write a feature that leaks drafts, if you like. Its code will route through context.db — the CLAUDE.md tells it to, and there's no other wired path — and the engine will scope whatever it writes.
5. Add comments — by description
Now that you trust the loop, add a whole feature in one prompt:
Add comments. A comment has a required text body, an author (a User), and belongs to a post. Anyone can read comments; signed-in users can create them; only a comment's author can edit or delete it.
Claude adds the list and regenerates; the running pnpm dev loop reconciles the Dev database with the new config. Check the checkpoint:
Checkpoint 2 — the Comment list
Comment: list<Lists.Comment.TypeInfo>({
fields: {
body: text({ validation: { isRequired: true } }),
author: relationship({ ref: 'User' }),
post: relationship({ ref: 'Post.comments' }),
},
access: {
operation: {
query: () => true,
create: isSignedIn,
update: isAuthor,
delete: isAuthor,
},
},
}),
(Your Post list gains comments: relationship({ ref: 'Comment.post', many: true }).)
Note what the agent had to do to ship this: declare access rules, because every list needs them — a Comment list without an access block would be inaccessible, not open. The framework turned "add comments" into a decision about who may do what, and the config records the answer.
Refresh /admin: Comments appear in the navigation with list, create, and edit views. Nobody wrote admin UI code.
6. Tour the admin you didn't build
Spend a minute in /admin:
- List views with filtering — filter Posts by
status is draft; filter state lives in the URL, so a filtered view is shareable - Edit forms generated per field type — the status segmented control, the relationship picker for author, the textarea for content
- The same access rules everywhere — you saw it in step 3: the admin shows a session exactly what the engine allows, nothing more
The admin is composable, too: keep the full UI, or pull individual pieces (ItemCreateForm, ListTable, shadcn-based primitives) into your own pages. See Composability.
Where you are
In half an hour you have:
- Access rules that scope reads and writes per session — described in one prompt, declared once, enforced on every operation
- Proof the rules hold against code that wasn't written with them in mind
- A comments feature added by description, with its own access rules because the framework insists
- An admin UI you never built, bound by the same engine
Where to next
- Access Control — how the engine works: operation vs. field level, filters, silent failure
- Add Authentication — replace the mock session with Better-auth (
--with-authscaffolds it from the start) - Claude Code Workflow — the plugin, MCP tools, and feature wizards behind the prompts you just used
- Deploy to Production — Vercel + Postgres in ~15 minutes