mehmetozer.dev
open to senior rolesGet in touch
← All posts

Castra: a self-hosted, agent-safe backend on Cloudflare Workers + D1

Castra started from one constraint: D1 has no row-level security.

I don't think of that as a bug. D1 gives you SQLite at the edge, no servers to run, and a free tier that easily covers a personal project. What you give up is everything a Postgres user takes for granted: RLS, interactive transactions, row locks across requests. If you need "this user may read this row, and only this row", you write it yourself.

The usual fix is a policy language that lives next to your code, in a dashboard or a YAML file or a runtime you can't import into a test. The syntax was never what bothered me. What bothered me is that the rule and the query end up as two separate code paths, and then the only way to answer "is this table actually protected?" is to read both and hope they agree.

Castra takes the other route. The rule is TypeScript in your repository, it compiles to SQL, and that compiled SQL is what production runs. REST requests go through it, and so do AI agents talking to the MCP server, which means an agent can check what production enforces instead of taking your word for it.

If you want row-level rules today, Supabase has real Postgres RLS, with a Postgres to run or pay for. PocketBase has rules in a single binary that you host and back up yourself. Castra is for the case in between: you've already picked Workers and D1, and you want rules without leaving your own Cloudflare account.

The shape

One Worker and one D1 database, in your Cloudflare account. Nothing runs on my infrastructure, so there's nothing to migrate away from later. Install steps are at the end.

There are six MIT packages on npm: @castra-kit/core (policy engine, schema snapshot, REST router, OpenAPI), plus auth, mcp, app, cli and create. It's TypeScript all the way through, on Workers and Hono, with Kysely for queries, Better Auth for identity and Zod for validation.

Closed by default

If a table has no policy in the config, it gets no routes. It doesn't return a 403 or an empty list; the route simply isn't there. Better Auth's tables and Castra's own _castra_* tables can't be configured at all.

import { authenticated, defineConfig, isPublic, owner, role } from "@castra-kit/core";

export default defineConfig({
  database: "d1",
  roles: ["admin", "editor"],
  tables: {
    posts: {
      read: isPublic()
        .where((q) => q.eb("status", "=", "published"))
        .or(owner("author_id"))
        .or(role("admin")),
      create: authenticated(),
      update: owner("author_id").or(role("editor", "admin")),
      delete: role("admin"),
      fields: { author_id: { setBy: "server" }, internal_notes: { visibleTo: ["admin"] } },
      filterable: ["status", "created_at"],
    },
  },
});
// audit_logs has no policy, so it is never exposed.

That's most of what there is to learn. Each table has four operations (read, create, update, delete), and each one is built from isPublic(), authenticated(), role(...), owner(column) and your own Kysely predicates, joined with .and() and .or().

Rules compile to SQL

read, update and delete rules compile, together with the caller's context, into a WHERE fragment. Castra never filters rows after the query comes back.

That sounds like an implementation detail, but it decides whether things are correct or only mostly correct:

  • ?limit=10 returns ten rows the caller is allowed to read. Filtering afterwards would give you ten rows that shrink to four.
  • count matches the rows you actually get, so pagination stays stable.
  • A code path can't forget to apply the rule, because the filter sits inside the query the router was going to run anyway.

explain_query hands that same compiled expression to an agent. So what the agent sees is the SQL that will run, rather than someone's summary of the policy.

A WHERE clause is not enough

Postgres RLS separates USING from WITH CHECK. The first decides which rows you can see, the second decides which rows you're allowed to leave behind. Castra keeps the same split.

Every create and update is checked against the stored row, not the payload. D1 has no interactive transactions, so the check goes into the same batch() as the write, right after it. D1 runs a batch as one SQL transaction, so if any statement raises an error, the whole batch rolls back.

The problem is that a guard which simply returns zero rows doesn't raise anything. So the guard is a single SELECT written to throw:

select json_extract('{}', CASE WHEN exists(
         select 1 from "posts" where ("posts"."id" = ?) and <rule>)
       THEN '$' ELSE 'castra_write_check_failed' END)

If the stored row passes the rule, the path is $ and nothing happens. If it doesn't, json_extract gets an invalid path and SQLite raises bad JSON path: 'castra_write_check_failed'. RAISE() only works inside triggers, and this was the one error I found that carries a marker of my choosing in its message. That lets Castra tell a policy violation apart from any other database error and answer with 403 write_check_failed instead of a 500.

Order matters here. If you validate the input first and write it second, there's a window where the row on disk isn't the row you validated. Checking the stored row inside the same batch closes that window.

Ownership spoofing goes through the same path. In a table whose rule is owner("author_id"), author_id is implicitly server-set, and the server fills it in from the session. If a client sends the same value, that's fine. If it sends a different one, it gets a 403 instead of a silent correction, so a spoofing attempt shows up rather than disappearing. Either way the stored value comes from the session and never from the payload.

Field rules sit on top of that. hidden columns never leave the server, readonly columns can't be written, and visibleTo limits a column to certain roles, with "self" as a pseudo-role meaning the caller's own row.

The same engine serves REST, MCP and your own routes

REST and OpenAPI are generated from your D1 schema and your policies. /openapi.json lists exactly the operations that are open. A closed table doesn't appear in it at all, rather than being documented and then denied.

The MCP server is the part I care about most. It's a set of read and write tools on the same engine and the same request pipeline as REST. There's no separate data path for MCP, and there's a test that checks query_data and the matching REST call return the same thing across a matrix of identities.

  • describe_schema: tables, columns, relations and policies
  • check_access: dry run of identity × operation × row; returns the decision and the rule that matched
  • explain_query: takes a REST request, returns the generated SQL and the policies applied
  • query_data: a policy-bound read as a given identity
  • create_data / update_data / delete_data: writes through the REST write path
  • propose_policy: returns a unified diff of castra.config.ts, validated against the schema and your declared roles, and never applied

So an agent can ask "could user 41 read row 9f3a?" and get back the matching rule along with the WHERE clause it compiled to. It doesn't have to trust your description of the policy. It also can't invent an admin path: remote tokens are scoped (schema:read, access:check, data:read, data:write), no scope grants admin, and a remote write always runs as the token's own user.

Every remote call is logged to _castra_audit. Argument values are masked before they're stored. A write records an attempt row first and doesn't run if that insert fails. Retention is opt-in and has an upper bound.

I am the first user

The API behind this blog runs on Castra at api.mehmetozer.dev: one Worker, one D1, four migrations.

posts: {
  // Anyone, including anonymous, reads published posts; admins also read drafts.
  read: isPublic().where((q) => q.eb("is_published", "=", 1)).or(role("admin")),
  create: role("admin"),
  update: role("admin"),
  delete: role("admin"),
  filterable: ["lang", "slug", "is_published", "published_at"],
}

Anonymous readers see published posts, I see drafts too, and nobody else can write. The posts table holds the Markdown you're reading right now. I published it with one POST and an admin API key.

Running it for real surprised me in two ways.

First, schema drift turned into a deploy-time failure. Deploy compares a committed JSON snapshot with the live database and refuses to ship if they don't match. So a rule pointing at a column that no longer exists never reaches production; the deploy stops before that.

Second, the boring parts ended up being the useful ones. castra deploy runs provision → migrate → drift check → secret → wrangler deploy, and you can run it again safely. castra doctor --remote checks token permissions, bindings, package versions and remote drift, and tells you what's missing. castra rollback won't move traffic to a Worker version whose recorded schema and config fingerprints don't match the live database. None of this is clever, but it's what lets me leave the thing running.

Some numbers: the v0.3.0 release gate ran 1,288 tests, plus a separate run of 529 allow/deny policy cases. There are 85 architecture decision records, one for each decision that changed the design.

I should be honest about one thing. 0.3.0 is on npm, but the API behind this post is still on 0.2.1. I'll upgrade it when I have a reason to, not just because a new version came out.

What it is not

I'd rather you read these here than discover them at 2am.

  • D1's limits are Castra's limits: 10 GB per database on Workers Paid and 500 MB on Free, 100 bound parameters per query, and 1,000 queries per Worker invocation on Paid (50 on Free).
  • Writes go to a single primary. Cloudflare forwards every write query there by design, so Castra isn't a good fit for write-heavy workloads.
  • There are no interactive transactions and no row locks across requests. Atomicity stops at a single batch(), which is why every guarded write is exactly one batch. Two requests that each read and then write can interleave.
  • I haven't configured or tested read replication. If you turn it on yourself, a revoked session or role could still be served from a replica that hasn't caught up.
  • Rate limits only cover writes: sign-in, sign-up, /mcp and table writes, per IP, with a separate bucket for auth. Reads need a WAF rule, and the docs include one.
  • There's no multi-tenancy. It's one Worker and one D1 per deployment.
  • Out of scope until v1.0: PostgreSQL/MySQL/Hyperdrive, realtime (Durable Objects), queues, Redis/S3/OIDC adapters, and any hosted or managed version.

docs/SECURITY.md also has a real known-issues list. My favorite entry is that constraint violations can work as existence oracles: a unique-constraint 409 can tell a caller that a row exists even though they can't read it. I wrote it down instead of hiding it.

Try it

npm create @castra-kit@latest my-app
cd my-app
cp .dev.vars.example .dev.vars    # BETTER_AUTH_SECRET: openssl rand -base64 32
pnpm db:migrate:local
pnpm dev                          # http://localhost:8787/health

Deploying to your own account takes one command and a Cloudflare API token. The minimum permissions for the token are listed in the repo.

What is next

Next up are R2 storage routes, multi-level includes and bulk operations. I still haven't decided on multi-tenancy. One Worker and one D1 per deployment is a clean default for a personal backend, but it's the wrong one for a product with tenants. The plugin SDK, custom hooks and a stable config API belong in v1.0.

If you're building on D1 and you've written your own "may this user read this row" check, I'd like to hear how you did it.