Quickback Docs

Scopes & Capability Grants

Let non-org principals (event attendees, shuttle drivers, vendors) reach a narrow slice of your data through a server-verified, relationship-derived scope carried in a signed JWT — with least-privilege scoped roles enforced across every pillar.

Org membership answers "which tenant is this caller in?" Scopes answer a different question: "this caller isn't an org member, but they're a confirmed attendee of event E — let them see exactly E's rows, as exactly the role they hold, and nothing else."

A scope is a tenant axis whose value is established by a relationship you prove once (at POST /scope/v1/enter) and then carry in a signed JWT claim — verified cryptographically on every later request with no database round-trip. It's the REST/JWT twin of the realtime ws-ticket.

When to use it

Reach for a scope when a principal must touch a bounded slice of an instance without being an org member:

  • Event attendee — reads their event's announcements / sessions, not the org.
  • Shuttle driver — sees only the passengers on their bus (a row slice), can DM them.
  • F&B vendor — reads dietary counts, zero attendee rows.
  • Visiting nurse / carrier / contractor — one patient's meds, one carrier's shipments.

Each is event-scoped but a distinct least-privilege role. Org members are unaffected — they keep their normal tenant access.

The model

1. Declare relationships + scopes

A scope kind (event) declares named roles, each conferred by a relationship whose from row carries the role (and optionally a sub-key for row slicing):

// quickback.config.ts
authz: {
  relationships: {
    attendeeOf:      { from: 'guests', subject: { column: 'linkedUserId', equals: 'ctx.userId' }, resource: { column: 'eventId' }, where: { status: 'confirmed' } },
    organizerOf:     { from: 'staff',  subject: { column: 'linkedUserId', equals: 'ctx.userId' }, resource: { column: 'eventId' }, where: { role: 'organizer' } },
    shuttleDriverOf: { from: 'staff',  subject: { column: 'linkedUserId', equals: 'ctx.userId' }, resource: { column: 'eventId' }, where: { role: 'shuttleDriver' } },
  },
  scopes: {
    event: {
      requestField: 'eventId',                 // the body field the client sends to /enter
      roles: {
        attendee:      { via: 'attendeeOf' },
        organizer:     { via: 'organizerOf' },
        shuttleDriver: { via: 'shuttleDriverOf', subKeys: ['shuttleId'] }, // row-slice sub-key
      },
    },
  },
}

2. Enter the scope

POST /scope/v1/enter      (session OR existing JWT auth)
{ "eventId": "evt_123" }

The server probes every declared role for the proposed instance (one DB touch), and mints the proven roles + sub-keys into a signed JWT scope claim, returned via the set-auth-token header and the body:

// a shuttle driver who is also a confirmed guest
"scope": { "event": { "id": "evt_123", "roles": ["attendee", "shuttleDriver"], "shuttleId": "shA" } }

The client sends that JWT as Authorization: Bearer … on subsequent requests. The auth middleware verifies the signature and sets ctx.scopeno DB. The claim is carried forward on every authed call (rolling refresh) until you enter a different instance.

Invariant: the client only proposes the instance id (from the URL). The server proves membership before signing. No client-asserted scope is ever trusted.

3. Gate the pillars

Scoped roles are referenced everywhere a pillar takes a role as the namespaced identifier scope:<kind>:<role>:

// features/guests/guests.ts — the manifest, row-sliced + PII-masked
export default feature('guests', {
  columns: { /* … eventId, shuttleId, email, … */ },
  // org staff see the whole event; a shuttle driver sees only their bus
  firewall: {
    any: [
      { field: 'organizationId', equals: 'ctx.activeOrgId' },
      { all: [
        { field: 'eventId',   equals: 'ctx.scope.event' },
        { field: 'shuttleId', equals: 'ctx.scope.event.shuttleId' }, // sub-key row slice
      ] },
    ],
  },
  masking: {
    email: { type: 'email', show: { roles: ['admin', 'scope:event:organizer'] } }, // hidden from the driver
  },
  read: {
    views: {
      manifest: {
        fields: ['id', 'nameAtInvite', 'shuttleId', 'pickupLocation'],
        access: { roles: ['admin', 'scope:event:organizer', 'scope:event:shuttleDriver'] },
      },
    },
  },
});
PillarHow it reads the scoped role
Firewall (rows)all/any arms keyed on ctx.scope.<kind> (+ sub-keys) — see Firewall
Access (verbs/views)access.roles: ['scope:event:shuttleDriver'] on CRUD, actions, and views
Masking (fields)masking.<col>.show.roles includes / excludes scope:event:<role>

The org / scope split (security)

Scoped roles never merge into ctx.roles. A scope:* identifier matches only the verified scope claim (ctx.scope.<kind>.roles); a bare org role (admin, member) matches only ctx.roles. The compiler enforces the split so an externally-provisioned shuttleDriver can never satisfy an admin gate by string coincidence, and a tampered scope claim can't impersonate an org role. This is the precondition for admitting limited outside users at all.

Sub-keys (row slicing)

A role's subKeys name columns on the relationship's from row that are projected into the claim. A firewall arm then slices on them:

{ field: 'shuttleId', equals: 'ctx.scope.event.shuttleId' }

Because sub-key arms compose with all, an absent sub-key denies (sql\1 = 0`) rather than widening — a driver can never see *all* shuttles by simply lacking the claim. The scope accessor is always emitted null-safe (ctx.scope?.event?.shuttleId`), so an org caller who never entered the scope hits a clean deny, not a 500.

Set-valued sub-keys

A driver may cover more than one bus. Append a [] marker to the sub-key name to project every matched from row's value into the claim as a string[]:

roles: {
  shuttleDriver: { via: 'shuttleDriverOf', subKeys: ['shuttleId[]'] }, // set-valued
}

The [] is a marker only — it's stripped from the column name (the relationship's from row still reads shuttleId). At /scope/v1/enter the proven rows union their values, so the claim carries an array:

"scope": { "event": { "id": "evt_123", "roles": ["shuttleDriver"], "shuttleId": ["shA", "shC"] } }

The matching firewall arm — written exactly as for the scalar case ({ field: 'shuttleId', equals: 'ctx.scope.event.shuttleId' }) — is rewritten by the compiler to an inArray row-slice:

WHERE shuttle_id IN (ctx.scope?.event?.shuttleId ?? [])

The ?? [] default makes an absent claim lower to inArray(col, []) — a clean DENY, never a malformed SQL fragment or a 500. A scalar sub-key (no [] marker) is untouched and keeps lowering to eq. You write the same equals arm either way; declaring shuttleId[] vs shuttleId in the scope role is the only switch.

Auto-stamping the scope column on insert (q.scope)

q.scope('organization') auto-stamps the tenant column on write, the same way q.scope('owner') stamps ownerId. A relationship-derived kind works identically: q.scope('event') auto-stamps the column from the verified scope claim (ctx.scope.event.id) on insert — no need to pass eventId in the request body:

export const eventNotes = q.table('event_notes', {
  id:             q.id(),
  eventId:        q.scope('event'),         // INSERT autofill from ctx.scope.event.id
  body:           q.text().required(),
  organizationId: q.scope('organization'),  // INSERT autofill from ctx.activeOrgId
});

The stamp is insert-only and null-safe — the generated CREATE handler writes eventId: ctx.scope?.event?.id, so it never binds undefined. It does not add a read-time WHERE filter (that's the firewall's job; a read scoped to eventId = ctx.scope.event.id would be wrong here). Infra kinds map to their session fields (organizationctx.activeOrgId, ownerctx.userId, teamctx.activeTeamId); any other kind maps to ctx.scope.<kind>.id.

Namespace scope-claim refresh

A scope is normally minted at POST /scope/v1/enter. When you also mount a namespace that gates /event/:eventId/* on the same relationship, the namespace resolver can refresh ctx.scope.<kind> in memory for the duration of that request — so an attendee hitting /event/evt_123/announce gets ctx.scope.event = { id: 'evt_123', roles: ['attendee'] } without a separate enter round-trip. The refresh runs after the gate (the gate proves the relationship; the refresh stamps the proven role from the path param).

This is a strictly-guarded convenience, and it stamps per matched lane. The resolver records which via: lane opened the gate (first-match-wins) and stamps only that lane's scope role — an attendee who matched attendeeOf gets roles: ['attendee']; an organizer who matched organizerOf gets roles: ['organizer']. A caller can never receive a role they didn't prove this request.

  • Relationship lanes each confer their own scope role, stamped only when that exact lane matched — so a multi-lane via: (e.g. via: ['attendeeOf', 'organizerOf']) refreshes the matched lane's role, not an ambiguous union.
  • A bulk-grant lane ({ roles: [...] } or { team: true }) confers no scope role — it opens the gate via the caller's org role without proving any relationship, so there is nothing to stamp (stamping one would forge it). The gate still passes; the caller is simply authorized by their org role.

The refresh described so far is in-memory and single-request. To also hand the caller a persisted scope token, opt the namespace into mintScope — see below.

Gotcha: a namespace only emits its resolver when it claims a standalone action

This is the single most common surprise (it has tripped up a customer): a namespace emits its resolver — and therefore the scope refresh and the mintScope mint — only when at least one STANDALONE action's path: falls under the namespace prefix.

A standalone action declares its own path: (e.g. path: '/event/:eventId/home'), so the compiler can match it against the namespace prefix and hoist it into the synthetic namespace routes file. Record-based actions are different — they mount under their resource's CRUD routes (/api/v1/guests/:id/checkin), which live outside the /event/:eventId prefix. They never match the prefix, so they never get claimed.

A namespace that contains only record-based actions is therefore inert: nothing matches the prefix, no synthetic routes file is emitted, and so there is no resolver, no scope refresh, and no mint — even though the namespace is declared and validates cleanly. The middleware you expected to gate /event/:eventId/* simply isn't there.

The fix is to give the namespace at least one standalone action under the prefix. In q-events, eventHome exists precisely for this — a standalone GET /event/:eventId/home that pulls the resolver (and the §5.3 mint) into existence:

// features/events/actions/eventHome.ts
export default defineAction({
  path: '/event/:eventId/home',   // ← standalone path UNDER the namespace prefix
  method: 'GET',
  input: z.object({}),
  access: { roles: ['scope:event:attendee', 'scope:event:organizer', 'manager+'] },
  async execute({ db, ctx, c }) { /* … */ },
});

With that action present, the eventScope resolver is emitted, runs the attendeeOf probe once per /event/:eventId/* request, stamps ctx.scope.event, and (because the namespace sets mintScope: true) mints the scope token. Without a standalone action under the prefix, none of that fires.

mintScope — mint the scope token from the namespace resolver

A scope is normally minted by an explicit POST /scope/v1/enter round-trip. Setting mintScope: true on a namespace folds that mint into the namespace resolver: on a successful relationship probe the resolver MINTS a persisted scope JWT and returns it via the set-auth-token header — so a native client hitting /event/:eventId/* gets a scope token without ever calling /scope/v1/enter.

// quickback.config.ts
authz: {
  relationships: {
    attendeeOf: {
      from: 'guests',
      subject: { column: 'linkedUserId', equals: 'ctx.userId' },
      resource: { column: 'eventId' },
      where: { status: 'confirmed' },
      loads: 'guests',
      exposeAs: 'guest',
    },
  },
  namespaces: {
    eventScope: {
      prefix: '/event/:eventId',
      via: ['attendeeOf'],   // one or more lanes — mints the matched lane's role
      mintScope: true,       // ← fold the scope-token mint into the resolver
    },
  },
}

A confirmed attendee then hits the standalone action under the prefix:

GET /api/v1/event/evt_123/home
Authorization: Bearer <existing-jwt>

200 OK
set-auth-token: <new-scoped-jwt>     ← carries scope.event.roles = ["attendee"]

The client stores the set-auth-token value and sends it as the bearer on every subsequent /event/:eventId/* request. The JWT carries scope.event = { id: 'evt_123', roles: ['attendee'] }, so the scoped-role access checks (scope:event:attendee) pass with no DB touch and no second enter call.

mintScope is opt-in per namespace and defaults to false. With it off, the resolver does the in-memory ctx.scope.<kind> refresh only (the section above); subsequent requests must still call /scope/v1/enter for a persisted token.

Per-matched-lane minting (forging safety)

The mint stamps the scope role of whichever lane matched, gated on the first-match-wins probe — the same per-lane discipline as the in-memory stamp, applied here with higher stakes: a minted token persists for its whole TTL, so it can only ever carry the role the caller actually proved this request.

  • A multi-lane via: (e.g. via: ['attendeeOf', 'organizerOf']) mints the matched lane's role — attendeeOfscope.event.roles = ['attendee'], organizerOf['organizer']. The resolver records which lane opened the gate, so there is no ambiguity.
  • A bulk-grant lane ({ roles: [...] } or { team: true }) mints no scope role — it opens the gate via the caller's org role without proving a relationship. The gate passes; the caller carries their org role, not a minted scope role.

The realtime ws-ticket can authorize through this same namespace via:, issuing a per-matched-lane WebSocket ticket — so the socket gate and the REST gate stay identical.

In every suppressed case the gate still authorizes the request; only the mint (and the in-memory refresh) are skipped, and those callers obtain a scope token through /scope/v1/enter as usual. Flipping mintScope: true can never forge a scope role — the guard, not the flag, is the gate.

TTL and best-effort semantics

  • TTL is the scope-token TTLauth.jwt.expiresIn (default 180s), clamped to a hard ≤180s ceiling at the mint site. A longer-lived scope token would widen the post-revocation replay window past the spec bound, so the clamp is non-negotiable.
  • The mint is gated by the same proven-relationship guard as the in-memory stamp. It runs after the gate has granted access, reading the instance id from the proven path param (:eventId) — never from request input — so the minted scope:<kind>:<role> is backed by exactly the proof /scope/v1/enter requires. The token is byte-identical to one /scope/v1/enter would produce: same shared mint helper, same carry-forward of any other verified scope kinds already on the incoming token (the freshly-proven kind takes precedence; siblings survive the re-mint).
  • The mint is best-effort. A mint failure (e.g. a missing signing secret) never fails the request — the in-memory stamp already authorized it; the caller simply doesn't receive a refreshed token on that response and can fall back to /scope/v1/enter.

Revocation & TTL

The scope claim is trusted for the JWT TTL (default 180s, auth.jwt.expiresIn) — identical to how orgId / role claims already behave. Removing a guest/staff row takes effect within one TTL (the next enter won't re-mint the role). Immediate revocation (auth.jwt.revocationCheck: 'kv') is on the roadmap.

Reference

// AppContext (generated lib/types.ts)
ctx.scope?: Record<string, { id: string; roles?: string[]; [subKey: string]: string | string[] | undefined }>;

// authz.scopes.<kind>
{
  requestField: string;                                  // body field carrying the instance id
  roles: Record<string, { via: string; subKeys?: string[] }>;  // 'col' → eq, 'col[]' → inArray
}
  • requestField must equal every role-relationship's resource.column.
  • Each relationship's from table must exist and ship a generated firewall (it can't be exception).
  • A subKeys entry ending in [] is set-valued (lowers to inArray, minted as string[]); without the marker it's scalar (eq, minted as string).
  • q.scope('<kind>') auto-stamps the column from ctx.scope.<kind>.id on insert.
  • Generated route: POST /scope/v1/enter (mounted only when authz.scopes is declared).

On this page