Scoped Database
The security-filtered db handle inside an action — unsafe mode, raw SQL policy, and sharing code between actions.
Scoped Database
All actions receive a scoped db that applies each table's firewall —
the same generated WHERE clause the table's CRUD routes use:
| Operation | What the scoped db adds |
|---|---|
SELECT | the table's firewall (org / owner / team / scope / via: arms) AND soft-delete filter |
UPDATE / DELETE | the same firewall WHERE, AND-merged with yours |
INSERT | every required claim column the firewall binds (e.g. organizationId from ctx.activeOrgId), overwriting any value you passed — then, where those stamps can't guarantee the row passes the firewall (a via: / { permission } / literal / any: arm), the firewall-on-write check: every row of .values(row) or .values([rows]) must pass the table's firewall, or nothing is written and the action answers 403 FIREWALL_WRITE_DENIED |
INSERT … onConflictDoUpdate / onConflictDoNothing | the insert branch runs the same firewall-on-write check; the conflict update gets the firewall AND-merged into setWhere — a conflict with a row the caller can't update (another tenant's id) is a no-op, never an overwrite |
The firewall is set on the builder when you call .from() / .set() /
.delete(), so every executor gets it: await, .all(), .get(),
.values(), .run(), .execute(), .prepare(), and the builder embedded as
a subquery (inArray(col, db.select(...).from(t))). The firewall-on-write
check on an INSERT is async, so it runs when the insert executes — on
await, on any executor, and ahead of db.batch([...]). A .prepare()d
insert into a table that needs the check is refused (FIREWALL_WRITE_CHECK_UNSUPPORTED):
it would run later with nothing to check it.
The same refusal comes back from Postgres itself when an INSERT reaches the
database outside the caller's firewall (RLS 42501): the action route maps it
to the same 403 FIREWALL_WRITE_DENIED Problem, never a generic action
failure — so D1 and Postgres answer alike.
It is not a guess from column names. any: groups, optional arms, scope
(ctx.scope.*) and via: relationship arms, the null-claim deny, the
cms.sysadmin cross-tenant escape, and firewall: { exception: true } all
behave exactly as they do on CRUD — including custom per-table predicates such
as { field: 'linkedUserId', equals: 'ctx.userId' }.
Access decides whether the handler runs; the firewall still decides which
rows it touches. An action admitted by an uppercase marker (PUBLIC,
AUTHENTICATED, SCOPED, …) skips the Access layer's active-org
precondition, but not the firewall. A caller who carries none of the
claims a table's firewall can bind (no activeOrgId for an org-scoped table; neither
org nor ctx.scope.event for an any: [org, scope] table) cannot query it at
all: select, update, delete, $count and insert on that table all fail
with 403 FIREWALL_CLAIM_MISSING — the statement never runs, so it can never
silently match nothing, and an INSERT never lands in whatever tenant the
request body named. The Problem's details.claims lists the claim sets the
firewall accepts; the message names the fixes: require a role that carries
the claim, add a firewall arm for this caller's claim, or declare the action
unsafe and use unsafeDb (or unsafeDb.scopeTo({ organizationId }) with a
tenant you resolved yourself — the pattern for a PUBLIC door that looks a
row up by secret code first).
- Scope principals reach rows through the table's
ctx.scope.*arms, as on CRUD. - Delegated principals named in a table's
databaseAccesslane keep the soft-delete filter; their row bound is the lane's RLS policy. - Tables with no
defineTable(Better Auth tables, bare Drizzle tables) get column rules instead —organizationId = ctx.activeOrgId,ownerId = ctx.userId,teamId = ctx.activeTeamId(team only narrows when another rule binds), plusdeletedAt IS NULL— and a missing claim denies.
Caught at compile time
The action's db is typed against its own access. A table whose firewall
requires a claim that some caller your access admits may lack — an
AUTHENTICATED or PUBLIC action reading an org-scoped table, a
scope:<kind>:<role> action reading a table with no ctx.scope.<kind> arm —
is a tsc error on the from(...), insert(...), update(...) or
delete(...) call, instead of a query that silently matches nothing:
Property '"QUICKBACK: this action's access admits callers that lack a claim this
table's firewall requires … Fix: require an org role, add a scope or relationship
access arm that supplies the claim, or declare the action unsafe and use unsafeDb"'
is missing in type …Every alternative in access is checked on its own:
or: [{ roles: ['member'] }, { roles: ['scope:application:applicant'] }] may read a table whose firewall is
any: [org arm, ctx.scope.application arm], because each lane satisfies one arm.
A record action's own table is always accepted — the route loaded record
through its firewall. Tables with no defineTable, subqueries and raw sql
sources are not gated. Anchored actions get the same treatment for reads:
select().from(t) only accepts tables in the anchor's reach.
What each lane is credited with follows how its callers are proven. An org
role carries the user, org and team claims. A scope:<kind>:<role> lane
carries ctx.scope.<kind> — and also the user claim when the role's
subject is 'user' (the
default): such a role is only ever proven by a signed-in caller, so a via:
or owner arm can serve it. A role with subject: 'supplied' or 'both' can be
minted sessionless, so its lane is credited the scope claim only; an org-only
table (no ctx.scope.<kind> arm) is then a tsc error for that action — and a
403 at runtime for the sessionless caller — even though a signed-in attendee
would have reached it. Give the table a scope arm, branch the action into two
(or lanes are checked separately), or use unsafeDb for that read.
Proving a claim the type cannot see: db.given
Your code often knows more than the access lane does — a PUBLIC or
scope:<kind>:<role> action that branches on if (ctx.userId) is, inside that
branch, a signed-in caller. db.given({ userId: ctx.userId }) hands back the
same firewalled handle typed with that claim added to every lane. Its argument
must be a definite string, so it only compiles where TypeScript has narrowed the
value; at runtime it checks the value equals the caller's own claim and answers
403 FIREWALL_CLAIM_MISSING otherwise. It is a proof, never a grant.
async execute({ db, ctx }) {
if (ctx.userId) {
const mine = db.given({ userId: ctx.userId }); // user claim now credited
await mine.select().from(chatReads); // chat_reads pins userId = ctx.userId
}
}given accepts organizationId, userId and teamId (matched against
ctx.activeOrgId, ctx.userId, ctx.activeTeamId).
Re-scoping a raw handle: unsafeDb.scopeTo
A door that resolves the tenant itself — a PUBLIC action that looks an invite
up by secret code and then reads that tenant's rows — declares unsafe and uses
unsafeDb.scopeTo({ organizationId }). The result is a firewalled scoped db
for that tenant, typed with exactly the claims you passed, so everything after
the lookup is gated and typed again:
export default defineAction({
path: '/invite/enter-event',
access: { roles: ['PUBLIC'] },
// crossTenant: false — `unsafe: true` means crossTenant + adminOnly, which
// requires a sysadmin session and 403s an anonymous caller.
unsafe: { reason: 'Invite door — tenant resolved from the invite token', crossTenant: false },
async execute({ unsafeDb, input }) {
const [token] = await unsafeDb.select().from(inviteTokens).where(eq(inviteTokens.code, input.code));
if (!token) throw new ActionError('Invalid invite', 'INVITATION_INVALID_OR_EXPIRED', 404);
const db = unsafeDb.scopeTo({ organizationId: token.organizationId });
const [guest] = await db.select().from(guests).where(eq(guests.id, token.guestId)); // org firewall applies
// …
},
});scopeTo takes the same three keys as given; every value must be a definite
string.
One more source of claims is the route itself. An action under an
authz.namespaces prefix runs after
the namespace gate has stamped ctx.activeOrgId from the proven row or the
loaded instance, so every lane carries the org claim at runtime. Import that
namespace's define-action.<namespace> helper and the gate credits it; the
generic define-action helper cannot see the namespace and stays strict.
Not gated at the type level: joins (leftJoin(t) and friends) and $count —
both still go through the runtime firewall rules above ($count is scoped;
a joined table is not re-filtered, so join only through foreign keys you own).
db.with(...) is blocked outright.
async execute({ db, ctx, input }) {
// This query automatically includes WHERE organizationId = ? AND deletedAt IS NULL
const items = await db.select().from(applications).where(eq(applications.stage, 'interview'));
// Inserts automatically include organizationId
await db.insert(applications).values({ candidateId: input.candidateId, jobId: input.jobId, stage: 'applied' });
return items;
}Not enforced in scoped DB (by design):
- Guards — actions ARE the authorized way to modify guarded fields
- Masking — actions are backend code that may need raw data
- Access — already checked before action execution
Delegated principals and databaseAccess
On a Cloudflare project with Postgres over Hyperdrive (Neon connectionMode: "hyperdrive" or PlanetScale Postgres), an action can admit a
configured delegated principal with access.principals:
// features/attendance/actions/confirm.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
export default defineAction({
description: "Confirm attendance from an event session",
path: "/attendance/confirm",
input: z.object({ response: z.enum(["yes", "no"]) }),
access: { principals: ["event_delegate"] },
async execute({ ctx, db, input }) {
// ctx.principal is guaranteed and narrowed by the generated helper.
const { eventId, personId } = ctx.principal.claims;
return confirmAttendance(db, { eventId, personId, response: input.response });
},
});This is a separate authority lane, not an account impersonation. A delegated
action context has no userId, active organization/team, or membership roles.
If access has a PUBLIC sibling arm, the generated type includes the anonymous
lane and code must narrow ctx.principal before reading it.
For an organization- or team-scoped host feature, the standalone route does
not run the Better Auth activeOrgId / activeTeamId precondition before a
principal access tree. Authentication and evaluateAccessPreRecord still run;
the declared principals arm is the authority decision. This avoids turning an
account-session field into an accidental prerequisite for delegated callers.
The generated standalone route reuses that action-specific execute context
after its runtime access gates. Account, event_pass, and event_delegate
actions therefore keep distinct compile-time authority types all the way into
the handler. The route also reuses the action's schema-aware scoped db type,
including when an unsafe/service-role client is wrapped before the call. If a
relationship uses loads / exposeAs, the loaded row type is
intersected with the same authority context: a single or shared hydration
target is required, while targets selected by distinct relationship lanes are
optional and must be narrowed by the handler.
For tables queried by authored actions, resource.databaseAccess opens a
delegated-principal lane at the database: an independent Postgres RLS policy
with no HTTP surface. It never mounts a read/CRUD route and never adds an
OpenAPI operation. The action remains the API layer where validation, state
transitions, side effects, and business logic belong; databaseAccess is only
the database's row-level upper bound.
It exists for one reason. A delegated principal has no userId, no active
organization, and no membership roles, so the table's ordinary firewall
predicate — organizationId = ctx.activeOrgId — can never match for it. The
lane is the row rule that CAN: the principal's own verified claims.
// features/attendance/attendanceEvidence.ts
import { q, defineTable } from '@quickback/compiler';
export const attendanceEvidence = q.table('attendanceEvidence', {
id: q.id(),
eventId: q.text().required(),
personId: q.text().required(),
organizationId: q.scope('organization'),
...q.audit(),
...q.softDelete(),
});
export default defineTable(attendanceEvidence, {
// The ACCOUNT lane: ordinary organization-scoped CRUD. This is the firewall,
// and the firewall is the only pillar that lowers to SQL.
firewall: [
{ field: "organizationId", equals: "ctx.activeOrgId" },
],
// The DELEGATE lane: the event delegate has no active organization, so the
// firewall above can never admit it. Its rows are the ones its own claims
// name. Fields inside one arm AND; sibling `or` arms are independent lanes.
databaseAccess: {
select: {
principals: ["event_delegate"],
record: {
eventId: { equals: "$ctx.principal.claims.eventId" },
personId: { equals: "$ctx.principal.claims.personId" },
},
},
},
});One lane, one policy
The compiler emits exactly one permissive policy per table with a lane,
<table>_principal_scope, whose USING is the OR over the declared principal
arms. Two principal types on the same table are two arms of that one policy,
not two policies.
Its command is the lane's declared reach:
| Declared | Emitted |
|---|---|
select only | FOR SELECT, USING only — the lane reads and cannot write |
any of insert / update / delete | FOR ALL, with the same predicate as WITH CHECK |
Whether a delegate may write is a property of the lane, declared once by the author, so the database enforces it as the policy's command. Which verb a caller may reach is still the API's decision — RLS is the lane backstop, not a per-operation access matrix.
The policy is permissive, and PostgreSQL ORs permissive policies together,
so the lane opens beside the table's <table>_scope firewall policy rather
than narrowing it. That is the point — the delegated caller has no active
organization — but it means a databaseAccess arm is never a place to write a
restriction: a row that satisfies the arm is reachable even when the table
firewall would not have matched it, and even when it is soft-deleted.
Restrictions belong in firewall. The restrictive <table>_deny_anon and
<table>_principal_fence policies remain the only arms that cap every lane.
(The fence is emitted precisely because this table declares a lane; a table
with no databaseAccess and no access.principals gets neither the fence nor
the principal arm of deny_anon, which blocks every principal just the same.)
What the compiler refuses, and why
databaseAccess lowers only principals + record, plus recursive
or/and over those. Two shapes that used to be spellable here are now
compile errors:
roles, anywhere. Authored roles are enforced by the API and never reach SQL (generated RLS is a backstop, not a second copy ofaccess), so a role arm here promised an authorization the database did not enforce.rolesis absent from theDatabaseAccessRuletype as well, so an editor rejects it before the compiler does.- A
recordequality with noprincipalsgate. That is an owner or tenant rule, and those go infirewall— the one pillar that lowers to SQL. If the owner boundary must hold at the database, author it as a firewall arm.
Both produce: *databaseAccess lowers only delegated-principal lanes (principals
- record). Account-lane scope belongs in firewall; roles are enforced by the API.*
Every authorization path must also contain at least one record equality. A
principal-only leaf is rejected; every or arm must be bounded on its own,
because each is an independent lane. An and group is one lane whose children
may split the principal gate and the row bound between them. A node cannot mix
leaf fields with or/and, because the boolean node is the complete rule. This
mandatory shape is what stops a lane declaration from quietly granting a
delegated principal an entire table.
The supported equality sources are ctx.userId, ctx.activeOrgId,
ctx.activeTeamId, ctx.principal.actor.id, optional principal session/family
ids, and ctx.principal.claims.<name> (with or without a leading $). The
record keys are Drizzle/JavaScript table field names; Quickback resolves them
through the table metadata and quotes the exact physical PostgreSQL identifier,
so a field such as eventId: text("event-id") is safe. Unknown fields fail the
compile. Application-only values — userRole, functions, request/body fields —
stay at the action access layer; the compiler refuses them rather than
pretending PostgreSQL can evaluate them. The feature is rejected outside
Cloudflare + Postgres over Hyperdrive (Neon or PlanetScale Postgres) because no
other target can guarantee
request-scoped Postgres claims.
Upgrading. The four per-operation <table>_database_<op> policies are
retired. One appended quickback_rls migration drops them on every table and
creates the single <table>_principal_scope where a lane is declared —
append-only, like every other security migration. A databaseAccess block that
still carries roles (or a principal-less record) fails the compile with the
message above; move the account arm to firewall and keep the principal arm.
Scoped relational-query safety
Drizzle's relational query API — db.query.<table>.findFirst() /
findMany() (including nested with: relations) — builds its SQL from the
relations config and your where alone, so the scoped wrapper cannot inject
the org/owner/team firewall into it. The scoped db therefore exposes the
Drizzle select builder—the path where Quickback can guarantee firewall
injection—and refuses db.query.<table> rather than returning unscoped rows.
The compiler rejects authored uses before code generation, including Start's
definition-validation build; the generated runtime guard remains as a
fail-closed backstop:
async execute({ db, input }) {
// ❌ Compile error: relational queries aren't org-scoped
const item = await db.query.media.findFirst({ where: eq(media.id, input.id) });
// ✅ Scoped builder — firewall conditions injected automatically
const [item] = await db.select().from(media).where(eq(media.id, input.id));
}For nested reads, compose joins with the scoped select builder. If a
Postgres-specific relational query is genuinely required, make the boundary
explicit with unsafe: true and query unsafeDb.query with the full scope
predicate in the WHERE (for example,
eq(media.organizationId, ctx.activeOrgId)).
Unsafe Mode
Actions that need to bypass scoped DB filters (for example, platform-level support operations) can enable unsafe mode.
unsafeDb and a raw createDb() handle are unchecked by design: no
firewall WHERE, no claim stamping, no firewall-on-write check. On Postgres a
non-cross-tenant unsafeDb still runs under the caller's RLS claims, so an
insert the policy refuses comes back as 403 FIREWALL_WRITE_DENIED; on D1 it
lands. That is the point of the escape hatch — and why it is audited.
unsafe is the only sanctioned way around the scoped db. It is a compile
error (ACTION_RAW_DB_WITHOUT_UNSAFE) when a non-unsafe action reaches a
raw database handle instead. The check covers the action file and every file
it imports, transitively, from the feature's lib/ and the project's lib/,
hooks/, or plugins/ (relative or ~/ imports). A raw handle is any of:
c.get('db')- a database binding off the environment (
env.DB,c.env.HYPERDRIVE,env.DB.prepare(…), …) - a drizzle driver import (
drizzle-orm/d1,drizzle-orm/postgres-js, …) - a call to
drizzle(…),createDb(), orcreateAuthDb() auth.$context(Better Auth's adapter)- the action's own
unsafeDb
Each error names the action, file, line, and handle. If the access is
intentional, declare unsafe: { reason: "…" } so it is audited (or
firewall: { exception: true } for a deliberately unscoped action). To adopt
the check on an existing project, compiler.securityContracts.failMode: "warn"
downgrades it to a warning. Writes to Better Auth tables are a separate error,
ACTION_AUTH_DB_WRITE, that plain unsafe does not silence: only
unsafe: { reason: "…", crossTenant: false, authWrites: true } does. Call auth.api.* instead
where Better Auth has an endpoint. See
Diagnostics → ACTION_AUTH_DB_WRITE.
Use object form for explicit policy and mandatory audit metadata:
// features/admin/actions/crossTenantReport.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
import { applications } from "../../applications/applications";
export default defineAction({
description: "Generate cross-org hiring report",
path: "/admin/cross-tenant-report",
input: z.object({ startDate: z.string() }),
access: { roles: ["owner"] },
unsafe: {
reason: "Support investigation for enterprise customer",
adminOnly: true, // default true
crossTenant: true, // default true
targetScope: "all", // "all" | "organization"
},
async execute({ unsafeDb, input }) {
// unsafeDb bypasses scoped filters
const allOrgs = await unsafeDb.select().from(applications);
return allOrgs;
},
});Unsafe cross-tenant actions are generated with:
- Better Auth authentication required (no unauthenticated path)
- sysadmin gate (
ctx.userRole === "sysadmin") - mandatory audit logging on deny/success/error
Provider note (Neon, PlanetScale Postgres): unsafeDb is the
service-role handle (createServiceDb, wrapped by the audit writer) for
every unsafe action, crossTenant: true or false — the same reach as on
D1. It sets the quickback.service_role transaction marker instead of caller
claims, and generated RLS grants that marker an explicit per-table policy. So
RLS does not narrow unsafeDb to the caller: a PUBLIC door's token lookup
finds the row, and a roster join over member sees every member. The action's
own checks — and unsafeDb.scopeTo(...) — govern, exactly as on D1.
crossTenant decides the gate, not the handle: crossTenant: true adds the
sysadmin guard and mandatory audit log described above. The
audit-logging contract is otherwise identical to D1. On Cloudflare, both HTTP
and Hyperdrive are supported. Hyperdrive is the recommended lane when an unsafe
action also needs an interactive transaction. The deprecated Node/Bun WebSocket
mode is refused for unsafe actions because it cannot provide the same
request-scoped service-role boundary.
PUBLIC actions also receive mandatory audit logging (same audit table), since unauthenticated endpoints are high-risk by nature.
Without unsafe mode, unsafeDb is undefined in the executor params.
Operator lane: operator:
A platform operator acting on one tenant — set a customer's plan, fix an
event's status, seed an organization — is not a cross-tenant query. Declare the
target instead of reaching for unsafe:
// features/events/actions/setEventStatus.ts
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { defineAction, events } from '../.quickback/define-action';
export default defineAction({
description: "Platform operator: set an event's status in whichever tenant owns it",
path: '/events/operator/set-status',
input: z.object({ eventId: z.string(), status: z.enum(['draft', 'live', 'closed']) }),
access: { userRole: ['sysadmin'] },
operator: { target: { table: 'events', by: 'input.eventId' } }, // or table: 'organizations', by: 'input.organizationId'
async execute({ db, target, input }) {
// `db` is firewalled to target.organizationId — not the operator's own org.
const [row] = await db.update(events).set({ status: input.status })
.where(eq(events.id, target.id)).returning();
return row;
},
});Before execute runs, the route:
- Checks the
userRolelane (403 otherwise). An unauthorized caller never triggers the lookup. - Loads the target row by
input.<field>— across tenants, on purpose (the raw handle on D1, the service-role handle on Postgres; Better Auth's organization table fortable: 'organizations'). - Answers 404
OPERATOR_TARGET_NOT_FOUNDwhen the row is absent or soft-deleted. Settarget: { …, includeDeleted: true }to admit a soft-deleted row. - Hands
executethe row astargetand adbequal tounsafeDb.scopeTo({ organizationId })— the target'sorganizationId, or the organization's ownidwhen the target isorganizations. Every statement after that is firewalled to the target tenant, and inserts are stamped with it.unsafeDbis not passed. On Postgres thatdbwraps the service-role handle, the same asunsafeDb.scopeTo: the firewallWHEREand insert stamps are the boundary, and RLS does not narrow it to the target tenant as a backstop.
Every call writes one audit row (eventKind: 'operator_action') to the same
audit_events table unsafe actions use, on denial, success and
error. A failed audit write fails the request (500 ACCESS_AUDIT_LOG_FAILED).
| Column | Value |
|---|---|
targetOrgId | the target's organization (null when the request was denied before the target loaded) |
resource | the target table (events, organizations) |
targetRecordId | the requested id |
actorUserId / actorOrgId | the operator and their own active org |
metadata.operator | { targetTable, targetId } |
metadata.input | the action input, with every key named like a masked, .encrypted(), .sealed() or vault-ref column in the project replaced by "[REDACTED]" |
The return value is never stored.
The compiler refuses:
operatorwithout auserRole-only access lane —access: { userRole: [...] }and nothing else (noroles,or,principals).operatortogether withunsafe,anchor,recordorfirewall— one or the other.operatoron a record action (nopath:) or under anauthz.namespacesprefix.target.bythat is notinput.<field>of a required field in an inlinez.objectinput.target.tablethat is not an org-firewalled feature table (an org arm bound toctx.activeOrgId) or'organizations';includeDeletedon a table without soft delete.
A row in an org-firewalled table with no organization is refused with 500
OPERATOR_TARGET_TENANT_UNRESOLVED rather than run unscoped.
emit: and invalidates: on an operator action address the target
tenant's org room — the same organization the db is scoped to — never the
operator's own active org. A request whose by field is missing or empty is
rejected with 422 before the lookup.
Raw SQL Policy
By default, the compiler rejects raw SQL in action code. Use Drizzle query-builder syntax whenever possible.
If a specific action must use raw SQL, opt in explicitly:
// features/ledgers/actions/reconcile.ts
import { z } from "zod";
import { sql } from "drizzle-orm";
import { defineAction } from "../.quickback/define-action";
export default defineAction({
description: "Run custom reconciliation query",
input: z.object({}),
access: { roles: ["owner"] },
allowRawSql: true,
async execute({ db }) {
return db.execute(sql`select 1`);
},
});Without allowRawSql: true, compilation fails with a loud error pointing to the action and snippet.
The detector checks for SQL keywords in string arguments, so non-SQL method calls like headers.get("X-Forwarded-For") or map.get("key") will not trigger false positives.
Race-safe writes: insertWhere, updateWhere, ledger
D1 runs one statement at a time, but it has no transaction that spans two
statements. So "read, decide, then write" races another request: two votes
both see "no vote yet", two reservations both see "3 credits left". The
scoped db has three methods that put the decision inside the write, so
the database makes it once.
// One vote per voter, only while the poll is open — ONE statement:
// INSERT INTO votes (…) SELECT … WHERE EXISTS (open poll) AND NOT EXISTS (my vote)
const vote = await db.insertWhere(votes, { pollId, voterId: ctx.userId!, choice }, {
exists: { table: polls, where: and(eq(polls.id, pollId), eq(polls.status, "open")) },
notExists: { table: votes, where: and(eq(votes.pollId, pollId), eq(votes.voterId, ctx.userId!)) },
});
if (!vote) return { voted: false }; // the guard did not hold; nothing was written
// UPDATE votes SET choice = ? WHERE <where> AND EXISTS (open poll)
const changed = await db.updateWhere(votes, { choice }, {
where: and(eq(votes.pollId, pollId), eq(votes.voterId, ctx.userId!)),
exists: { table: polls, where: and(eq(polls.id, pollId), eq(polls.status, "open")) },
});| Method | Statement | Resolves to |
|---|---|---|
db.insertWhere(table, values, { exists?, notExists? }) | INSERT … SELECT … WHERE [NOT] EXISTS (…) | the inserted row, or null when the guard failed |
db.updateWhere(table, set, { where, exists?, notExists? }) | UPDATE … WHERE <where> AND [NOT] EXISTS (…) | the updated rows ([] when nothing matched) |
- Everything the scoped
dbdoes still applies. The row is built by the ordinary insert path — scope columns stamped, audit columns hard-stamped, firewall-on-write checked, trigger hooks run, encrypted columns encrypted — and only theVALUESlist becomes a guardedSELECT. - Probes are firewalled. Each
exists/notExistsprobe ANDs its own table's firewall into its WHERE, so a guard can never test another tenant's rows. Pass one probe or an array; at least one is required, and every probe needs awhere(a probe with no WHERE would test the whole table).updateWhererefuses a missingwhere. - One row.
insertWheretakes a single values object. - Postgres. Under
READ COMMITTED, two concurrentINSERT … WHERE NOT EXISTSstatements can both succeed, so on Postgres the guarded statement runs afterpg_advisory_xact_lockin the same transaction (an interactive transaction, ordb.batch()on neon-http, which runs as one transaction). AnupdateWherewith no probes needs no lock: an UPDATE re-checks its own WHERE on the row it writes.
Ledgers: db.ledger.<name>
A ledger is a balance that must never go negative — credits, seats, quota,
stock. Declare it on the debits table; the grants table needs its own
defineTable:
// features/credits/credits.ts
export default defineTable(creditDebits, {
read: { access: { roles: ["owner", "admin"] } },
ledger: {
name: "credits", // db.ledger.credits (default: the table name)
grants: "creditGrants", // the grants table
reserve: { amountColumn: "amount", subjectColumn: "accountId", statusColumn: "status" },
},
});const reservation = await db.ledger.credits.reserve(input.accountId, 1, { purpose: "sms" });
if (!reservation) throw new ActionError("Not enough credits", "INSUFFICIENT_CREDITS", 402);
try {
await sendSms();
await db.ledger.credits.finalize(reservation.id);
} catch (err) {
await db.ledger.credits.release(reservation.id);
throw err;
}| Call | Statement | Resolves to |
|---|---|---|
reserve(subject, amount, values?) | INSERT INTO debits … WHERE Σ grants − Σ live debits >= amount | the reserved debit, or null when the balance is short |
finalize(id) | UPDATE debits SET status = 'finalized' WHERE id = ? AND status = 'reserved' | the row, or null if it was already settled |
release(id) | UPDATE debits SET status = 'released' WHERE id = ? AND status = 'reserved' | the row, or null if it was already settled |
balance(subject) | two firewalled sums | grants − (reserved + finalized) |
amountColumn and subjectColumn must exist on both tables; the debits table
also needs id and the status column (default status), which holds
reserved / finalized / released. Released debits do not count against the
balance; both sums are firewalled, and soft-deleted rows drop out of them like
every other read. amount must be a positive number (400 LEDGER_INVALID_AMOUNT
otherwise), and values may not set the subject, amount or status columns.
A missing table or column, or an unknown key, is a compile error.
Sharing Code Between Actions
For Zod schemas, helpers, or constants used across multiple actions in a
feature, put them under <feature>/lib/ and import from each action file.
Files under lib/ are copied verbatim into the generated output.
features/applications/
├── applications.ts
├── actions/
│ ├── advance.ts
│ └── reject.ts
└── lib/
├── inputs.ts ← shared Zod schemas
└── transitions.ts ← state-machine helpers// features/applications/lib/inputs.ts
import { z } from "zod";
export const advanceInput = z.object({
nextStatus: z.enum(["screening", "interview", "offer"]),
notes: z.string().max(2000).optional(),
});// features/applications/actions/advance.ts
import { defineAction } from "../.quickback/define-action";
import { advanceInput } from "../lib/inputs";
export default defineAction({
description: "Move forward",
input: advanceInput,
access: { roles: ["owner"] },
async execute({ db, record, input }) { /* … */ },
});Importing Tables
The action's own generated helper supplies defineAction and the tables owned
by that feature. It does not re-export tables from sibling features. Import a
cross-feature table from that feature's generated helper or generated table
module, and import Drizzle query operators through the generated project
lib/q:
// features/waitlist/actions/promote.ts
import {
defineAction,
waitlistEntries,
} from "../.quickback/define-action";
import { reservations } from "../../reservations/.quickback/define-action";
import { q } from "../../../lib/q";
export default defineAction({
// ...input, access, and transition...
async execute({ db, record, whereRecord, now }) {
const existing = await db
.select()
.from(reservations)
.where(q.eq(reservations.waitlistEntryId, record.id));
await db
.update(waitlistEntries)
.set({ promotedAt: now })
.where(whereRecord!(waitlistEntries));
return existing;
},
});Path pattern from features/{name}/actions/:
- Own feature helper:
../.quickback/define-action - Other feature helper:
../../{other-feature}/.quickback/define-action - Other generated table module:
../../{other-feature}/{table-file-name} - Generated project query helper:
../../../lib/q - Other generated lib files:
../../../lib/{module}(for example,../../../lib/realtimeor../../../lib/webhooks)
Keep defineAction from the action's own feature: that helper binds record,
whereRecord, and whereTransition. Import only the external table from a
sibling feature. The waitlist/reservations names above are just the common
pattern; the rule applies to every cross-feature action.
Executor Parameters
import type { Auth } from "better-auth";
interface ActionExecutorParams {
db: DrizzleDB; // Scoped database (auto-applies org/owner/soft-delete filters)
unsafeDb?: DrizzleDB; // Unscoped database (only available when unsafe mode is enabled)
target?: TRow; // The loaded operator target (operator lane only — see Operator lane)
ctx: AppContext; // User context (userId, roles, activeOrgId, activeTeamId, userRole)
record?: TRecord; // The record (record-based only, undefined for standalone)
input: TInput; // Validated input from Zod schema
services: TServices; // Configured integrations (billing, notifications, etc.)
c: HonoContext; // Raw Hono context for advanced use
env: Env; // Runtime bindings (Cloudflare env / process env). Alias for c.env.
auth: Auth; // Better Auth instance — call auth.api.getSession(...) etc. Lazy on Cloudflare.
whereRecord?: (table) => SQL; // Pre-built WHERE: firewall + soft-delete + eq(table.id, record.id). Record-based actions only.
whereTransition?: (table) => SQL; // whereRecord plus the transition's "from" precondition. Transition actions only.
}auth — Better Auth instance
auth is the Better Auth instance, ready to call from inside an action
handler. Use it for things like minting an OAuth access token, fetching the
current session in a custom way, or invoking a Better Auth plugin method
that isn't already exposed by the generated middleware.
// features/integrations/actions/connectGoogleDrive.ts
import { defineAction } from "../.quickback/define-action";
import { z } from "zod";
export default defineAction({
description: "Get a fresh Google Drive access token for the signed-in user",
input: z.object({}),
access: { roles: ["AUTHENTICATED"] },
async execute({ auth, ctx, c }) {
// genericOAuth plugin method — needs a cast since the default-generic
// `Auth` type doesn't carry plugin-specific API surface.
const token = await (auth.api as any).getAccessToken({
headers: c.req.raw.headers,
body: { providerId: "google", userId: ctx.userId },
});
return { accessToken: token.accessToken };
},
});Lazy on Cloudflare: the generated handler emits a Proxy so
createAuth(c.env, c.req.raw.cf) only runs the first time you read a
property on auth. Actions that never touch auth pay zero construction
cost. On Bun/Node auth is the imported singleton (an IIFE that lazy-loads
the database adapter) wrapped in the same proxy shape — call sites are
identical across runtimes.
Typing: auth is typed as Auth from better-auth. The base API
(getSession, signOut, signInEmail, etc.) is fully typed. Plugin-only
methods (getAccessToken from genericOAuth, admin-plugin methods, etc.)
need a cast at the call site — the default-generic Auth doesn't infer
the project's specific plugin set.
whereRecord — explicit, scope-safe WHERE
For protected-field writes inside record-based actions, prefer
whereRecord(table) over eq(table.id, record.id). It returns a Drizzle
expression that AND-merges the resource's firewall and soft-delete predicates
with the record id, so the WHERE is explicit even if a future refactor swaps
db back to an unscoped client:
// features/applications/actions/advance.ts
import { z } from "zod";
import { defineAction, applications } from "../.quickback/define-action";
export default defineAction({
description: "Advance an application to the next status.",
input: z.object({
nextStatus: z.string(),
notes: z.string().optional(),
}),
access: { roles: ["owner"] },
async execute({ db, record, input, whereRecord }) {
const [updated] = await db
.update(applications)
.set({
status: input.nextStatus,
notes: input.notes ?? record.notes,
})
.where(whereRecord!(applications)) // firewall + soft-delete + id, all in one
.returning();
return updated;
},
});The audit DB wrapper hard-stamps modifiedAt/modifiedBy on every
.set() call, so handlers don't write them. The same wrapper hard-stamps
createdAt/createdBy/modifiedAt/modifiedBy on every .values() —
and on the conflict branch of an onConflictDoUpdate({...}) upsert.
Caller-supplied audit fields are dropped on the floor — the audit log
always reflects the real actor and the real moment.
Who the stamped actor is depends on the verified principal, never on anything the handler passes:
| Caller | createdBy / modifiedBy value |
|---|---|
| Signed-in user | the Better Auth userId |
| Delegated principal (API key, service) | actor:<type>:<id> |
| Scope-token principal (no account) | scope:<kind>:<subjectId> |
Cron schedule via withInternalContext | system:cron-<name> |
Queue handler via withInternalContext | system:queue-<name> |
| No verified caller | the write throws (fail closed) — system-side code that must write outside a request uses the raw client and owns its own provenance |
Don't hand-build audit timestamps as a "just in case" fallback: on the
wrapped db they're dead code, and the idiom is a portability trap —
Postgres audit columns take Date objects while SQLite takes ISO strings
(the wrapper picks the right one per dialect).
whereRecord is undefined for standalone actions (they have no record), so
the parameter is typed as optional. whereTransition extends whereRecord
with the transition's "from" precondition for transition actions.
ctx.auth — Better Auth users and members, org-bound
Changing a user — banning them, changing their role, deleting the account — through a raw authDb handle bypasses the revocation stamps used to invalidate outstanding JWTs. Use ctx.auth instead. It is hydrated on every request context in Cloudflare + Better Auth projects and typed on execute's ctx:
// features/guests/actions/banMember.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
export default defineAction({
description: "Ban a member of the active organization.",
path: "/members/ban",
input: z.object({ userId: z.string(), reason: z.string().optional() }),
access: { roles: ["owner", "admin"] },
async execute({ ctx, input }) {
await ctx.auth.ban(input.userId, input.reason);
return { banned: input.userId };
},
});| Method | Requires | Does |
|---|---|---|
ctx.auth.ban(userId, reason?) | admin plugin | Sets banned + banReason, deletes every session. You cannot ban yourself. |
ctx.auth.setRole(userId, role, { orgId? }) | organization plugin | Sets the member's role in the active organization (role is typed to the declared organization roles). The last owner cannot be demoted (409). |
ctx.auth.deleteAccount(userId) | — | Deletes the user's memberships, sessions, linked accounts, then the user. Deleting yourself is always allowed. |
ctx.auth.members.list(orgId?) | organization plugin | The organization's members: { id, userId, organizationId, role, createdAt, user: { id, email, name, image } }. |
ctx.auth.claims.backfill(opts?) | auth.claims | Links rows that predate their account. |
Every method writes through Better Auth's adapter with its schema mapping. setRole adds a database-enforced guard to that adapter update so concurrent calls cannot both demote the remaining owners; PostgreSQL calls serialize per organization. A stale membership or target platform-role read also fails with 409 AUTH_SURFACE_LAST_OWNER, so reload before retrying. Organization endpoint lifecycle hooks are not invoked by this action surface; account methods use Better Auth's internal adapter where applicable. With auth.jwt.revocationCheck: 'kv' each write stamps qbrev:user:<id> (and qbrev:member:<id>:<org> for a role change), so outstanding JWTs die within seconds; if the stamp cannot be written the method throws 503 AUTH_REVOCATION_UNAVAILABLE instead of reporting success. Platform roles (user.role) are not ctx.auth's job: change them with Better Auth's /admin/set-role, which is on the revocation kill list too.
Who may act on whom. The action's access decides who can call it; ctx.auth then bounds what the call can touch:
- An operator — a caller whose platform role is one of the admin plugin's
adminRoles(appmanager,sysadminby default) — may act on any user whose platform role ranks at or below its own, and may name another organization (orgId). - Everyone else is org-bound. The target must be a member of the caller's active organization (otherwise
404 AUTH_SURFACE_NOT_FOUND— nothing is learned about other tenants' users), must hold an organization role at or below the caller's (auth.roleHierarchy, defaultmember < admin < owner), and must not be an operator.setRolemay not assign a role above the caller's own.bananddeleteAccountchange the whole account, so they also refuse a target who belongs to any other organization.
Errors are action errors: 401 AUTH_SURFACE_UNAUTHENTICATED (no account caller), 403 AUTH_SURFACE_NO_ORG, 403 AUTH_SURFACE_OUT_OF_SCOPE, 403 AUTH_SURFACE_ROLE_CEILING, 400 AUTH_SURFACE_INVALID_ROLE, 400 AUTH_SURFACE_SELF, 404 AUTH_SURFACE_NOT_FOUND, 409 AUTH_SURFACE_LAST_OWNER, 503 AUTH_REVOCATION_UNAVAILABLE.
ctx.unmask — reading a q.secret() column
A q.secret() column is never returned by any read path, and the rows your action reads hold its ciphertext. ctx.unmask(row, column) is the one server-side read: it decrypts with the row's own organization key and records a best-effort masked_reveal audit entry.
// features/integrations/actions/checkSecret.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
export default defineAction({
description: "Check an integration's client secret without revealing it.",
input: z.object({ candidate: z.string() }),
access: { roles: ["admin+"] },
async execute({ ctx, record, input }) {
const secret = await ctx.unmask(record, "clientSecret");
return { matches: secret === input.candidate };
},
});column is the property name when no other table has a secret column of that name, or { table, column } (SQL table name + property name) — always accepted, and required for a shared name. The row must carry the column and its organization column; null unmasks to null.
Reaching Better Auth tables (authDb)
The scoped db is the features database. Better Auth tables never appear
on it — that is by design. AUTH_DB is a separate Drizzle client.
Changing a user or member — use ctx.auth, never a write on authDb. A raw write skips Better Auth's hooks and leaves the user's JWTs alive.
Caller identity — use ctx, do not query AUTH_DB. ctx.userId,
ctx.activeOrgId, ctx.roles, and ctx.userRole are already on the request.
To check that a submitted user id is an org member (assignee-style
features), use c.get("authDb") plus the generated getOrgMemberRole helper:
// features/projects/actions/assign.ts — validate a userId is an org member
import { z } from "zod";
import { eq } from "drizzle-orm";
import { ActionError } from "@quickback/compiler";
import { defineAction, projects } from "../.quickback/define-action";
import { getOrgMemberRole } from "../../../lib/org-access";
export default defineAction({
description: "Assign a project to an org member",
input: z.object({ assigneeId: z.string() }),
access: { roles: ["member", "admin"] },
async execute({ db, record, input, ctx, c, whereRecord }) {
// getOrgMemberRole(authDb, userId, organizationId) → role string | null
const role = await getOrgMemberRole(c.get("authDb"), input.assigneeId, ctx.activeOrgId!);
if (!role) {
throw new ActionError("Assignee is not a member of this organization", "NOT_A_MEMBER", 400);
}
const [updated] = await db
.update(projects)
.set({ assigneeId: input.assigneeId })
.where(whereRecord!(projects))
.returning();
return updated;
},
});c.get("authDb") is the Drizzle connection to the auth database (in dual-DB
mode it's a separate D1 from your features). That is the native membership
check.
On a single-database project — Postgres, or D1 without split databases — it is
the same database as your features. On Postgres it runs under the caller's
claims, so Better Auth's own RLS applies: members rows of the active
organization are readable, users only the caller's own row. To list the
active organization's members with names and emails, use the generated
roster read.
For a richer query, use the same authDb and import tables from
../../../auth/schema — from an actions/ file. On D1 the tables are
user, organization, and member — not organization_membership. (Neon
exports the Better Auth CLI's plural names: users, organizations,
members.)
| Surface | AUTH_DB handle | Notes |
|---|---|---|
Action execute | c.get("authDb") | Import schema from ../../../auth/schema |
| Account hooks | { authDb, authSchema } injected | Use the injected namespace — do not invent a path to auth/schema |
Trigger handler: | none | Args are { ctx, db, env } — db is features only. There is no c. Prefer ctx for the acting user. Do not import ../../auth/schema from the table file to reach AUTH_DB; that path used to compile and then fail package-mode wrangler. Check another user's membership in an action. |