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.

Declarations
#

22 declarations

view source

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; }, acting_value: string | undefined): Promise<...>

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-validation 401 โ†’ input validation 400 โ†’ authorization phase โ†’ post-authorization 403.

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-validation 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; }

acting_value

type string | undefined

returns

Promise<AuthorizationResult>

AUTH_SESSION_TOKEN_HASH_KEY
#

auth/request_context.ts view source

"auth_session_token_hash"

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

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 fuz_app/CLAUDE.md ยง Cleanest architecture takes priority for the rationale.

AuthorizationResult
#

auth/request_context.ts view source

AuthorizationResult

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>

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>

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>

Create the route-spec authorization handler used by apply_route_specs.

Reads acting off c.var.validated_input (or c.var.validated_query for GET routes) โ€” input validation runs first, so the authorization phase consumes the typed Zod field instead of pre-parsing the body. 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 reading from c.var.validated_input.acting / c.var.validated_query.acting is type-safe.

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, log: Logger, session_context_key?: string): MiddlewareHandler

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)

log

the logger instance

type Logger

session_context_key

the Hono context key where session middleware stored the session token

type string
default 'auth_session_id'

returns

MiddlewareHandler

get_request_context
#

auth/request_context.ts view source

(c: Context<any, any, {}>): RequestContext | null

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

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

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

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>

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
#

RequestActorContext
#

auth/request_context.ts view source

RequestActorContext

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

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>

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

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. 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

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

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

resolve_acting_actor
#

auth/request_context.ts view source

(deps: QueryDeps, account_id: string, acting_actor_id: string | undefined): Promise<ResolveActingActorResult>

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
#

Depends on
#

Imported by
#