auth/request_context.ts

Request context middleware and role_grant checking helpers.

Two-phase identity resolution:

  1. Authentication (middleware)create_request_context_middleware, bearer_auth, and daemon_token_middleware validate the credential (session cookie, bearer token, daemon token) and set c.var.account_id + c.var.credential_type on the Hono context. They do not resolve an acting actor or load role_grants; REQUEST_CONTEXT_KEY stays null at this stage, so account-grain identity is the only thing known.
  2. Authorization (route-spec wrapper / RPC dispatcher) — after input validation, the per-route layer inspects the route. If the input schema declared acting?: ActingActor (reference equality with the canonical ActingActor schema) or the auth requires role_grants (role / keeper), apply_authorization_phase resolves the actor against c.var.account_id plus the validated acting value via resolve_acting_actor, builds the {account, actor, role_grants} context via build_request_context, and sets it on REQUEST_CONTEXT_KEY before auth guards fire. Authenticated routes that don't need an actor still get an account-only context via build_account_context so handler signatures stay uniform.

Account-grain operations (logout, password_change, account_verify, etc.) declare neither acting nor role_grant-requiring auth, so no actor is resolved and their handlers see a RequestContext with actor: null + empty role_grants. They never trigger actor_required, which is what makes multi-actor logout work without first picking a persona.

build_request_context loads account → actor → role_grants and verifies the actor.account_id === account.id binding. refresh_role_grants reloads role_grants on an existing context.

view source

Declarations
#

24 declarations

apply_authorization_phase
#

auth/request_context.ts view source

(deps: QueryDeps, account_id: string | null, auth: { account: "none" | "optional" | "required"; actor: "none" | "optional" | "required"; roles?: readonly string[] | undefined; credential_types?: readonly string[] | undefined; required_scope?: string | undefined; }, acting_value: string | undefined): Promise<...> import {apply_authorization_phase} from '@fuzdev/fuz_app/auth/request_context.js';

Apply the dispatcher's authorization phase against the flat-record RouteAuth shape. Shared by the route-spec wrapper, the HTTP RPC dispatcher, and the per-message WS dispatcher. Phase order: pre-authorization 401 → authorization phase → post-authorization 403 → input validation 400.

Pure data — the function does not touch a Hono context. Each transport passes account_id (extracted from its own credential surface) and binds the returned AuthorizationResult to its wire shape. The REST pipeline additionally writes REQUEST_CONTEXT_KEY on c for downstream require_role / require_credential_types middleware that still reads the resolved context off the Hono context.

Branching by auth.account × auth.actor:

  • Both 'none'{ok: true, request_context: null}. Public actions never see a RequestContext.
  • account_id == null on any non-public route → same null request_context. The 'required' callers were already rejected at the pre-authorization gate in the dispatcher; only genuine anonymous access on an 'optional' axis lands here.
  • actor === 'none' → builds account-only context via build_account_context. Null lookup → account_vanished 500 failure.
  • actor === 'required' → resolves the actor from acting_value (or single-actor account); failures map to 400 / 500.
  • actor === 'optional' → same as 'required' except multi-actor accounts without an acting value fall back to account-only context (no actor_required 400). Bad acting ids still 400.

500 branches stay distinct: ERROR_NO_ACTORS_ON_ACCOUNT (signup invariant violation), ERROR_ACCOUNT_VANISHED (torn read after resolve).

deps

account_id

type string | null

auth

type { account: "none" | "optional" | "required"; actor: "none" | "optional" | "required"; roles?: readonly string[] | undefined; credential_types?: readonly string[] | undefined; required_scope?: string | undefined; }

acting_value

type string | undefined

returns

Promise<AuthorizationResult>

AUTH_SESSION_TOKEN_HASH_KEY
#

auth/request_context.ts view source

"auth_session_token_hash" import {AUTH_SESSION_TOKEN_HASH_KEY} from '@fuzdev/fuz_app/auth/request_context.js';

Hono context variable name for the authenticated session token hash.

Set by create_request_context_middleware after a successful session lookup. null when the request is unauthenticated or authenticated via a non-session credential (bearer token, daemon token). Exposed so handlers can scope per-session resources (e.g., SSE stream identity for targeted disconnection on session_revoke) without re-hashing the token.

AuthorizationFailureBody
#

auth/request_context.ts view source

AuthorizationFailureBody import type {AuthorizationFailureBody} from '@fuzdev/fuz_app/auth/request_context.js';

Resolution-failure shape returned by apply_authorization_phase. Each transport binds this to the appropriate wire shape — REST emits the body directly via c.json(body, status); the RPC dispatcher folds it into a JSON-RPC error envelope {jsonrpc, id, error: {code, message, data}}.

The auth phase deliberately stops short of constructing a Response so the same failure flows through every transport without the auth-domain code knowing about JSON-RPC. See ../../../CLAUDE.md §Cleanest architecture takes priority for the rationale.

AuthorizationResult
#

auth/request_context.ts view source

AuthorizationResult import type {AuthorizationResult} from '@fuzdev/fuz_app/auth/request_context.js';

Result of the authorization phase. Pure data — the auth domain stops short of touching the Hono context or producing a Response so HTTP RPC, WS, and REST each bind the same shape to their wire surface.

  • {ok: true, request_context}request_context is non-null on resolved (actor-bound or account-only) outcomes; null for public actions ({account: 'none', actor: 'none'}) and for genuine anonymous access on an 'optional' axis. Public and unauthenticated collapse to the same null request_context; every transport already treated them identically.
  • {ok: false, status, body} — 400/500 failure. status is narrowed to the two values the auth phase emits, so Hono's c.json status overload accepts the literals directly. The 500 reasons stay distinct in body: no_actors_on_account (signup invariant violation); account_vanished (torn read after resolve).

build_account_context
#

auth/request_context.ts view source

(deps: QueryDeps, account_id: string): Promise<RequestContext | null> import {build_account_context} from '@fuzdev/fuz_app/auth/request_context.js';

Build an account-only RequestContext (no actor, no role_grants) from an account id.

Used by the dispatcher's authorization phase for authenticated routes that don't need an acting actor — account-grain operations (logout, password change, account self-service). Lets handlers read auth.account.id / auth.account.username uniformly with role_grant-bound routes; the cost is one extra query_account_by_id per request.

Returns null when the account row is missing (e.g. deleted between the auth middleware's session lookup and the dispatcher) — caller surfaces that as a 500 since it represents a torn read.

deps

query dependencies

account_id

the account to build context for

type string

returns

Promise<RequestContext | null>

an account-only request context, or null if the account is missing

build_request_context
#

auth/request_context.ts view source

(deps: QueryDeps, account_id: string, actor_id: string): Promise<RequestActorContext | null> import {build_request_context} from '@fuzdev/fuz_app/auth/request_context.js';

Build a full RequestContext from an account id and an explicit actor id (already resolved via resolve_acting_actor).

Loads account + the named actor + the actor's active role_grants. Verifies the actor.account_id === account.id binding so downstream handlers can trust ctx.actor.account_id === ctx.account.id. Returns null when the account is missing, the actor is missing, or the actor doesn't belong to the supplied account.

Called by the route-spec / RPC dispatcher's authorization phase for routes that need an acting actor; account-grain routes use build_account_context instead.

deps

query dependencies

account_id

the account to build context for

type string

actor_id

the actor this request acts as

type string

returns

Promise<RequestActorContext | null>

a request context, or null if account/actor not found or mismatched

create_fuz_authorization_handler
#

auth/request_context.ts view source

(deps: QueryDeps): (c: Context<any, any, {}>, spec: RouteSpec) => Promise<void | Response> import {create_fuz_authorization_handler} from '@fuzdev/fuz_app/auth/request_context.js';

Create the route-spec authorization handler used by apply_route_specs.

Reads the acting selector via read_route_actingc.var.validated_query on GETs, the raw body on mutations, since input validation now runs after the authority gates. Public routes (`auth.account === 'none' && auth.actor === 'none'`) skip the phase entirely.

Per registry-time invariant 2, auth.actor !== 'none' ⟺ the input (or query) schema declares acting?: ActingActor — so the selector is present on exactly the specs that read it, and input validation is what rejects a malformed one.

Resolved contexts land on REQUEST_CONTEXT_KEY so the post-authorization REST middleware (require_role, require_credential_types) reads the actor-bound context off c.var. The HTTP RPC and WS dispatchers consume the apply_authorization_phase outcome directly without round-tripping through c.var.

deps

returns

(c: Context<any, any, {}>, spec: RouteSpec) => Promise<void | Response>

create_request_context_middleware
#

auth/request_context.ts view source

(deps: QueryDeps, session_context_key?: string): MiddlewareHandler import {create_request_context_middleware} from '@fuzdev/fuz_app/auth/request_context.js';

Create middleware that authenticates the account from a session cookie.

Reads the session identity (set by session middleware), looks up the auth_session, and on a valid session sets c.var.auth_account_id, CREDENTIAL_TYPE_KEY = 'session', and AUTH_SESSION_TOKEN_HASH_KEY. Touches the session (fire-and-forget). Does not load actor or role_grants; REQUEST_CONTEXT_KEY is left null — the route-spec / RPC dispatcher authorization phase resolves the acting actor and builds the full RequestContext when the route needs one.

Invalid / missing session leaves all keys null and calls next()require_auth / require_role enforce.

deps

query dependencies (pool-level db for middleware)

session_context_key

the Hono context key where session middleware stored the session token

type string
default 'auth_session_id'

returns

MiddlewareHandler

mutates

  • Hono — context - sets `ACCOUNT_ID_KEY`, `CREDENTIAL_TYPE_KEY`, `AUTH_SESSION_TOKEN_HASH_KEY`, and `AUTH_API_TOKEN_ID_KEY`

get_request_context
#

auth/request_context.ts view source

(c: Context<any, any, {}>): RequestContext | null import {get_request_context} from '@fuzdev/fuz_app/auth/request_context.js';

Get the request context from a Hono context, or null if unauthenticated.

c

the Hono context

type Context<any, any, {}>

returns

RequestContext | null

the request context, or null

has_any_scoped_role
#

auth/request_context.ts view source

(ctx: RequestContext | null, roles: readonly string[], scope_id: string | null, now?: Date): boolean import {has_any_scoped_role} from '@fuzdev/fuz_app/auth/request_context.js';

Whether the request context holds an active role_grant for any role in roles at scope_id. Empty roles short-circuits to false — documents intent at the call site ("zero roles trivially admit no-one"). Same scope and null-tolerance semantics as has_scoped_role.

ctx

the request context, or null for unauthenticated callers

type RequestContext | null

roles

the roles that would admit the caller (any-of)

type readonly string[]

scope_id

the scope to check (null for global)

type string | null

now

current time (defaults to new Date(), pass for testability)

type Date
default new Date()

returns

boolean

true iff the actor holds an active role_grant for any role in roles at the requested scope

has_role
#

auth/request_context.ts view source

(ctx: RequestContext | null, role: string, now?: Date): boolean import {has_role} from '@fuzdev/fuz_app/auth/request_context.js';

Check if a request context has an active role_grant for a given role.

Checks the role_grants already loaded in the context (no DB query). Null-tolerant — null ctx (unauthenticated) returns false. Symmetric with has_scoped_role / has_any_scoped_role so the three helpers compose freely in the same predicate (e.g. has_role(auth, ADMIN) || has_scoped_role(auth, role, scope)).

ctx

the request context, or null for unauthenticated callers

type RequestContext | null

role

the role to check

type string

now

current time (defaults to new Date(), pass for testability and hot-path efficiency)

type Date
default new Date()

returns

boolean

true if the actor has an active role_grant for the role

has_scoped_role
#

auth/request_context.ts view source

(ctx: RequestContext | null, role: string, scope_id: string | null, now?: Date): boolean import {has_scoped_role} from '@fuzdev/fuz_app/auth/request_context.js';

Whether the request context holds an active role_grant for role at scope_id.

Walks the in-memory ctx.role_grants snapshot loaded once per request by the route-spec / RPC dispatcher's authorization phase (when the route declares acting?: ActingActor or has role_grant-requiring auth); zero DB roundtrip per check. The "freshness" framing of a SQL re-query is illusory because the race window is between predicate and the actual mutation, not predicate and authorization load. Closing that race needs a transactional re-check inside the UPDATE/INSERT, which neither style provides.

Null-tolerant — null ctx (unauthenticated) and account-grain contexts (actor: null, empty role_grants) both return false. Same convention as has_role; lets the helper drop into public ({account: 'none', actor: 'none'}) and account-grain ({account: 'required', actor: 'none'}) handlers without a manual narrow. See cell_authorize for the resource-side analog.

scope_id semantics: in-memory role_grant.scope_id is string | null, so JS === matches the SQL IS NOT DISTINCT FROM semantics exactly:

  • scope_id === null matches global role_grants (scope_id IS NULL).
  • scope_id === '<uuid>' matches role_grants bound to that exact scope.

ctx

the request context, or null for unauthenticated callers

type RequestContext | null

role

the role to check

type string

scope_id

the scope to check (null for global)

type string | null

now

current time (defaults to new Date(), pass for testability and hot-path efficiency)

type Date
default new Date()

returns

boolean

true iff the actor holds an active role_grant for the role at the requested scope

refresh_role_grants
#

auth/request_context.ts view source

(ctx: RequestContext, deps: QueryDeps): Promise<RequestContext> import {refresh_role_grants} from '@fuzdev/fuz_app/auth/request_context.js';

Reload active role_grants from the database, returning a new request context.

Useful for long-lived WebSocket connections where role_grants may change (grant or revoke) during the connection lifetime. Call periodically or after receiving a revocation signal.

Returns a new RequestContext with updated role_grants — the original context is not mutated, making concurrent calls safe. Throws when ctx.actor is null; account-grain contexts have no role_grants to refresh.

ctx

the request context to refresh

deps

query dependencies

returns

Promise<RequestContext>

a new RequestContext with fresh role_grants

throws

  • Error - when called on an account-grain context (`actor: null`)

REQUEST_CONTEXT_KEY
#

auth/request_context.ts view source

"request_context" import {REQUEST_CONTEXT_KEY} from '@fuzdev/fuz_app/auth/request_context.js';

Hono context variable name for the request context.

RequestActorContext
#

auth/request_context.ts view source

RequestActorContext import type {RequestActorContext} from '@fuzdev/fuz_app/auth/request_context.js';

Request context narrowed to a resolved acting actor.

Used by handlers bound through rpc_action against an actor-implying spec (auth.actor === 'required') — the binder's conditional return type tightens ctx.auth to this shape because the dispatcher's authorization phase always resolves an actor before the handler runs. The biconditional actor !== 'none' ⟺ input declares acting?: ActingActor is enforced at registry time.

inheritance

actor

type Actor

RequestContext
#

auth/request_context.ts view source

RequestContext import type {RequestContext} from '@fuzdev/fuz_app/auth/request_context.js';

The resolved identity context for an authenticated request.

actor is null on account-grain routes (no acting field on input, no role / keeper auth) — those handlers don't trigger actor resolution. role_grants is empty in that case. Role grant checks (has_role, has_scoped_role, has_any_scoped_role) are null-tolerant on RequestContext | null; they additionally treat actor: null as "no role_grants" so callers don't have to narrow.

Multi-actor invariant: when populated, actor.account_id === account.id. build_request_context enforces this; the dispatcher's authorization phase rejects with actor_not_on_account before reaching the handler.

account

type Account

actor

type Actor | null

role_grants

type Array<RoleGrant>

require_auth
#

auth/request_context.ts view source

(c: Context<any, string, {}>, next: Next): Promise<void | Response> import {require_auth} from '@fuzdev/fuz_app/auth/request_context.js';

Middleware that requires authentication.

Returns 401 if the auth middleware did not set c.var.auth_account_id.

c

type Context<any, string, {}>

next

type Next

returns

Promise<void | Response>

require_credential_types
#

auth/request_context.ts view source

(credential_types: readonly string[]): MiddlewareHandler import {require_credential_types} from '@fuzdev/fuz_app/auth/request_context.js';

Create middleware that requires the request's credential_type to be one of the given values.

Returns 401 if unauthenticated, 403 with ERROR_CREDENTIAL_TYPE_REQUIRED + required_credential_types echoing the spec's allowlist when the wire-side credential isn't in it.

Reads only ACCOUNT_ID_KEY + CREDENTIAL_TYPE_KEY, both set by the auth middleware — never the resolved RequestContext. That is why fuz_auth_guard_resolver mounts it in the pre_authorization phase: a wrong channel is refused before the route resolves an actor for it, so the denial costs no DB work and discloses no account state. Body shape is symmetric with the role gate (ERROR_INSUFFICIENT_PERMISSIONS + required_roles) and matches what the RPC dispatcher's post-auth gate emits for the same condition. Today's only credential gate is keeper (['daemon_token']); future gates (agent_token, group_actor_token) reuse this literal and label themselves through the array.

credential_types

allowed credential types (any-of)

type readonly string[]

returns

MiddlewareHandler

require_request_context
#

auth/request_context.ts view source

(c: Context<any, any, {}>): RequestContext import {require_request_context} from '@fuzdev/fuz_app/auth/request_context.js';

Get the request context, throwing if unauthenticated.

Use in route handlers where the dispatcher's authorization phase guarantees a context exists (i.e., routes with auth: {type: 'authenticated'} or stricter). Prefer this over get_request_context(c)! for explicit error handling.

c

the Hono context

type Context<any, any, {}>

returns

RequestContext

the request context (never null)

throws

  • Error - if no request context is set (dispatcher misconfiguration)

require_role
#

auth/request_context.ts view source

(roles: readonly string[]): MiddlewareHandler import {require_role} from '@fuzdev/fuz_app/auth/request_context.js';

Create middleware that requires the actor to hold any of the given roles globally (scope_id IS NULL).

Returns 401 if unauthenticated, 403 if none of the roles are present. Reads REQUEST_CONTEXT_KEY because role-gated routes always run the dispatcher's authorization phase before this guard (the phase sets the actor-bound RequestContext).

Uses has_any_scoped_role(ctx, roles, null) so the gate matches global / unscoped role_grants only. A scoped role_grant ({role: 'admin', scope_id: <some uuid>}) does not unlock route-spec gates that are inherently global. The same scope-aware check is mirrored in actions/action_rpc.ts (HTTP RPC dispatcher) and actions/register_action_ws.ts (WS dispatcher) so all three transports agree.

Multi-role disjunction (any-of) lets auth.roles: ['admin', 'steward'] specs translate to one middleware that admits either role. Single-role routes pass [role_name]; the array shape is uniform.

roles

the roles to admit (any-of)

type readonly string[]

returns

MiddlewareHandler

require_token_scope
#

auth/request_context.ts view source

(capability: string): MiddlewareHandler import {require_token_scope} from '@fuzdev/fuz_app/auth/request_context.js';

Create middleware refusing a route to a credential whose scope does not admit capability.

The route-spec form, mounted by fuz_auth_guard_resolver from auth.required_scope. Lands in the pre_authorization phase, so a narrowed token is told about its scope rather than about the role it also lacks — and learns nothing about the route's input shape on the way.

Parses at construction, so a malformed capability is a registration-time throw rather than a guard that mounts and reports nonsense.

capability

the capability string this route demands, e.g. surface:audit_stream

type string

returns

MiddlewareHandler

middleware that 403s a credential whose scope lacks it

throws

  • Error - if `capability` is not a well-formed `rpc:<method>` / `surface:<name>` string

resolve_acting_actor
#

auth/request_context.ts view source

(deps: QueryDeps, account_id: string, acting_actor_id: string | undefined): Promise<ResolveActingActorResult> import {resolve_acting_actor} from '@fuzdev/fuz_app/auth/request_context.js';

Resolve the acting actor for an authenticated request.

Called from the route-spec / RPC dispatcher's authorization phase with the authenticated account id and the validated acting value (from the request payload). Applies the uniform resolution rules:

  • acting_actor_id omitted + 1 actor → use it.
  • acting_actor_id omitted + 0 actors → no_actors (defensive — signup / bootstrap always create an actor in the same tx, so this is a server error).
  • acting_actor_id omitted + multiple actors → actor_required with the available list so the client can prompt; never pick silently.
  • acting_actor_id present + matches an actor on the account → use it.
  • acting_actor_id present + does not match → actor_not_on_account. The available list is intentionally not echoed in this branch (treat as opaque rejection).

deps

query dependencies

account_id

the authenticated account

type string

acting_actor_id

the requested acting actor id, or undefined

type string | undefined

returns

Promise<ResolveActingActorResult>

ResolveActingActorResult
#

token_scope_surface_denial
#

auth/request_context.ts view source

(c: Context<any, any, {}>, capability: string): Response | null import {token_scope_surface_denial} from '@fuzdev/fuz_app/auth/request_context.js';

Refuse a non-RPC surface to a narrowed token — rule 3, for callers that are not route specs. Under the Rust twin's name (token_scope_surface_denied_response).

The WS upgrade is the spine's one such caller: upgradeWebSocket's callback must return WSEvents and so cannot produce a denial, which is why the gate runs as pre-upgrade middleware calling this rather than as a route-spec declaration. A consumer hand-rolling its own upgrade is in the same position and reaches for the same function.

Takes the whole capability string, not a bare surface name — one spelling across route-spec declarations and direct calls alike, and the twin of the Rust signature. It needs no parse: the surface arm's identifier is pure label, since rule 3 never asks *which* surface. And it is deliberately not narrowed to the spine's four — the name decides nothing, so there is nothing for a closed set to protect, while a consumer surface would have nothing to pass. fuz_app's own uses are pinned two ways by the surface census: the exact call site, and the scan asserting every 'surface:<name>' literal in src/lib names a surface the spine actually mounts.

c

the request context, after the auth middleware chain

type Context<any, any, {}>

capability

the surface:<name> this route demands; names itself in the denial

type string

returns

Response | null

the 403 to return, or null when the caller may proceed

Depends on
#

Imported by
#