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:

FieldTypeDescription
rolesstring[]Required roles, checked on the SUBJECT (roles pass through delegation unchanged). The principal must carry every listed role. AND-combined.
scopesstring[]Required scopes, checked on the subject's own scopes as narrowed by delegation (intersected at every hop). The principal must carry every listed scope. AND-combined.
anyScopestring[]Accepted scopes, any ONE of which admits the principal (OR), where scopes requires every one (AND). Given both, they compose as an AND of the two conditions. An empty array is refused with RC2001 when the validator is built, since an accepted set naming nobody admits nobody.
effectivebooleanWhether scopes and anyScope may also be satisfied from the OUTERMOST actor's scopes. Default false. Never applies to roles. A no-op under the default actor: 'none'.
subjectSubjectMatcher | (p: Principal) => booleanConstrain whose authority is exercised: subject id(s), issuer, and/or entity profile (user / service / ai_agent).
actorActorSpecConstrain who is driving. Default 'none': any delegated principal is rejected until the route admits an actor. 'any', an ActorMatcher, an array (OR, may include 'none'), or a predicate (actor, subject) => boolean. Matches the OUTERMOST actor only (RFC 8693 section 4.1); nested prior actors are audit data.
maxDelegationDepthnumberMaximum actor-chain length once an actor is admitted at all. Default 1 (one hop; agent-to-sub-agent chains rejected).
predicate(p: Principal) => booleanCustom check. Runs after the built-in checks. Return false to reject.
clockToleranceSecnumberClock skew tolerance for the expiresAt check. Default 0.

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.

Warning

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 (no auth: 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: throws RC5036.
  • Missing role or failed predicate: throws RC5015. Permanent: no ceremony changes who the subject is.
  • Missing scope: throws RC5038. Recoverable: the cause carries missing.scopes so a consent flow can request exactly what is absent, and missing.mode says how to read it. "all" lists required scopes that are absent, every one needed; "any" lists the whole accepted set of an anyScope check, of which one suffices, so a consent flow offers the choice instead of requesting all of them.
  • An empty anyScope: throws RC2001 when 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.

Warning

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)