Access & identity
Bring identity. Define access.
Section titled “Bring identity. Define access.”The Worker verifies short-lived signed workspace claims. Your application implements the identity adapter that resolves the signed-in subject and their workspace role. Production refuses the development claim route.
Each model’s actions take one of three decisions:
| Rule | Purpose |
|---|---|
role(...) |
Require a minimum role before SQL runs. |
filter(...) |
Apply a read rule inside SQL queries and counts. |
check(...) |
Evaluate a row or write with application-defined logic. |
board: model({ name: t.string(), ownerId: t.string().index(),}).access({ read: filter(({ auth }) => ({ ownerId: auth.sub })), update: check(({ auth, row }) => row?.ownerId === auth.sub), delete: role("moderator"),}),Defaults: read member, create/update editor, delete moderator.
filter(({ auth }) => where)is forreadonly. It is compiled into the SQL of every query that touches the model, so it uses indexes, fills every page and counts correctly. An array is OR; the caller’s own filter ANDs with it.check()works for any action. It receives{ auth, row, data, changed }and returns a boolean. Write checks run inside the transaction next to the write they gate. Onreadthey scan matching rows until the page is full or the result set is exhausted. Counts apply the same visibility rule.- A rule that throws denies access.
Prefer SQL filters for read visibility, because a function-based read check runs after fetching.
Files have rules of their own, set in the same schema: see File access.
Claims and identity
Section titled “Claims and identity”The engine verifies a claim and never issues one. Implement two methods and the library serves the claim route, rate-limited out of the box:
import { defineTapeDB, WORKSPACE_ROLE_LEVELS } from "tapedb/worker";import schema from "./tapedb.schema";import { migrations } from "./tapedb/migrations.gen";
const tapedb = defineTapeDB({ schema, migrations, identity: { async getSubject(headers) { const session = await auth.api.getSession({ headers }); return session ? { sub: session.user.id } : null; }, async getRole(workspaceId, sub) { const member = await memberships.get(workspaceId, sub); return member ? WORKSPACE_ROLE_LEVELS[member.role] : null; }, // Optional; these are the defaults. limits: { ip: { requests: 300, perSeconds: 60 }, subject: { requests: 30, perSeconds: 60 }, }, },});
export const WorkspaceRoom = tapedb.WorkspaceRoom;export default tapedb.worker;POST /_tapedb/claim returns { token, workspaceId, roleLevel, expiresAt, expiresIn }. The route keeps a token bucket per caller IP, checked before your session lookup, and one per subject, checked after it. The buckets live in Durable Objects, so a count is exact and global, and they store HMACs of the IP and subject, never the values. Past a limit the route answers 429 rate_limited with Retry-After; set a limit to false, or limits: false, to turn it off, and add allowRequest(key) for a check of your own, run after the IP limit with key = claim:<caller IP>. If the limiter cannot be reached, claims go through and the Worker logs claim_limit_unavailable. The client sends it as Authorization: Bearer <token> and renews on reconnect. In development, tapedb dev enables a dev claim route so the client needs no session at all; ENVIRONMENT=production refuses that route, and tapedb deploy refuses to ship the flag.
Schema changes and their SQL migrations are covered in Migrations.