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:

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

bash
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 a User list and a Post list that already has a draft/published status, a publishedAt timestamp (auto-set by a hook when a post is first published), and an internalNotes field with field-level access.
  • .opensaas/ — generated from the config: TypeScript types and the getContext() factory. Application code talks to context.db, never to Prisma directly, and every context.db operation 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:

  1. The access helpers (isSignedIn, isAuthor, isOwner) are placeholders — they all return true so you can explore the starter without auth. Wiring them up is our first feature.
  2. session.getSession is a mock that returns null — 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:

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

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

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

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

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

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

bash
npx tsx guardrails-check.ts

You should see:

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

  1. 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.
  2. 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.
  3. internalNotes never 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

typescript
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