authorize
authorize(options?: AuthorizeOptions): RouteBuilder<Current>
Declare an authorization requirement on the next route. Route-only, same staging convention as .id, .title, .description, .input, .output, .tag, and .batch: it writes onto the next-route options. Calling a pipeline op (.to, .transform, .process, ...) while authorizers are staged but no .from() has opened the next route throws RC2001 with a message that lists .authorize alongside the other staging ops. For a mid-pipeline check use .validate(authorize({ ... })) directly.
The check runs at route entry, before any pipeline step. It verifies that the inbound exchange carries an authenticated principal and (optionally) that the principal has every required role and scope. It does NOT issue, mint, or attach any credential: it asserts an existing identity meets the criteria. Multiple .authorize() calls stack and AND-combine in declaration order, so a missing role in the first call short-circuits before later predicates run.
.authorize() can also act as a route-starter when chaining routes: craft().from(s1).to(d1).authorize({...}).from(s2).to(d2) opens route 2 with the authorizer staged, no explicit .id("next") required.
For mid-pipeline checks (rare, for example after a .process() swaps the principal or inside a .choice() branch), use .validate(authorize({ ... })) directly with the underlying validator function.
AuthorizeOptions:
Match actors by the (issuer, subject) pair: a bare subject matches a same-named actor from any issuer, which is ambiguous the moment two issuers exist.
Any one of several scopes
scopes is an AND over exact strings. Where a scope family has narrowing variants that are interchangeable at the door, anyScope is the OR:
craft()
.id('read-leave')
.authorize({ anyScope: ['leave:read', 'leave:read:self', 'leave:read:base'] })
.from(http({ path: '/leave', method: 'GET' }))
.to(leaveDestination)
Any one of the three admits the caller, and the exact variants held select rows and fields further down the pipeline. Reaching for predicate instead would cost the recoverable refusal: a failing predicate throws RC5015, which carries no missing.scopes, so exactly the routes that most need to name what would have worked lose the ability to. An anyScope refusal throws RC5038 naming the whole accepted set, because any one entry would have opened the door and a consent flow should be able to offer the caller the choice.
Given both, scopes and anyScope compose as an AND of the two conditions: every entry of scopes, and at least one entry of anyScope.
Reading the actor's scopes
scopes and anyScope read the subject's ring. On a delegated call, effective: true widens them to the subject's ring plus the OUTERMOST actor's:
// Zoe may read the restricted mailbox under her own standing authority.
// The caller she is acting for need not hold that scope themselves.
craft()
.id('summarise-mailbox')
.authorize({
scopes: ['mail:read:admin'],
effective: true,
actor: { subject: 'agent:zoe', issuer: 'https://agents.example.com' },
})
.from(direct())
.to(summarise)
This is the normal shape of delegation rather than an edge case. An agent legitimately holds standing scopes its caller does not, because that is its job. A gate reading only the subject's ring means the agent can never exercise its own authority on anyone's behalf, and it cannot delegate to itself to recover it.
Three rules bound what the flag does.
Only the outermost actor is read. Prior actors in a nested chain stay audit data, never a policy input (RFC 8693 section 4.1), which is the rule actor already follows. Walking the chain would undo the intersection delegate() applies at every hop and let authority accumulate with delegation depth, and accumulating authority fails open where missing authority fails closed. A route raising maxDelegationDepth to 2 or more therefore gets deeper delegation but still reads only the outermost actor's scopes; the intermediate agents' rings are deliberately unreachable.
It never applies to roles. A role is what the principal IS; scopes are what a keyring CARRIES, and only keyrings are inheritable. An agent driving a request does not become the subject, so no flag combination makes a role check read the actor's roles.
The actor has to carry scopes. An actor minted by delegate() does. An actor parsed from a token's RFC 8693 act claim does not: the claim has no scope member, and the parser will not invent authority from an unstandardised one. For token-borne delegation, map whatever your IdP emits onto the actor with ClaimMappers.actor, or the check reads an empty ring and refuses.
With actor: 'none' it does nothing. That default admits no actor, so the flag has nothing to read. The combination is a documented no-op rather than a build-time error: it is a harmless misconfiguration, and a route that means to admit an agent has to say so on actor anyway.
What the agent lends
Widening a check to the actor's ring adds the agent's standing scopes to the subject's: the check reads the union, so a caller passes on their own scopes or on the agent's. The agent is not a cap, here or in delegate(), which deliberately does not intersect delegated scopes with the actor's own.
What an agent's grant does bound is the additional authority a caller gains by going through it, and that is where this moves the control: keeping an agent's own grant narrow stops being tidiness and becomes the limit on what it can lend.
It also means effective: true belongs with a named actor matcher rather than actor: 'any'. Paired with 'any', every agent that can reach the route lends its ring to whoever drives through it, so what can be lent becomes the widest grant among them rather than one agent's.
This is why effective is opt-in per route rather than a context-wide default. A scope meaning "the holder's own rows" cannot be lent across principals, since the agent has no rows for it to resolve against, so that variant must keep reading the subject's ring alone even where its siblings read both.
Failure modes:
- No principal on the exchange: throws
RC5012. The source did not authenticate (noauth:configured) and no.authenticate()step ran before the check. - Principal not authentic (self-asserted object): throws
RC5023. - Actor present but not admitted (or required but absent): throws
RC5034. - Subject constraint failed: throws
RC5035. - Delegation chain deeper than
maxDelegationDepth: throwsRC5036. - Missing role or failed predicate: throws
RC5015. Permanent: no ceremony changes who the subject is. - Missing scope: throws
RC5038. Recoverable: the cause carriesmissing.scopesso a consent flow can request exactly what is absent, andmissing.modesays how to read it."all"lists required scopes that are absent, every one needed;"any"lists the whole accepted set of ananyScopecheck, of which one suffices, so a consent flow offers the choice instead of requesting all of them. - An empty
anyScope: throwsRC2001when the validator is built, before any request. An accepted set naming nobody admits nobody, so it is refused rather than read as no check.
All codes flow through the route's normal error path: .error() handles them like any other validation failure; without .error(), route:exchange:failed fires.
Breaking change: delegation is opt-in per route
The actor default is 'none'. Routes written before delegation existed keep exactly their old behavior for direct callers, but a principal carrying an actor (minted by .delegate() or parsed from a token's act claim) is rejected with RC5034 until the route declares its permitted actor(s). This is deliberate: a capability is not agent-reachable unless it says so.
Because stacked .authorize() calls AND-combine, every guard on a route carries its own actor default. Adding .authorize({ actor: 'any' }) next to an existing .authorize({ roles: ['admin'] }) still fails with RC5034, since the first guard rejects the actor before the second runs. Put the actor clause on the guard that needs it, or fold the guards into one.
import { craft, mcp } from '@routecraft/routecraft'
// Route-entry guard: authentication at the source boundary,
// authorization declared on the route.
craft()
.id('delete-user')
.description('Delete a user by id')
.authorize({ roles: ['admin'] })
.from(mcp({ annotations: { destructiveHint: true } }))
.to(deleteUserDestination)
// Stacked authorizers (AND-combined; first failure short-circuits)
craft()
.id('billing-admin')
.authorize({ roles: ['admin'] })
.authorize({ scopes: ['billing:write'] })
.from(http({ path: '/admin/billing', method: 'POST' }))
.to(billingDestination)
// Delegation-aware declarations: the same grammar covers a person
// acting directly, an agent acting on a person's behalf, and an agent
// acting under its own standing authority.
// Humans only; never reachable through delegation (this is the default).
craft()
.id('delete-invoice')
.authorize({ roles: ['finance'], actor: 'none' })
.from(mcp({ annotations: { destructiveHint: true } }))
.to(deleteInvoice)
// A member directly, OR one named agent acting for a member.
craft()
.id('send-reply')
.authorize({
roles: ['member'],
scopes: ['mail:send'],
actor: ['none', { subject: 'agent:zoe', issuer: 'https://agents.example.com' }],
})
.from(direct())
.to(mail())
// Autonomous agents only (e.g. a cron-triggered heartbeat).
craft()
.id('write-daily-note')
.authorize({ subject: { profile: 'ai_agent' }, actor: 'none' })
.from(direct())
.to(writeNote)
// Mid-pipeline check: route mints a principal from an inbound email
// with .authenticate() and authorizes it. authorize() trusts only
// principals minted this way (or attached by a source verifier); a
// plain object written to the principal header is rejected (RC5023).
import { authorize } from '@routecraft/routecraft'
craft()
.from(mail('INBOX', { /* ... */ }))
.authenticate((ex) => {
// The mail source puts the sender on a header, not the body.
const from = ex.headers['routecraft.mail.from']
return {
scheme: 'email',
subject: from ?? 'anonymous',
email: from,
claims: { tenant: deriveTenant(from) },
}
})
.validate(authorize({
predicate: (p) => p.email?.endsWith('@yourcompany.com') === true,
}))
.to(yourDestination)