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. Accountless principals (invite codes, partner tokens) mint the same scope via ctx.mintScope.
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.
Most scope principals are signed-in users who prove a relationship at /scope/v1/enter. But some have no account at all — an attendee who arrives by email + invite code, a kiosk/handout code, a partner token. For those, an action proves the principal its own way and ctx.mintScope mints the same scope, no Better Auth user required.
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).
Scope kinds declare at root authz (visible everywhere) or inside a
feature area's _area.ts — then the kind, its bare
role aliases, the canonical scope:<kind>:<role> spellings, and ctx.scope.<kind> accessors
are all visible to that area's subtree only (any outside reference is a compile error):
// 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", "sctMs": "m1700000000000" } }sctMs is on every entry: the server's proof timestamp for that kind (epoch milliseconds, written m<ms>) — see Revocation & TTL. The examples below elide it to keep the key under discussion in view.
The client sends that JWT as Authorization: Bearer … on subsequent requests. The auth middleware verifies the signature and sets ctx.scope — no DB. The claim is carried forward on every authed call (rolling refresh) for up to 15 minutes from the last actual relationship proof — the mint stamps a proof timestamp that the refresh carries forward verbatim and checks against the carry-forward cap. Past the cap the refresh stops, the token expires within one TTL, and the client re-proves via enter (or any namespace route that mints) to restart the window.
The proof timestamp is per scope kind. A mint proves the kind it was asked for and carries every other kind forward with the stamp it already had (sctMs on the entry, epoch milliseconds written m<ms>), so entering one relationship never extends — or resurrects — another. Re-proving event restarts event's window and nothing else.
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: {
id: q.id(),
nameAtInvite: q.text().required(),
email: q.text().required(),
eventId: q.text().required(),
shuttleId: q.text().optional(),
pickupLocation: q.text().optional(),
organizationId: q.scope('organization'),
...q.audit(),
...q.softDelete(),
},
// 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'] },
},
},
},
});| Pillar | How 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
...q.audit(),
...q.softDelete(),
});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 (organization → ctx.activeOrgId,
owner → ctx.userId, team → ctx.activeTeamId); any other kind maps to
ctx.scope.<kind>.id.
Stamping a proven identity claim (q.stamp)
q.scope('event') stamps the tenant id (ctx.scope.event.id). To stamp a
different proven claim off the same verified scope — the caller's own
subjectId (the conferring row's PK) or a scalar sub-key — use
q.stamp:
authorId: q.stamp({ fromScope: 'event', claim: 'subjectId' }),Every proven scope carries id, subjectId, and any projected sub-keys; a
q.stamp column reads one of those. Unlike q.scope it is never a firewall
arm (identity is not a tenant scope), and it force-stamps only for scope-only
principals — an org member/admin/owner writes it via the org path. A scope-only
caller missing the claim gets a fail-closed 403, never a NULL row.
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
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
export default defineAction({
description: "Return the event home payload for the caller's proven scope.",
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 —attendeeOf→scope.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 TTL —
auth.jwt.expiresIn(default 180s), clamped to a hard ≤180s ceiling at every signing site that emits a scope claim: both proven mints (/scope/v1/enter, the namespace resolver) and the middleware's rolling refresh. A longer-lived scope token would widen the post-revocation replay window past the spec bound, so the clamp is non-negotiable — anauth.jwt.expiresInabove the ceiling applies to plain tokens but never survives into a scope-bearing one. - 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 mintedscope:<kind>:<role>is backed by exactly the proof/scope/v1/enterrequires. The token is byte-identical to one/scope/v1/enterwould 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.
acceptScope — accept an incoming scope claim as proof of entry
mintScope is the resolver minting a scope claim after a ctx.userId lane
matches. acceptScope: '<kind>' is the inverse: it lets the same resolver
accept an already-minted ctx.scope.<kind> claim as its own proof — so one
/event/:eventId/* handler serves both the Better-Auth user (userId
relationship probe) and the sessionless scope principal (verified scope
JWT, no account). It's the exact fast-path the realtime
ws-ticket already has, ported to REST.
Without it, every scope-principal-facing namespaced action needs a parallel
PUBLIC twin (a separate handler with a hand-rolled requireScope check and
raw DB writes that bypass scoped-db) — double the surface, and "someone forgot
the twin" becomes a standing bug class. acceptScope retires the twin.
// quickback.config.ts
authz: {
relationships: {
inspectorOf: {
from: 'site_inspectors',
subject: { column: 'inspectorUserId', equals: 'ctx.userId' },
resource: { column: 'locationId' },
where: { status: 'active' },
loads: 'locations',
exposeAs: 'location',
},
},
scopes: {
location: {
requestField: 'locationId',
roles: {
// supplied-reachable: a signed-in user AND a sessionless principal
// converge on ONE scope:location:inspector.
inspector: { via: 'inspectorOf', subKeys: ['inspectorId'], subject: 'both' },
},
},
},
namespaces: {
locationScope: {
prefix: '/location/:locationId',
via: ['inspectorOf'],
acceptScope: 'location', // ← honor an incoming ctx.scope.location claim
// mintScope: true, // ← compose freely: a userId caller also re-mints
},
},
}A sessionless principal that already minted a token (via
ctx.mintScope or /scope/v1/enter)
then hits the namespaced action directly, no second round-trip:
GET /api/v1/location/loc_42/summary
Authorization: Bearer <scope-only-jwt carrying scope.location = { id: 'loc_42', roles: ['inspector'] }>
200 OKThe resolver reads ctx.scope.location, confirms its id equals the :locationId
param, sees the inspector role a supplied-reachable lane confers, and admits
the request — no ctx.userId, no DB relationship probe. ctx.location is
hydrated through its own firewall exactly as it is for the userId caller.
Two locks (both required, fail-closed)
acceptScope is named-string only — always the explicit kind, never a bare
true (there's no inference from the :param). Default absent → the resolver
never admits a scope principal (unchanged behavior). Beyond naming the kind,
the compiler enforces a second lock:
acceptScope: '<kind>'opts this namespace's REST surface in, and- at least one relationship lane of the namespace confers a
<kind>role declaredsubject: 'supplied' | 'both'(supplied-reachable).
A subject: 'user' role can be minted or refreshed by the namespace but can
never be accepted from a token — a user-only role must be proven by a live
ctx.userId probe. If acceptScope names a kind whose every conferring lane is
subject: 'user', the compiler errors rather than emit a dead fast-path.
The compiler also checks the kind is declared and its requestField equals the
prefix's first :param.
Forging safety
A scope principal can't use acceptScope to reach anything its token doesn't
already authorize:
- The claim is verified, not asserted —
ctx.scopeis populated only from a JWT the auth middleware verified; the action can't write it. - Named kind, not any-kind — the fast-path reads
ctx.scope['<kind>']for the declared kind (stricter than the ws-ticket's any-key loop); a colliding foreign-kind id can't open the gate. - Instance pinned twice —
entry.id === :paramin the gate, andeq(loads.pk, :param)in hydration. The principal enters exactly the instance its claim proved. - Role → lane is exact and supplied-restricted — each accept arm is one
(role, lane)pair withsubject !== 'user'; aninspectorclaim matchesinspectorOf, never a sibling lane. - No new enforcement surface — firewall, masking,
accessarms, and scoped-db all read the samectx.scope/ctx.activeOrgIdthey read for a userId caller. New proof source, zero new enforcement surface.
The tenant (ctx.activeOrgId) is sourced from the pinned-loaded resource
(ctx.<exposeAs>.organizationId) — parity with the userId caller, not a new
grant. Fine-grained "own rows only" stays the resource author's job via a
firewall arm on ctx.scope.<kind>.subjectId, the same arm that already
serves the userId principal.
Writes: audit fields for a userless principal
A scope principal has no ctx.userId, so the scoped-db audit wrapper — which
stamps created_by / modified_by — can't record a user. Rather than force a
parallel raw-db write path (which would re-scatter the exact duplication
acceptScope removes), the wrapper stamps the principal's synthetic, audit-only
id — the token's sub, scope:<kind>:<subjectId> — into those columns:
// A verified scope principal's write, through the normal scoped db:
await db.insert(vendorCheckins).values({ eventId, note });
// → created_by = modified_by = "scope:event:guest_abc123"The scope: prefix self-describes the actor (which kind, which subject), so no
separate "actor type" column is needed. This is the write-side companion to the
read fast-path: one handler body serves both a Better-Auth user and the
sessionless principal, on reads and writes alike.
It stays fail-closed: the relaxation fires only for ctx.scopePrincipal === true. A write that reaches the wrapper with no userId and no scope
principal (genuine auth-context loss) still throws — the audit layer never
silently logs an unauthenticated write. For a real system-side write outside any
request, use the unwrapped db (createDb) directly, as before.
Sessionless scope mint — ctx.mintScope
POST /scope/v1/enter and the namespace resolver both prove the relationship by
ctx.userId — so they require a Better Auth account. But a legitimate
principal often has none: an event attendee who arrives by email + invite
code, holding an opaque session token you validate yourself, who must never
get a global account. The shape is general — magic links, kiosk/handout codes,
partner-issued API keys, expiring share links that need row-level scope.
For these, prove the relationship your own way inside an action, then ask Quickback to mint a disciplined scope token from the row you proved.
1. Opt the role into supplied-subject proof
Add subject to the scope role (default 'user'):
authz: {
scopes: {
event: {
requestField: 'eventId',
roles: {
// 'both' → the SAME scope:event:attendee for a logged-in user
// (via /enter) AND a sessionless invite-holder (via ctx.mintScope).
attendee: { via: 'attendeeOf', subKeys: ['guestId'], subject: 'both' },
},
},
},
}'user'(default) —/scope/v1/enteronly, proven bysubject.column = ctx.userId. Unchanged.'supplied'— sessionless only.ctx.mintScopemints it;/enterwon't (there's no user to probe).'both'— either path. A user-attendee and a sessionless invite-attendee converge on onescope:event:attendee— one firewall arm, one masking rule, both principals.
This opt-in is the single fail-closed switch: without 'supplied' or 'both', no relationship is mintable by a supplied id.
2. Mint from the proven row
ctx.mintScope is hydrated on ctx in any action when a subject: 'supplied' | 'both' role is declared (including PUBLIC actions — the
canonical caller has no token yet). The action proves the principal however it
likes, then hands Quickback only the conferring row's primary key:
// features/events/actions/enterEvent.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
export default defineAction({
description: "Redeem an invite code and mint the caller's event scope token.",
path: '/enter-event',
method: 'POST',
input: z.object({ code: z.string() }),
access: { roles: ['PUBLIC'] }, // unauthenticated entry
async execute({ ctx, input }) {
const guestRowId = await validateInviteCode(ctx, input.code); // YOUR proof, YOUR table
const { token } = await ctx.mintScope({ via: 'attendeeOf', subject: guestRowId });
return { token }; // client sends it as `Authorization: Bearer …` thereafter
},
});You assert nothing about the scope. Quickback re-reads the attendeeOf row
by primary key and takes the role, the resource id (eventId), the sub-keys,
and the subject id off that row — so a buggy or hostile action can't forge
organizer, can't pick a different event, and can't widen the slice. The only
thing delegated to you is which row — exactly the opaque-credential check that
has to be custom code.
| What you supply | What Quickback reads off the row |
|---|---|
via (a declared supplied/both relationship) + subject (the from-row PK you proved) | the role, id (resource), sub-keys, and subjectId — none caller-asserted |
The where: filter runs on the mint probe exactly as at /enter — a cancelled
or pending row confers nothing, so flipping a guest's status revokes them
within one mint cycle (≤180s). Re-prove by calling ctx.mintScope again
(your own refresh endpoint does exactly that). The by-PK probe does not apply
the relationship's tenant firewall (a sessionless principal has no
activeOrgId yet — the proven row is what establishes scope), but matching by
primary key + the where: filter keeps it bounded and fail-closed.
3. The scope-only token
ctx.mintScope signs a scope-only JWT — it carries the scope claim and
nothing else. There is no user identity: the auth middleware builds a context
with ctx.userId undefined, so every user-, org-, and owner-gated path
fails closed automatically (an owner firewall compares against undefined
→ matches nothing; org-role gates see empty ctx.roles). AUTHENTICATED
admits a scope principal past the auth gate — it's the wrapper over session
and scope callers (see Access → Pseudo-roles,
where SESSION and SCOPED distinguish the two) — but a user/org/owner
firewall then filters it to zero rows (no ctx.userId, no ctx.activeOrgId),
so it reaches nothing it shouldn't. USER is likewise gate-permissive but
firewall-excluded: it mandates a userId scope the principal can't satisfy. To
serve a scope principal actual rows, use scope:<kind>:<role> gates +
ctx.scope.<kind> firewall arms (or SCOPED to admit any scope-token caller as
a tier).
Everything downstream — REST firewall, masking, scoped-role access, the realtime
ws-ticket — then treats the sessionless attendee identically to a
user-proven one. There is no new enforcement surface; the principal is just
scope:event:attendee.
subjectId — scoping to the person, not just the instance
A user-proven scope scopes self-data (an attendee's travel, dietary, todos) by
ctx.userId. A sessionless attendee has no ctx.userId, so the proven
subject's identity rides in the claim as a reserved subjectId — the
conferring row's primary key:
"scope": { "event": { "id": "evt_123", "roles": ["attendee"], "subjectId": "gst_42" } }Scope self-data on it with one firewall arm:
{ field: 'guestId', equals: 'ctx.scope.event.subjectId' }subjectId is populated on both proof paths — /scope/v1/enter projects
the matched row's PK too — so the arm above is one arm that serves both a
logged-in attendee and a sessionless one. (It is distinct from
ctx.scope.event.id, which is the instance — the event.)
Realtime
A scope-only token authorizes the realtime
ws-ticket directly off its proven
ctx.scope claim — no ctx.userId re-probe. The sessionless attendee joins the
same per-event rooms (and per-attendee rooms keyed on subjectId) as a
user-attendee, carrying the same scope role on the ticket. So the REST gate and
the socket gate stay identical for accountless principals too.
Revocation & TTL
The scope claim is trusted between proofs — it is never re-checked against the database while it rides. Two bounds keep that window finite:
- The TTL (default 180s,
auth.jwt.expiresIn, clamped ≤180s on scope-bearing tokens) bounds an idle client: a token that stops being used dies within one TTL. - The carry-forward cap (15 minutes) bounds an active client: the rolling refresh re-signs the claim on every authed call, but only while its proof timestamp (stamped exclusively by the proven mint sites) is younger than the cap. The cap is applied per scope kind — a kind whose own proof is past it is dropped from the re-signed claim, so re-proving one relationship cannot roll another forward. Past the cap the refresh refuses, the token expires, and the next
enter(or namespace-route mint) re-probes the relationship — which won't re-mint a revoked role.
So removing a guest/staff row takes effect within cap + one TTL for a client that keeps polling, and within one TTL once it goes idle. Two ways to tighten that:
auth.jwt.revocationCheck: 'kv'(v0.45+, Cloudflare runtime) — sub-TTL revocation: updating or deleting a conferring relationship row stamps a revocation timestamp in KV, and the bearer fast-path rejects a carried scope claim whose proof is at or before it — no same-second gap. Each kind is compared against its own proof time (sctMson the entry; older tokens without one fall back to the token-levelsct/sctMs), so a mint of one kind can't carry a revoked kind past its marker. Neither the refresh nor a re-mint ever substitutes its own clock for a proof it didn't take. The check fails closed — a scope claim whose revocation state can't be read is not trusted either. See auth config.viaarms — for tables where any caching is too wide, gate with aviaarm instead of a barectx.scope.<kind>arm:viaemits a live subquery against the relationship table on every request, immune to a stale claim entirely.
Reference
// AppContext (generated lib/types.ts)
ctx.scope?: Record<string, { id: string; roles?: string[]; subjectId?: 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
subject?: 'user' | 'supplied' | 'both'; // who selects the conferring row (default 'user')
}>;
}
// ctx.mintScope — present in actions when a `subject: 'supplied' | 'both'` role is declared
ctx.mintScope(opts: { via: string; subject: string; setAuthToken?: boolean }):
Promise<{ token: string; scope: { id: string; roles: string[]; subjectId?: string } }>;requestFieldmust equal every role-relationship'sresource.column.- Each relationship's
fromtable must exist and ship a generated firewall (it can't beexception). - A
subKeysentry ending in[]is set-valued (lowers toinArray, minted asstring[]); without the marker it's scalar (eq, minted asstring). id,roles,subjectId, andsctMsare reserved on the claim entry — the mint owns them, so asubKeysentry of that name is a compile error rather than a row value overwriting proven state.subjectdefaults to'user'.'supplied'/'both'enablectx.mintScope(sessionless mint);'supplied'is not mintable via/scope/v1/enter.ctx.mintScope({ via, subject })mints a scope-only token (no user identity,ctx.userIdundefined) from thefrom-row whose PK issubject— reading the role, resource id, sub-keys, andsubjectIdoff that row.subjectIdis the conferring row's primary key, populated on both proof paths — scope self-data onctx.scope.<kind>.subjectId.q.scope('<kind>')auto-stamps the column fromctx.scope.<kind>.idon insert.- Generated route:
POST /scope/v1/enter(mounted only whenauthz.scopesis declared).
Permissions
Name a grant rule once with anyOf / allOf / not over relationships, org roles, scoped roles, and pseudo-roles — then reference it from any firewall as { permission: 'x' }.
Arrows
Traverse a foreign key to inherit authority from a parent object — "you can see this row because you administer the organization it belongs to", or "because you control an ancestor in the tree". Declared once under authz.arrows, referenced from a permission, lowered to an FK-hop subquery or a bounded WITH RECURSIVE CTE.