Quickback Docs

Access - Role & Condition-Based Access Control

Define who can perform read and write operations and under what conditions. Configure role-based and condition-based access control for your API endpoints.

Define who can perform read and write operations and under what conditions.

Basic Usage

// features/applications/applications.ts
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { defineTable } from '@quickback/compiler';

export const applications = sqliteTable('applications', {
  id: text('id').primaryKey(),
  candidateId: text('candidate_id').notNull(),
  jobId: text('job_id').notNull(),
  stage: text('stage').notNull(),
  notes: text('notes'),
  organizationId: text('organization_id').notNull(),
  // ── quickback:audit (compiler-managed — edits are validated, not merged) ──
  createdAt: text('created_at').notNull().default('1970-01-01T00:00:00.000Z').$defaultFn(() => new Date().toISOString()),
  modifiedAt: text('modified_at').notNull().default('1970-01-01T00:00:00.000Z').$defaultFn(() => new Date().toISOString()).$onUpdate(() => new Date().toISOString()),
  createdBy: text('created_by'),
  modifiedBy: text('modified_by'),
  deletedAt: text('deleted_at'),
  deletedBy: text('deleted_by'),
});

export default defineTable(applications, {
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  guards: { createable: ["candidateId", "jobId", "notes"], updatable: ["notes"] },
  read: {
    access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] },
  },
  create: { access: { roles: ["owner", "hiring-manager", "recruiter"] } },
  update: { access: { roles: ["owner", "hiring-manager", "recruiter"] } },
  delete: { access: { roles: ["owner", "hiring-manager"] } },
});

Role Hierarchy

If your project uses a tiered role system (e.g., member < admin < owner), you can define a hierarchy in your config and use the + suffix to mean "this role and above":

// quickback.config.ts
export default defineConfig({
  name: "my-app",
  auth: {
    roleHierarchy: ['member', 'admin', 'owner'],  // lowest → highest
  },
  // ...
});

Then in your resource definitions:

read: { access: { roles: ["member+"] } },   // member, admin, owner
create: { access: { roles: ["admin+"] } },     // admin, owner
delete: { access: { roles: ["owner"] } },      // owner only (no +)

The + suffix expands at compile time — "member+" becomes ["member", "admin", "owner"] in the generated code. You can mix hierarchical and exact roles: roles: ["member+", "finance"] expands to ["member", "admin", "owner", "finance"].

  • Roles without + are exact matches (no expansion)
  • Pseudo-roles / reserved markers (PUBLIC, AUTHENTICATED, SESSION, SCOPED, USER, ADMIN, SYSADMIN) cannot use the + suffix — they're terminal markers
  • Using + with a role not in the hierarchy throws a compile error
  • Using + without configuring auth.roleHierarchy throws a compile error

Custom org roles

You're not limited to Better Auth's built-in owner / admin / member — any lowercase role name you reference in an access tree ("recruiter", "hiring-manager", "support", …) is a custom org-membership role, matched against ctx.roles like the built-ins.

As of v0.46, custom roles declared in access trees are auto-registered with the Better Auth organization plugin, so members can actually be provisioned into them: invite-member and update-member-role accept them (previously they rejected custom roles with 400 ROLE_NOT_FOUND). Auto-registered roles carry member-equivalent Better Auth permissions — registration is about assignability; your access trees remain the authorization model. If you pass your own roles / ac to the organization plugin options, those win and auto-registration is skipped.

Configuration Options

interface Access {
  // Org-membership roles (OR logic — user needs at least one)
  // Matched against ctx.roles. Use "role+" suffix for hierarchy expansion.
  roles?: string[];

  // User-table role (OR logic). Matched against ctx.userRole from the
  // user.role column — independent of org membership. See userRole below.
  userRole?: string[];

  // Record-level conditions
  record?: {
    [field: string]: FieldCondition;
  };

  // Combinators
  or?: Access[];
  and?: Access[];
}

userRole vs roles

roles and userRole check two different fields and are often confused — get this wrong and you'll grant access too broadly.

PrimitiveChecksSourceExample values
rolesctx.rolesOrg membership roles"owner", "admin", "member"
userRolectx.userRoleThe user.role column (Better Auth / platform control-plane roles)"user", "appmanager", "sysadmin"

In multi-tenant mode, roles: ["admin"] means "org admin" — not a platform-wide role. An org-admin of Acme Corp has no elevated rights across other orgs. If you want platform control-plane operators through regardless of org membership (CMS, Better Auth admin surfaces, support tooling), use userRole: ["appmanager"]. If you need true cross-tenant data-plane access, use roles: ["SYSADMIN"] (with cms: { sysadmin: true }).

Pinned organization mode does not change this split. roles still come from organization membership in the pinned org, while userRole still comes from Better Auth's user.role column.

When to use userRole

// Platform control-plane users can see SSNs on every customer record, regardless of org
masking: {
  ssn: { type: 'ssn', show: { userRole: ['appmanager'] } },
}

// Platform ops (user.role === 'appmanager') OR the org owner can refund
access: {
  or: [
    { userRole: ['appmanager'] },
    { roles: ['owner'] },
  ],
}

// CMS-internal resource: only platform control-plane users
read: { access: { userRole: ['appmanager'] } },
create: { access: { userRole: ['appmanager'] } },
update: { access: { userRole: ['appmanager'] } },
delete: { access: { userRole: ['appmanager'] } },

When roles and userRole both appear in a single Access node, they're combined with AND — the user must satisfy both. Use or: to express "one or the other".

userRole doesn't expand with the + hierarchy suffix — user-table roles are flat (typically just "user", "appmanager", and "sysadmin"). If you've added custom user-table roles via Better Auth, list them explicitly.

Pseudo-roles

Quickback ships seven reserved UPPERCASE markers. Six are real pseudo-roles handled directly by the compiler; ADMIN is retained only as a reserved legacy marker so the compiler can fail loudly with migration guidance. The casing carries the boundary: UPPERCASE = compiler-owned marker, lowercase = real role from a plugin's table (e.g. members.role from the org plugin).

RoleAuth gateCompile-time requirementRuntime check
PUBLICbypassednone — explicit opt-in for unauthenticated accessalways passes (auth skipped)
AUTHENTICATEDrequirednonectx.authenticated === true (a full session or a scope principal — the wrapper over SESSIONSCOPED)
SESSIONrequirednonea full Better Auth session onlyctx.authenticated && !ctx.principal && !ctx.scopePrincipal
SCOPEDrequirednonea sessionless scope principal onlyctx.authenticated && ctx.scopePrincipal === true
USERrequiredresource firewall must scope by userIda real signed-in user — ctx.authenticated AND not a scope principal (!ctx.scopePrincipal) AND (ctx.userRole unset OR === "user"). A sessionless scope caller does not satisfy USER (use SCOPED / scope:<kind>:<role>); AUTHENTICATED is the marker that admits both flavors.
ADMINrequiredreserved legacy marker — compiler rejects itfalse
SYSADMINrequiredcms: { sysadmin: true } must be set on the projectctx.authenticated && ctx.userRole === "sysadmin"

You can mix pseudo-roles with real roles in the same list — they're OR'd together.

PUBLIC — unauthenticated access

Use roles: ["PUBLIC"] for public-facing endpoints like contact forms, public listings, or webhooks. Skips both the auth gate and the role check.

access: { roles: ["PUBLIC"] }

Important:

  • PUBLIC skips authentication and role checks — anyone can call the endpoint
  • PUBLIC is the explicit, uppercase opt-in for unauthenticated access. It works on every access node — reads, top-level writes (create/update/delete/upsert), views, and actions. The compiler trusts the marker; row-level safety comes from the firewall, masking, and (for token-gated endpoints like RSVP confirm, magic-link redemption, List-Unsubscribe-Post, webhook ingest) your handler's own validation. Anonymous write endpoints typically pair roles: ["PUBLIC"] with firewall: { exception: true } on the resource so the row predicate doesn't reject every unauthenticated caller.
  • Firewall still applies — data is scoped by the firewall (organization, owner, team)
  • Tree semantics: PUBLIC admits anonymous callers from anywhere in the access tree, not just a flat roles list. An or: arm containing PUBLIC admits anonymous callers past the authentication gate; an and: group admits them only when every arm does. The access, org, and firewall layers still evaluate the full tree afterwards. (Before v0.46 the emitted auth gate only honored PUBLIC in a flat roles list — a nested PUBLIC arm incorrectly answered 401.)
  • Every PUBLIC action invocation is mandatory audit logged to the security audit table (IP address, input, result, timing)
  • The wildcard "*" is not supported — using it throws a compile-time error
  • Tables with no isolation columns and any PUBLIC route do not require firewall: [{ exception: true }]

For org-scoped tables, PUBLIC routes still filter by organization. Since the user isn't authenticated, the organization ID must be passed as a query parameter:

GET /api/v1/listings?organizationId=org_abc123

The firewall WHERE clause is always enforced. If no organizationId is provided, the API returns a 403 with code ORG_REQUIRED.

?organizationId= is an account/public addressing surface. A request using a configured delegated principal cannot use it to acquire account organization authority: the generated route returns 403 CROSS_TENANT_FORBIDDEN and never adds activeOrgId or account roles to the delegated AppContext. Delegated scope continues to come only from the verified principal claims and database RLS policy.

// quickback/features/listings/listings.ts
import { q, defineTable } from '@quickback/compiler';

// Public listing page — anyone can browse, but scoped to an org
export const listings = q.table('listings', {
  id:             q.id(),
  title:          q.text().required(),
  organizationId: q.scope('organization'),
  ...q.audit(),
  ...q.softDelete(),
});

export default defineTable(listings, {
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  read: { access: { roles: ["PUBLIC"] } },              // Public browsing + detail
  create: { access: { roles: ["admin", "member"] } }, // Auth required to create
});

AUTHENTICATED — any authenticated caller (the wrapper)

Use roles: ["AUTHENTICATED"] for endpoints that any logged-in caller can hit, regardless of role or org membership. No plugin context required.

// Public events feed — any signed-in user can list/read; anonymous gets 401
read: { access: { roles: ["AUTHENTICATED"] } }

AUTHENTICATED is the wrapper over two auth flavors — it admits both a full Better Auth session and a sessionless scope principal (a scope-token holder, e.g. an event attendee who isn't an org member; see Scopes). It excludes only external delegated principals (ctx.principal, admitted via principals:). When you need to distinguish the two flavors, use SESSION or SCOPED below; when either is fine, use AUTHENTICATED.

SESSION — a full session only

Use roles: ["SESSION"] when a route must admit only a full Better Auth session and not a sessionless scope principal. This is AUTHENTICATED minus scope-token callers.

// Account settings — a real logged-in user, never a scope-token attendee
read: { access: { roles: ["SESSION"] } }

SCOPED — a scope principal only

Use roles: ["SCOPED"] for a route that is specifically for sessionless scope-token callers (a principal-only surface — e.g. a shared event link or kiosk token with no account). It admits any scope principal regardless of which scope; for a specific scoped role, use the scope:<kind>:<role> form instead (e.g. scope:event:attendee — see Scopes).

// Principal-only event surface — a scope token, no account required
read: { access: { roles: ["SCOPED"] } }

Firewall coherence: if an access surface admits a scope caller (SCOPED or a scope:<kind>:<role> role) but the resource firewall can't filter that caller, the compiler fails the build — the caller would reach the handler yet see zero rows (a silent 200 + []). Give the firewall a way to filter them: a ctx.scope.<kind> arm inside an { any: [...] } group (compose org or scope — see Firewall), a via: relationship, or firewall: [{ exception: true }].

USER — per-user records

Use roles: ["USER"] for resources where each user only sees their own records. Requires the firewall to scope by owner — the role marker is the access half of the pair, the firewall is the row-filter half.

The owner column is ownerId. Name the column ownerId and the firewall is auto-derived (WHERE ownerId = ctx.userId, plus auto-stamping on insert); userId is not auto-detected on feature tables and must be declared explicitly. See Firewall → ownerId vs userId.

// quickback/features/todos/todos.ts
import { q, defineTable } from '@quickback/compiler';

// "My todos" — each caller sees only their own rows
export const todos = q.table('todos', {
  id:      q.id(),
  title:   q.text().required(),
  done:    q.bool().default(false).required(),
  ownerId: q.text().required(),
  ...q.audit(),
  ...q.softDelete(),
});

export default defineTable(todos, {
  firewall: [{ field: 'ownerId', equals: 'ctx.userId' }],
  read: { access: { roles: ["USER"] } },
  create: { access: { roles: ["USER"] } },
  update: { access: { roles: ["USER"] } },
  delete: { access: { roles: ["USER"] } },
});

If the firewall doesn't scope by owner, the compiler throws:

Resource "todos" uses roles: ["USER"] but the firewall does not scope by
ownerId. Add an ownerId column (auto-scoped) or an explicit owner predicate
(firewall: [{ field: "ownerId", equals: "ctx.userId" }, ...]). If the column
is named userId, declare it explicitly:
firewall: [{ field: "userId", equals: "ctx.userId" }]. If you want any
signed-in user to read all records, use roles: ["AUTHENTICATED"] instead.

When the Better Auth admin plugin is enabled, USER matches user.role === "user" specifically — appmanagers and sysadmins are excluded. If the same route should serve both, express it explicitly with or: [{ roles: ["USER"] }, { userRole: ["appmanager"] }].

ADMIN — legacy reserved marker (compile-time error)

roles: ["ADMIN"] is no longer supported. The compiler fails hard on any use of ADMIN so platform and tenant roles can't be confused.

// Platform control-plane audit log
read: { access: { userRole: ["appmanager"] } }

Use one of these instead:

  • userRole: ["appmanager"] for platform control-plane access
  • roles: ["admin"] for org-membership admin access
  • roles: ["SYSADMIN"] for true cross-tenant access

SYSADMIN — cross-tenant DB-admin tier

Use roles: ["SYSADMIN"] for endpoints that should only be reachable by platform sysadmins — the cross-tenant DB-admin tier introduced in v0.15.0. Compiles to ctx.userRole === "sysadmin". Requires cms: { sysadmin: true } on the project config; without that flag, using SYSADMIN fails the compile so the role can't sit as dead code.

// Cross-tenant audit dashboard, sysadmin-only
read: { access: { roles: ["SYSADMIN"] } }

SYSADMIN is distinct from userRole: ["appmanager"]. Appmanager is the platform control-plane tier (CMS shell, Better Auth admin surfaces, support tooling); sysadmin is the cross-tenant data-plane tier. The one place sysadmin gets implicit extra powers is the firewall escape hatch: when cms.sysadmin: true, every buildFirewallConditions emits an if (ctx.userRole === 'sysadmin') return undefined; (or just the soft-delete predicate when present) at the top — so sysadmins see every tenant's rows, not just their own org's. That escape hatch does NOT fire for userRole === 'appmanager'; appmanagers still go through the firewall like everyone else.

This separation exists so Better Auth admin-plugin powers (user management, impersonation, org creation) and DB-admin powers (cross-tenant data inspection) stay decoupled. A user can have user.role === 'appmanager' without DB-level cross-tenant access; a user can have user.role === 'sysadmin' without org-membership context. See CMS Configuration for the cms.sysadmin flag, the cross-tenant /auth/v1/admin/list-organizations endpoint, and the SPA's "All organizations" sentinel.

Mixing pseudo-roles with real roles

Pseudo-roles and lowercase real roles compose with OR:

// Record owner OR a platform control-plane user can read
read: { access: { or: [{ roles: ["USER"] }, { userRole: ["appmanager"] }] } }

// Org owner OR sysadmin
delete: { access: { or: [{ roles: ["owner"] }, { roles: ["SYSADMIN"] }] } }

Note: userRole: ["appmanager"] never bypasses a userId firewall scope. If you need cross-user visibility, expose a separate route with firewall: { exception: true } or a SYSADMIN-gated cross-tenant surface.

Quick reference

// Unauthenticated public
access: { roles: ["PUBLIC"] }

// Any signed-in user, no scoping
access: { roles: ["AUTHENTICATED"] }

// Per-user records (requires userId firewall scope)
access: { roles: ["USER"] }

// Platform control-plane
access: { userRole: ["appmanager"] }

// Cross-tenant DB-admin tier
access: { roles: ["SYSADMIN"] }

// Org-membership roles with hierarchy expansion
// ("+" requires auth.roleHierarchy in quickback.config.ts — see Role Hierarchy above)
access: { roles: ["member+"] }     // expands to ["member", "admin", "owner"]
access: { roles: ["admin+"] }      // expands to ["admin", "owner"]
access: { roles: ["admin"] }       // exact match (no expansion)
// Field conditions - value can be string | number | boolean
type FieldCondition =
  | { equals: value | '$ctx.userId' | '$ctx.activeOrgId' }
  | { notEquals: value }
  | { in: value[] }
  | { notIn: value[] }
  | { lessThan: number }
  | { greaterThan: number }
  | { lessThanOrEqual: number }
  | { greaterThanOrEqual: number };

Centrally defined access rules

Instead of repeating the same roles: [...] expression across every feature, define it once in authz.roles and reference it by name everywhere. This is the recommended way to manage access as your app grows: one source of truth per rule, changed in one place.

Named roles (and named gates, tenant anchors, scope kinds) can be declared at two levels: at root authz in quickback.config.ts (visible everywhere), or inside a feature area's _area.ts (visible to that area's subtree only — a feature outside the area referencing the name is a compile error, and root authz bodies may reference root-declared names only). Declare each name in exactly one place: re-declaring an inherited name is a compile error.

// quickback.config.ts — define ONCE
export default defineConfig({
  // ...providers...
  authz: {
    roles: {
      // Compose from BA roles, pseudo-roles, and other named rules:
      staff:     { roles: ['admin', 'member'] },
      // Boolean combinators:
      eventStaff: { or: [{ roles: ['staff'] }, { via: 'organizerOf' }] },
      // Relationship-backed (see below):
      attendee:   { via: 'attendeeOf' },
    },
  },
});
// any feature — REFERENCE by bare name
import { q, defineTable } from '@quickback/compiler';

export const agendaItems = q.table('agendaItems', {
  id:             q.id(),
  title:          q.text().required(),
  startsAt:       q.text().required(),
  organizationId: q.scope('organization'),
  ...q.audit(),
  ...q.softDelete(),
});

export default defineTable(agendaItems, {
  read:   { access: { roles: ['eventStaff'] } },
  update: { access: { roles: ['eventStaff'] } },
});

// features/events/actions/checkIn.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Check a guest in to the event and record the arrival time.",
  input: z.object({ guestId: z.string() }),
  access: { roles: ['eventStaff', 'attendee'] },
  async execute({ db, input, ctx }) { /* ... */ },
});

Rule shapes

Each entry in authz.roles is one of four shapes:

ShapeMeaning
{ roles: ['admin', 'member'] }Composition — matches any of the listed roles (BA, pseudo, or other named rules)
{ or: [ruleA, ruleB] }Matches if any sub-rule matches
{ and: [ruleA, ruleB] }Matches only if all sub-rules match
{ via: 'relationshipName' }Caller proves a declared relationship — see Relationship-based authorization below

Rules compose freely — a rule may reference another rule by name (cycles are a compile error).

Where references resolve

A named rule works in the roles: [...] array of every access surface. The compiler expands it at build time:

Surface
crud.*.access
read.access / read.views.*.access
masking.<field>.show✓ for role/composite rules; relationship (via) rules refused at compile (v1)
actions.<name>.access
realtime.requiredRoles✓ — relationship arms fail closed at the WS handshake and are enforced by the row-stream firewall

Names that aren't declared in authz.roles — Better Auth roles, pseudo-roles like AUTHENTICATED — pass through untouched, so you can mix named rules and raw roles in the same array.

Beyond roles: named permissions

For access that traverses the data graph (FK hops, SpiceDB-style permission composition), see authz.permissions + authz.arrows — the same define-once-reference-by-name model, one level richer than authz.roles.

Named gates (authz.rules)

authz.roles centralizes the who. A gate (authz.rules.<name>) bundles a full access rule — who + an optional row predicate (record:) + optional firewall lanes — under ONE name, declared once:

// quickback.config.ts
authz: {
  tenants: { org: { column: 'organizationId', equals: 'ctx.activeOrgId' } },
  rules: {
    // who + record: "org staff, but only rows still active"
    activeStaff: {
      who: { roles: ['member', 'admin', 'owner'] },
      record: { status: { equals: 'active' } },
    },
    // who + firewall: an org-OR-event-scope row gate, declared ONCE
    eventRow: {
      who: { roles: ['owner', 'admin', 'member'] },
      firewall: [{ any: [
        { tenant: 'org' },
        { field: 'eventId', equals: 'ctx.scope.event' },
      ] }],
    },
  },
}
// any feature — the gate name works like a role name
export default defineTable(eventLinks, {
  firewall: [{ rule: 'eventRow' }],               // firewall stamp
  read: { access: { roles: ['eventRow'] } },      // access stamp
});

The two-stamp contract

One shared name, two independent stamps — a single reference never applies both:

  • An access-position reference (the bare name in any roles: [...] array) applies the gate's who + record arms only. It expands exactly like an inline rule: a who-only gate splices as the flat role list; a who+record gate as { and: [who, { record }] }.
  • A firewall reference (firewall: [{ rule: '<name>' }]) applies the gate's firewall lanes only, spliced into the resource's top-level AND array. The who of a gate referenced only from firewall: is not consulted — that is the documented contract, not a silent drop. Firewall-who is legitimately wider than access-who (e.g. the firewall admits scope-token holders to rows while read access stays staff-only).

Both directions are enforced fail-closed at compile time:

You wroteCompiler says
Access reference to a firewall-bearing gate, but the resource's firewall: doesn't reference itError — the row lanes would silently not apply
firewall: [{ rule }] naming a gate with no firewall armError — a who/record-only gate would splice zero lanes
A firewall-bearing gate referenced from an action's access or from masking.showError, unconditional — no firewall array exists at those sites (nested show.or / show.and arms included)
A who-less (firewall-only) gate referenced from an access positionError — row lanes can't masquerade as a who-grant
A gate's record: field is not a column of the referencing tableError, per reference — a missing field evaluates against undefined at runtime (notEquals/notIn would silently grant)
A spliced gate's firewall lane filters on a column the stamped table doesn't haveError, per reference — the lane could never scope rows there

Naming rules

Rule names share the reference namespace with roles, so the compiler rejects (at declaration time) a rule name that collides with an authz.roles entry, a reserved/provider role, any scoped-role name (bare sugar like attendee or the canonical scope:<kind>:<role> spelling), or a name containing + or : (the scope: prefix always means a token-verified scope principal — a colon-named rule could capture references meant for one).

Inside authz.roles / gate who bodies, + hierarchy sugar is banned — write the expanded list (['member', 'admin', 'owner']), because + expansion runs at resource sites only and a member+ inside a body would silently never match. Roles may not reference rules (the dependency arrow is one-way), and gates never nest.

Typo safety

A roles: [...] name that matches nothing declared is fail-closed at runtime (it never grants) but historically silent. The compiler now emits a warning-level AUTHZ_UNKNOWN_ROLE_NAME diagnostic for any name that is not a provider/hierarchy role, pseudo-role, scoped role, or declared role/rule — a typo'd gate name is visible at compile time without breaking projects that use dynamic auth-provider role names. The heuristic runs only for projects that use the authz naming system (any of authz.roles, authz.rules, or authz.scopes declared); projects outside it keep the pre-existing silent-passthrough behavior for dynamic role names.

Relationship-based authorization

Beyond Better Auth roles (org membership, user-table role) and pseudo-roles, Quickback supports relationship-based authorization roles — names that bind to a declared relationship between the caller and a resource. Use these when access should follow a domain-table membership ("user is a confirmed guest of this event") rather than an org/admin gate. These are the { via: '...' } rule shape from Centrally defined access rules above.

authz.relationships

Declare relationships once in quickback.config.ts:

// quickback.config.ts
import { defineConfig } from '@quickback/compiler';

export default defineConfig({
  // ...providers...
  authz: {
    relationships: {
      guestOf: {
        from: 'event_guests',                                 // table name string
        subject: { column: 'userId', equals: 'ctx.userId' },  // caller side
        resource: { column: 'eventId' },                      // resource side
        where: { status: 'confirmed' },                       // optional filter
      },
    },
    roles: {
      guest: { via: 'guestOf' },
      // Composite: any guest OR an org owner/admin
      eventStaff: {
        or: [
          { via: 'guestOf' },
          { roles: ['admin', 'owner'] },
        ],
      },
    },
  },
});

Using relationship-roles in resources

Resource definitions reference declared roles by name in roles: [...] arrays exactly like BA roles or pseudo-roles. The compiler rewrites the access tree at build time so the runtime route does an inline relationship lookup against the declared table.

Supported relationship-role surfaces

SurfaceStatusNotes
firewall: via: predicate✓ SupportedSubquery composes as outermost AND
crud.*.access roles: ['guest']✓ SupportedInline async lookup per request
read.access / read.views.*.access roles: ['guest']✓ SupportedSame inline path
Standalone actions.<name>.access roles: ['guest']✓ SupportedInline gate; the surface exposes resource.column as a path param or input field
Record-based actions.<name>.access roles: ['guest']✓ SupportedInline lookup uses the already-loaded, firewalled record
Namespace via lanes✓ SupportedHoisted middleware resolves once and can hydrate ctx.<exposeAs>
masking.<field>.show roles: ['guest']Build-time refusalUse a relationship-gated view for field projection; per-row async masking is not approximated

Relationship roles are therefore available across CRUD, reads, standalone actions, record-based actions, and namespace mounts. Masking is the one different execution surface: list masking must stay synchronous and batched, so the compiler rejects a relationship-bearing masking.show rule and points to a relationship-gated view instead. It never drops the relationship arm or turns it into a broader role grant.

Standalone actions: surface keys and loads / exposeAs (v0.19+)

When a standalone action's access.roles references a relationship-role, the compiler emits a runtime gate between input validation and execute(). The gate reads the relationship's resource.column value from the action surface — either a path parameter (:eventId) or an input field (input.eventId). If neither is present, the current compiler stops with a clear build error; an action can never ship with a relationship rule it has no key to evaluate.

Pair the relationship with loads + exposeAs to hydrate the loaded resource onto ctx:

authz: {
  relationships: {
    attendeeOf: {
      from: 'guests',
      subject: { column: 'userId', equals: 'ctx.userId' },
      resource: { column: 'eventId' },
      where: { status: 'confirmed' },
      loads: 'events',           // ← v0.19+: load matching event row
      exposeAs: 'event',         //   ← onto ctx.event for the action body
    },
  },
  roles: {
    attendee: { via: 'attendeeOf' },
  },
}

The resolver (v0.20):

  1. Cheap in-memory role check first — ctx.roles?.includes('admin') and friends. If the caller has a qualifying role, this short-circuits and no DB queries fire for the access gate.
  2. Firewall-gated relationship match if the role check failed — SELECT { organizationId, teamId } FROM guests WHERE userId=ctx.userId AND eventId=input.eventId AND status='confirmed' with the guests table's full firewall AND-applied. If neither role nor relationship qualifies → 403.
  3. Tenant stamping from the matched row — for any tenant column the from table carries (organizationId, teamId), the resolver stamps ctx.activeOrgId ??= matchedRow.organizationId (and similar for team). This is in-memory mutation of the request-scoped ctx only — no JWT remint, no session-record write, no persistent grant.
  4. Firewall-gated load of the loaded resourceSELECT * FROM events WHERE id = :eventId AND buildEventsFirewallConditions(ctx, db). The events firewall is applied as normal; it succeeds for attendees because tenant was just stamped from the relationship row.
  5. Assigns the loaded row to ctx.event for the action body.

No firewall bypass anywhere. Both queries run with their respective tables' full firewalls. The relationship row's existence is the proof that the caller is authorized; its tenant columns are the source of the stamped claim.

Conditional tenant preconditions (v0.20+)

Standalone actions in features with org/team-scoped tables previously emitted unconditional preconditions:

if (!ctx.activeOrgId) return c.json(AccessErrors.noOrg(), 403);
if (!ctx.activeTeamId) return c.json(AccessErrors.noTeam(), 403);

These ran before the relationship resolver, which 403'd attendees whose sessions don't carry an active tenant claim (the canonical event-attendee shape).

v0.20 makes these preconditions conditional on relationship supply. If the access tree contains a relationship arm whose from table carries the matching tenant column, the precondition is skipped — the resolver runs first and stamps the value. Pseudo-role short-circuits (PUBLIC / AUTHENTICATED / USER / SYSADMIN) and firewall: { exception: true } still bypass the preconditions as before. ADMIN is now rejected at compile time.

Typical event-attendee setup (guests table carries organizationId): the org precondition is dropped on every action whose access references attendee or eventStaff. Org admins still get ctx.activeOrgId from their session as today; attendees get it from the relationship row, mid-request. Both paths converge on a valid tenant claim before execute() runs.

Requirement on the relationship's from table

For the v0.20 tenant-stamping to work, the relationship's from table must carry tenant columns:

  • For org stamping: an organizationId column (or whatever spelling the project's auth-provider defaults pick).
  • For team stamping: a teamId column.

When the from table has neither, the compiler emits a [quickback:authz] warning at compile time:

Relationship "X" at authz.relationships.X has no organization or team columns
on its `from` table ("guests"). Action "Y" will compile, but downstream
scoped-db queries inside execute() may 403 because ctx.activeOrgId /
activeTeamId cannot be stamped from the match row. Either add the tenant
column to "guests", or set firewall: { exception: true } on the action.

The relationship match still works (existence check), but no tenant gets stamped — so the conventional precondition still fires and the action still 403s for callers without a session-resident tenant claim. Add the tenant column (or denormalize it onto the relationship table during writes) to enable the stamping path.

The body can then trust ctx.event and drop the hand-rolled SELECT … FROM guests WHERE … boilerplate:

// features/chat/actions/getInbox.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "List the caller's chat inbox for one event they attend.",
  path: '/chat/get-inbox',
  input: z.object({ eventId: z.number().int().positive() }),
  access: { roles: ['admin', 'member', 'attendee'] }, // attendee is a relationship-role
  execute: async ({ db, ctx, input }) => {
    // No hand-rolled attendance check needed — the resolver already ran.
    // ctx.event is the loaded events row. Typed as `any` here because the
    // hand-rolled `roles: ['attendee']` action gates per-action, not per-
    // namespace — see "Narrow ctx.<exposeAs> typing" below for the typed
    // import flip available when the same hydration runs through a
    // namespace mount.
    const event = ctx.event as typeof events.$inferSelect;
    // … domain logic only.
  },
});

loads and exposeAs are co-required (set both or neither). exposeAs must be a valid JS identifier and must not collide with built-in ctx fields (userId, activeOrgId, activeTeamId, db, etc.).

Action firewall escape hatch (v0.19+)

defineAction({ firewall: { exception: true } }) opts the action out of the inherited session-key preconditions (ctx.activeOrgId / ctx.activeTeamId requirements) AND the relationship resolver. The handler receives unsafeDb for raw Drizzle access. Use for cross-tenant or admin routes whose surface intentionally doesn't name a session/relationship key:

// features/admin/actions/crossTenantReport.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Report activity across every tenant for a date range (sysadmin only).",
  path: '/admin/cross-tenant-report',
  input: z.object({ since: z.string(), until: z.string() }),
  firewall: { exception: true },
  unsafe: { reason: 'cross-tenant audit', adminOnly: true, targetScope: 'all' },
  access: { roles: ['SYSADMIN'] },
  execute: async ({ unsafeDb, ctx }) => { /* cross-org query */ },
});

firewall: { exception: true } is the declarative twin of unsafe: true — they're conceptually distinct: firewall.exception opts out of the gate-emission pillars; unsafe signals audit/cross-tenant intent (and still applies for logging). Most cross-tenant actions set both.

Most of the time, relationship-based row visibility is what you want, and putting it at the firewall layer is the cleanest expression. The firewall is row-filter; the access layer is route/role-filter; let each do its job:

// features/sessions/sessions.ts
firewall: [
  { field: 'organizationId', equals: 'ctx.activeOrgId' },
  { field: 'eventId', via: 'guestOf' },          // ← row visibility
],
read: {
  access: { roles: ['admin', 'member'] },          // ← who can hit the route
  views: {
    cms:   { access: { roles: ['admin', 'member'] }, fields: [...] },
    guest: { access: { roles: ['member', 'admin'] }, fields: [...] },
  },
},

A guest who's in your org as member can hit the endpoint and the firewall filters them down to events they're guests of. Cross-tenant access is impossible because the org filter is the outermost AND.

Cross-tenant "my events" dashboards are NOT this

A common adjacent pattern — "logged-in user wants GET /my-events returning events they're a guest of across all tenants" — is not solved by relationship-roles. The tenant firewall is enforced at the outermost AND, so a guest of an event in another tenant remains correctly blocked.

For cross-tenant aggregation, use one of:

  • User-scoped firewall: firewall: { owner: { column: 'userId' } } — a flat per-user table.
  • A standalone action that walks the relationship table itself — see worked example below.

Don't reach for relationship-roles first and then get confused when the tenant firewall blocks. The two patterns serve different problems.

Worked example: GET /my-events standalone action

The cleanest expression of the cross-tenant attendee dashboard is a standalone action with a custom path that does its own join against the relationship table. The action is gated by roles: ['AUTHENTICATED'] (any signed-in user can hit it; per-row scoping is the query itself), and the SQL filters by ctx.userId so each caller sees exactly the events they're a guest of:

// features/dashboard/actions/myEvents.ts
import { z } from 'zod';
import { eq, and, inArray } from 'drizzle-orm';
import { defineAction } from '../.quickback/define-action';
import { events } from '../../events/events';
import { guests } from '../../guests/guests';

export default defineAction({
  description: "List events the caller is a confirmed guest of, across all tenants.",
  path: '/my-events',
  method: 'GET',
  input: z.object({}),
  // Any signed-in user. Row visibility comes from the query, not the role.
  access: { roles: ['AUTHENTICATED'] },
  async execute({ db, ctx }) {
    // 1. Find every event the caller has a confirmed guest row for.
    const guestRows = await db
      .select({ eventId: guests.eventId })
      .from(guests)
      .where(
        and(
          eq(guests.userId, ctx.userId!),
          eq(guests.status, 'confirmed'),
          // guests.organizationId is NOT filtered here — that's the whole
          // point of the cross-tenant lane. The relationship row's org is
          // ambient information that this action explicitly traverses.
        )
      );

    if (guestRows.length === 0) return { events: [] };

    // 2. Pull the matching events. NOTE: this query also bypasses
    //    `events.organizationId` scope — by design — so explicitly filter
    //    out soft-deleted rows yourself if your `events` table uses them.
    const eventIds = guestRows.map((r) => r.eventId);
    const rows = await db
      .select()
      .from(events)
      .where(inArray(events.id, eventIds));

    return { events: rows };
  },
});

Why this is a standalone action and not a CRUD route:

  • Tenant firewall would block it. The default firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }] on events is correct for every other read — only the cross-tenant dashboard wants to step outside it, and that's a deliberate, explicit step taken in handler code.
  • The join is the access control. A relationship-role at the firewall layer can't express "filter by this user's relationships, ignoring the org context." The handler does that join directly.
  • Audit-logged automatically. Standalone actions are mandatorily audit-logged in the runtime — the cross-tenant traversal is recorded with ctx.userId, IP, and timing.

Two important guardrails when you write one of these:

  1. Always filter by ctx.userId in the relationship lookup. If you accept a userId from the request body and trust it, you've built an IDOR. The example above uses ctx.userId! directly so the only events returned are the caller's.
  2. You take responsibility for soft-delete and other invariants the firewall would otherwise apply. Add the deletedAt filter, status checks, and any other row-level predicates by hand. The action is OUTSIDE the firewall pipeline by design — the cost is that you can't lean on the auto-applied predicates.

If you find yourself writing more than one of these per project, that's usually a signal to revisit the schema (could a user-owned userEvents materialized view replace the join?) rather than a sign Quickback should ship a higher-level abstraction. Cross-tenant lanes are intentionally explicit.

Compile-time refusals (consolidated)

The validator and resolver reject:

  • Unknown key in authz block (typo guard — realtionships would otherwise silently produce a permanently-denied role).
  • Reserved role-name (auth-provider-derived). Names like guest, speaker, attendee are free; admin/owner/member are reserved when the BA org plugin is on.
  • Cycle in role composition (a → b → a).
  • Relationship from references unknown table.
  • Relationship-role used on a resource missing the resource.column.
  • Relationship table with firewall: { exception: true } (would let cross-tenant grants slip through).
  • lowering: 'session' (reserved for v2 — staleness/invalidation ergonomics not yet shipped).
  • + hierarchy suffix on a relationship-role name (terminal, like pseudo-roles).
  • Relationship-backed rules in masking.show (use a relationship-gated view for field projection; action access is fully supported).

Namespace mounts (hoisted relationship middleware)

When several standalone actions share a path prefix and the same relationship gate — /event/:eventId/announce, /event/:eventId/cancel, /event/:eventId/respond — declaring the gate per-action means writing the same roles: ['attendee'] over and over AND paying the relationship-resolver cost N times per request (one subquery + one row-load, multiplied by the number of actions).

Namespace mounts hoist the resolver into one Hono middleware that runs once per request. Every action under the prefix skips the inline gate; ctx.<exposeAs> is hydrated by the middleware before the action's execute() runs. With the per-namespace helper (v0.27+, below), that field is also statically typed as the loaded row.

When to use roles vs. relationships

A user is authorized for a row when any of the following is true:

  • they have a per-row relationship to it (e.g. an attendee row, a named organizer row), or
  • they have a role on the row's tenant (org admin/owner of the row's organizationId), or
  • they have team membership matching the row's teamId.

Use relationships when membership is specific to this row — external collaborators who aren't in your org, attendees, anyone connected to the resource by virtue of data rather than payroll. Use roles / team when staff at your org/team should see everything in their scope by default.

The two compose at the action level (access.or accepts both relationship-roles and tenant roles) and — as of v0.25 — at the namespace level too: namespace.via accepts an array of lanes, first match wins.

Rule of thumb. If the answer is "yes if you work here," reach for org/team roles. If the answer is "yes if you have something to do with this row," reach for a per-row relationship. Reach for both together — at the namespace or via access.or — when an action should let either side in.

Two declaration sites depending on whether the participating actions live in one feature or many:

Single feature: defineTable({ namespace }) (v0.23+)

Use when every action under the prefix lives in the same feature directory. The namespace travels with the table that owns it — no addition to quickback.config.ts needed.

// features/events/events.ts
import { q, defineTable } from '@quickback/compiler';

export const events = q.table('events', {
  id:             q.id(),
  name:           q.text().required(),
  organizationId: q.scope('organization'),
  ...q.audit(),
  ...q.softDelete(),
});

export default defineTable(events, {
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  namespace: {
    prefix: '/event/:eventId',   // absolute URL path
    via: 'attendeeOf',            // relationship gate (must exist in authz.relationships)
  },
});

// features/events/actions/postAnnouncement.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Post an announcement to everyone attending the event.",
  path: '/event/:eventId/announcements',
  method: 'POST',
  input: z.object({ body: z.string().max(2000) }),
  // No `via:` gate boilerplate — the namespace already ran the resolver, so
  // `access` only names roles and ctx.event is typed inside execute().
  access: { roles: ['admin', 'member'] },
  async execute({ db, input, ctx }) { /* … */ },
});

The compiler emits one app.use(...) on the events feature's Hono app. Every standalone action in features/events/actions/ whose path starts with /event/:eventId/ joins the namespace automatically.

Multi-lane via (v0.25+) and bulk-grant lanes (v0.26+) work identically on the table-level declaration — via: accepts the same shapes documented below for the project-level form.

Cross-feature: authz.namespaces (v0.24+)

Use when the namespace spans multiple features. The declaration sits next to your other authz config in quickback.config.ts.

// quickback.config.ts
authz: {
  relationships: {
    attendeeOf: {
      from: 'guests',
      subject: { column: 'userId', equals: 'ctx.userId' },
      resource: { column: 'eventId' },
      loads: 'events',
      exposeAs: 'event',
    },
  },
  namespaces: {
    eventScope: { prefix: '/event/:eventId', via: 'attendeeOf' },
  },
}

With the namespace declared, any standalone action whose path: matches the prefix joins the gate — regardless of which feature owns it:

// features/feeds/actions/feedMoments.ts  (in one feature)
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Publish a moment to the event's live feed.",
  path: '/event/:eventId/feed-moments',
  method: 'POST',
  input: z.object({ caption: z.string().max(280), photoId: z.string().optional() }),
  access: { roles: ['admin', 'member'] },
  async execute({ db, input, ctx }) { /* … */ },
});

// features/rsvp/actions/respond.ts  (in a different feature)
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Record the caller's RSVP for the event.",
  path: '/event/:eventId/respond',
  method: 'POST',
  input: z.object({ response: z.enum(['yes', 'no', 'maybe']) }),
  access: { roles: ['admin', 'member'] },
  async execute({ db, input, ctx }) { /* … */ },
});

The compiler emits one synthetic src/routes/__namespace-event-scope.routes.ts that imports each contributing feature's actions map under a distinct alias, mounts the resolver middleware once, and dispatches to the matching action. Worker-root mount order ensures the middleware fires before any per-feature route handler under the prefix.

Multi-lane via (v0.25+) — first match wins

When more than one relationship can authorize a caller for the namespaced resource, pass via: as an array. The middleware tries each lane in declaration order and stops at the first match — ctx.<exposeAs> is hydrated from whichever lane fired.

authz: {
  relationships: {
    attendeeOf: {
      from: 'guests',
      subject: { column: 'linkedUserId', equals: 'ctx.userId' },
      resource: { column: 'eventId' },
      where: { status: 'confirmed' },
      loads: 'events',
      exposeAs: 'event',
    },
    // Named per-event organizers (potentially EXTERNAL — these users may
    // not be a member of the event's org. That's the point: lets you grant
    // event-level access to freelancers / partner staff without adding
    // them to the org's member list.)
    organizerOf: {
      from: 'eventOrganizers',
      subject: { column: 'linkedUserId', equals: 'ctx.userId' },
      resource: { column: 'eventId' },
      loads: 'events',
      exposeAs: 'event',
    },
  },
  namespaces: {
    eventScope: {
      prefix: '/event/:eventId',
      via: ['attendeeOf', 'organizerOf'],
    },
  },
}

All relationship lanes in a namespace must agree on loads: + exposeAs: — that's what guarantees ctx.event has the same shape regardless of which lane matched. The compiler rejects heterogeneous lanes at parse time.

Bulk-grant lanes (v0.26+)

Relationship lanes cover "specifically connected to this row." Bulk-grant lanes cover "anyone with this role / team membership in the row's tenant" — the case where an org admin or a team member should see every event in their scope without writing per-event relationship rows.

namespaces: {
  eventScope: {
    prefix: '/event/:eventId',
    via: [
      'attendeeOf',
      'organizerOf',
      { roles: ['admin', 'owner'] },   // org-role bulk grant
      { team: true },                  // active-team-membership bulk grant
    ],
  },
}

Each bulk-grant lane:

  • { roles: [...] } — passes when the caller's BA org-role for the loaded resource's tenant includes any listed role. Short-circuits the relationship loop entirely; the role check is in-memory and cheap. Use for the "company admin sees everything" case.
  • { team: true } — passes when ctx.activeTeamId is set. The loads-table firewall does the actual events.teamId === ctx.activeTeamId check during hydration. Use when the resource is team-scoped and you want active-team membership to grant access.

Both bulk-grant lanes piggyback on the namespace's relationship lane(s) for hydration — they don't fetch a separate per-row subject; they just gate access and let the loads-table firewall handle the tenant scoping. At least one relationship lane is required alongside bulk-grant lanes in v0.26 (to anchor loads: + exposeAs:).

When a request misses every lane, the 403 deny payload lists every authorization path the caller could have taken — relationship names, role names, and "team-membership" for { team: true } lanes — so a developer debugging an unexpected 403 sees the full surface they need to satisfy.

A namespace can also fold the scope machinery into this same gate: mintScope: true mints a scope token on a matched userId lane, and acceptScope: '<kind>' accepts an already-minted scope claim as proof of entry — one handler under the prefix serves both a Better-Auth user and a sessionless scope principal. See Scopes → acceptScope.

Discovery is path-prefix-only

Actions opt in by matching path: against a declared namespace prefix. There's no namespace: 'foo' field on defineAction — the path string is the discoverable surface, consistent with how path: already determines an action's URL.

Requirements (enforced at compile time)

Both forms share the same shape checks:

  • via: accepts one of three shapes. A single relationship name (v0.23+), an array of relationship names (v0.25+, first match wins), or — as of v0.26 — an array mixing relationship names with bulk-grant guards ({ roles: [...] }, { team: true }).
  • Every relationship lane must declare loads + exposeAs. Without loads there's nothing for the middleware to hydrate; the namespace gate would be indistinguishable from a per-action access rule.
  • All relationship lanes in one namespace must agree on loads: + exposeAs:. Otherwise ctx.<exposeAs> would change shape depending on which lane matched.
  • Bulk-grant lanes inherit loads: + exposeAs: from sibling relationship lanes. Pure bulk-grant namespaces (no relationship lane at all) are also accepted — set loads: + exposeAs: directly on the namespace declaration so hydration has somewhere to anchor.
  • prefix: must contain at least one :param segment (e.g. /event/:eventId). The middleware reads the relationship key from c.req.param() because no request body has been parsed at middleware time.
  • One declaration per namespace name. The same name in both defineTable({ namespace }) and authz.namespaces is rejected. The project-level form is canonical for cross-feature; the table-level form is the single-feature shorthand.

Project-level only:

  • Ambiguous prefixes like /a/:x and /a/:y (same shape, different :param names) are rejected. Strict super/sub-set relationships (/event/:e vs /event/:e/admin) are fine — the harvester routes each action to the longest matching prefix.
  • Unused namespace emits a [quickback:authz] warning when no action's path matches the declared prefix. Not a hard error — the namespace may be staged ahead of the actions.

What namespace actions get for free

Inside a namespace action's execute():

  • ctx.<exposeAs> is populated by the middleware with the loaded resource row. Statically any by default — narrowed to typeof <loadsTable>.$inferSelect when the action's import points at the per-namespace helper (see below).
  • Tenant context (ctx.activeOrgId / ctx.activeTeamId) is stamped from the matched relationship row when its from table carries those columns. Downstream scoped queries work without further setup.
  • The action's own access: block (additional roles, userRole, record predicates, OTHER relationship arms) still runs after the namespace gate — namespace handles entry, per-action gates further.

Narrow ctx.<exposeAs> typing (v0.27+)

The namespace middleware hydrates ctx.<exposeAs> at runtime — that's been true since v0.23. The static type of that field, though, is any via the AppContext index signature: useful for not blocking access, but a missed opportunity for autocomplete and typo-catching.

v0.27 emits one per-namespace defineAction helper alongside the standard <feature>/.quickback/define-action.ts. Importing from the narrowed helper retypes ctx.<exposeAs> inside execute({ ctx }) to the loaded row's $inferSelect shape. Nothing else changes — runtime behavior, action discovery, and the path: namespace match are identical.

features/feeds/actions/feedMoments.ts
// One-line flip per namespace-scoped action:
import { z } from 'zod';
import { defineAction } from '../.quickback/define-action.event-scope';
//                                          └──────┬──────┘
//                                       kebab(namespaceName)

export default defineAction({
  description: "Publish a moment to the event's live feed.",
  path: '/event/:eventId/feed-moments',
  method: 'POST',
  input: z.object({ body: z.string() }),
  access: { roles: ['attendee'] },
  async execute({ ctx, input }) {
    ctx.event.id            // ✓ string
    ctx.event.startsAt      // ✓ Date — autocomplete works
    ctx.event.organizerId   // ✓ narrows on the field's declared type

    ctx.event.idd           // ✗ TS2339 — typo caught at compile time
  },
});

The compiler emits one helper per (feature, namespace) pair where the feature owns at least one action whose path: falls under the namespace prefix. Filename is define-action.<kebab(namespaceName)>.tseventScope becomes define-action.event-scope.ts. Helpers live next to the standard define-action.ts in <feature>/.quickback/, so the relative import is the same shape as every other action file in your project.

Opt-in, not breaking

Actions that keep their existing import { defineAction } from '../.quickback/define-action' continue to compile unchanged — ctx.<exposeAs> is any via the AppContext index signature, which is exactly the v0.23–v0.26 behavior. You flip imports per action at your own pace.

Compile-time advisory

When quickback compile sees a namespace-scoped action still importing the generic helper, it emits a one-line advisory pointing at the narrowed path:

[quickback:typing] action "feedMoments" in feature "feeds" lives under
  namespace "eventScope" — switch its import to
  `../.quickback/define-action.event-scope` for narrow ctx.<exposeAs> typing.
  (Runtime behavior is unchanged; the narrowed helper only refines TS types.)

Non-blocking — same [quickback:typing] family as [quickback:authz]. The advisory only mentions actions the compiler can be confident about (namespace-claimed actions whose source still imports the generic helper).

Multi-namespace features

A single feature can contribute actions to several namespaces — say, an interviews feature with actions under both interviewScope and eventScope. The compiler emits one narrowed helper per namespace the feature participates in, and each action picks the import that matches its path.

src/features/interviews/.quickback/
├── define-action.ts                   # generic — standard fallback
├── define-action.interview-scope.ts   # ctx.interview typed
└── define-action.event-scope.ts       # ctx.event typed

Why per-namespace and not per-action

A defineAction helper that's narrow to one exposed key (the namespace's exposeAs) is enough — every action under a namespace sees the same hydrated key. The alternative (per-action emit) duplicates the same prelude N times for the same shape; per-namespace keeps emission proportional to the number of distinct hydration shapes in the project.

Read & Write Configuration

Reads (GET / and GET /:id) live under read:; mutations live under top-level create:, update:, delete:, and upsert:. See Read for the full read pipeline reference.

read: {
  // LIST + GET - GET /resource and GET /resource/:id
  access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] },
  pageSize: 25,        // Default page size
  maxPageSize: 100,    // Client can't exceed this
  views: {              // Per-view field projections
    summary: {
      fields: ['id', 'candidateId', 'jobId', 'stage'],
      access: { roles: ["interviewer"] },
    },
  },
}

create: {
  // CREATE - POST /resource
  access: { roles: ["owner", "hiring-manager", "recruiter"] },
  defaults: {           // Default values for new records
    stage: 'applied',
  },
},

update: {
  // UPDATE - PATCH /resource/:id
  access: {
    or: [
      { roles: ["hiring-manager", "recruiter"] },
      { roles: ["interviewer"], record: { stage: { equals: "interview" } } }
    ]
  },
},

delete: {
  // DELETE - DELETE /resource/:id
  access: { roles: ["owner", "hiring-manager"] },
  mode: "soft",  // 'soft' (default) or 'hard'
},

upsert: {
  // PUT - PUT /resource/:id (only when generateId: false + guards: false)
  access: { roles: ["hiring-manager", "sync-service"] },
}

Evaluation Order

Generated /:id and batch handlers evaluate access in this fixed order:

  1. Auth gate — 401 if unauthenticated
  2. Pre-record access — role-only check (skips access.record predicates)
  3. Firewall queryWHERE clause filters by ownership
  4. Post-record access — full check including record-level predicates
  5. Masking — applied to the response

Step 2 closes the 404-vs-403 ID-probe channel: an unauthorized caller without a matching role gets 403 before the database is touched, so they can't tell apart "row exists out-of-scope" from "row doesn't exist." A caller with the right role but the wrong tenant gets 403 by default (firewall reveals via the access layer); set firewallErrorMode: 'hide' to return an opaque 404 instead.

Function-form access (access: async (ctx, record) => ...) is opaque to the pre-record phase and always passes there; it's fully evaluated post-record.

List Filtering (Query Parameters)

The LIST endpoint automatically supports filtering via query params:

GET /jobs?status=open                         # Exact match
GET /jobs?salaryMin.gt=50000                  # Greater than
GET /jobs?salaryMin.gte=50000                 # Greater than or equal
GET /jobs?salaryMax.lt=200000                 # Less than
GET /jobs?salaryMax.lte=200000                # Less than or equal
GET /jobs?status.ne=closed                    # Not equal
GET /jobs?title.like=Engineer                 # Pattern match (LIKE %value%)
GET /jobs?status.in=open,draft                # IN clause
OperatorQuery ParamSQL Equivalent
Equals?field=valueWHERE field = value
Not equals?field.ne=valueWHERE field != value
Greater than?field.gt=valueWHERE field > value
Greater or equal?field.gte=valueWHERE field >= value
Less than?field.lt=valueWHERE field < value
Less or equal?field.lte=valueWHERE field <= value
Pattern match?field.like=valueWHERE field LIKE '%value%'
In list?field.in=a,b,cWHERE field IN ('a','b','c')

Sorting & Pagination

GET /jobs?sort=createdAt&order=desc   # Sort by field
GET /jobs?limit=25&offset=50          # Pagination
  • Default limit: 50
  • Max limit: 100 (or maxPageSize if configured)
  • Default order: asc

Delete Modes

delete: {
  access: { roles: ["owner", "hiring-manager"] },
  mode: "soft",  // Sets deletedAt/deletedBy, record stays in DB
}

delete: {
  access: { roles: ["owner", "hiring-manager"] },
  mode: "hard",  // Permanent deletion from database
}

mode on the single-record path is inherited by DELETE /batch unless batch names its own — so mode: "hard" above makes the whole resource hard-delete. That changes the schema: a hard-delete table must not declare deletedAt / deletedBy (via ...q.softDelete() or literal Drizzle lines), and the firewall drops its isNull(deletedAt) predicate. See Soft-delete fields.

Context Variables

Use $ctx. prefix to reference context values in conditions:

// User can only view their own records
access: {
  record: { userId: { equals: "$ctx.userId" } }
}

// Nested path support for complex context objects
access: {
  record: { ownerId: { equals: "$ctx.user.id" } }
}

AppContext Reference

PropertyTypeDescription
$ctx.userIdstringCurrent authenticated user's ID
$ctx.activeOrgIdstringUser's active organization ID
$ctx.activeTeamIdstring | nullUser's active team ID (if applicable)
$ctx.rolesstring[]User's roles in current context
$ctx.isAnonymousbooleanWhether user is anonymous
$ctx.userobjectFull user object from auth provider
$ctx.user.idstringUser ID (nested path example)
$ctx.user.emailstringUser's email address
$ctx.{property}anyAny custom context property

Function-Based Access

For complex access logic that can't be expressed declaratively, use a function:

update: {
  access: async (ctx, record) => {
    // Custom logic - return true to allow, false to deny
    if (ctx.roles.includes('admin')) return true;
    if (record.ownerId === ctx.userId) return true;

    // Check custom business logic
    const membership = await checkTeamMembership(ctx.userId, record.teamId);
    return membership.canEdit;
  }
}

Function access receives:

  • ctx: The full AppContext object
  • record: The record being accessed (for get/update/delete operations)

On this page

Basic UsageRole HierarchyCustom org rolesConfiguration OptionsuserRole vs rolesWhen to use userRolePseudo-rolesPUBLIC — unauthenticated accessAUTHENTICATED — any authenticated caller (the wrapper)SESSION — a full session onlySCOPED — a scope principal onlyUSER — per-user recordsADMIN — legacy reserved marker (compile-time error)SYSADMIN — cross-tenant DB-admin tierMixing pseudo-roles with real rolesQuick referenceCentrally defined access rulesRule shapesWhere references resolveBeyond roles: named permissionsNamed gates (authz.rules)The two-stamp contractNaming rulesTypo safetyRelationship-based authorizationauthz.relationshipsUsing relationship-roles in resourcesSupported relationship-role surfacesStandalone actions: surface keys and loads / exposeAs (v0.19+)Conditional tenant preconditions (v0.20+)Requirement on the relationship's from tableAction firewall escape hatch (v0.19+)Recommended pattern: gate at the firewall layerCross-tenant "my events" dashboards are NOT thisWorked example: GET /my-events standalone actionCompile-time refusals (consolidated)Namespace mounts (hoisted relationship middleware)When to use roles vs. relationshipsSingle feature: defineTable({ namespace }) (v0.23+)Cross-feature: authz.namespaces (v0.24+)Multi-lane via (v0.25+) — first match winsBulk-grant lanes (v0.26+)Discovery is path-prefix-onlyRequirements (enforced at compile time)What namespace actions get for freeNarrow ctx.<exposeAs> typing (v0.27+)Opt-in, not breakingCompile-time advisoryMulti-namespace featuresWhy per-namespace and not per-actionRead & Write ConfigurationEvaluation OrderList Filtering (Query Parameters)Sorting & PaginationDelete ModesContext VariablesAppContext ReferenceFunction-Based Access