actions/action_rpc.ts

Single JSON-RPC 2.0 endpoint from action specs.

create_rpc_endpoint produces RouteSpec[] (GET + POST on one path) with an internal dispatcher. Method name lives in the JSON-RPC envelope (POST body or GET query string), not the URL. Auth is checked per-action inside the dispatcher.

Handler signature: (input: TInput, ctx: ActionContext) => TOutput where ActionContext provides auth identity, DB, and framework context.

Declarations
#

11 declarations

view source

ActionActorContext
#

actions/action_rpc.ts view source

ActionActorContext

ActionContext narrowed to a resolved acting actor.

Used by handlers whose spec declares auth.actor === 'required' — the dispatcher's authorization phase resolves an actor (per registry-time invariant 2 the input declares acting?: ActingActor), so ctx.auth.actor is non-null. Selected automatically by rpc_action's conditional return type for the actor-implying tier.

inheritance

extends:
  • Omit<ActionContext, 'auth'>

auth

ActionAuthContext
#

actions/action_rpc.ts view source

ActionAuthContext

ActionContext narrowed to a non-null RequestContext.

Used by handlers whose spec declares auth.account === 'required' (with auth.actor === 'none') — the dispatcher's pre-validation 401 gate guarantees request_context is populated before the handler runs, but the actor slot stays null because no acting resolution happened. Selected automatically by rpc_action's conditional return type for the account-grain tier.

inheritance

extends:
  • Omit<ActionContext, 'auth'>

auth

ActionContext
#

actions/action_rpc.ts view source

ActionContext

Per-request context provided to action handlers across every transport (HTTP RPC, WebSocket, REST bridge). Built once per dispatched action by perform_action and threaded into the handler.

auth is RequestContext | null — handlers for authenticated actions can narrow via the dispatcher's authorization-phase guarantee.

Single handler context shape across every transport. Consumers inject domain deps via factory closures the same way HTTP RPC factories do.

auth

The authenticated identity, or null for public routes.

type RequestContext | null

request_id

The JSON-RPC request ID from the envelope.

connection_id

Stable per-socket connection id on WebSocket transport; undefined on HTTP RPC. Consumers key per-connection domain state on this directly; HTTP handlers ignore it.

type Uuid

db

Transaction-scoped when spec.side_effects is true (the dispatcher wraps in db.transaction); pool-level otherwise. Handlers that need rollback-resilient writes call deps.audit.emit(ctx, input), which captures the pool inside its closure.

type Db

pending_effects

Eager fire-and-forget queue — push the in-flight Promise<void> for pool writes already running (audit emits, session touch, api-token usage tracking). Drained via flush_pending_effects after the handler returns.

type Array<Promise<void>>

post_commit_effects

Deferred post-commit thunks — do not push directly; reach for emit_after_commit(ctx, fn) from pending_effects.ts. The flush site invokes each thunk after the handler (and any wrapping db.transaction) returns.

type Array<() => void | Promise<void>>

client_ip

Resolved client IP from the trusted-proxy middleware — 'unknown' if the middleware wasn't in the stack (e.g. WS dispatch) or couldn't resolve. Thread into deps.audit.emit as ip: ctx.client_ip for every user-initiated action so RPC audit rows match the REST convention. Pass null only for rows written outside a request (e.g. the role_grant_offer_expire cleanup sweep in auth/cleanup.ts).

type string

credential_type

Credential channel the request arrived on ('session' | 'api_token' | 'daemon_token'), or null for anonymous requests. Same value the dispatcher's credential_types gate consumed at step 4 — exposed here so handlers can record it in audit metadata (defense in depth: the gate may be loosened or bypassed in a future refactor, but the audit row preserves what actually authenticated the request).

type CredentialType | null

log

Logger instance.

type Logger

notify

Send a request-scoped JSON-RPC notification to the originator.

On streaming transports (WebSocket) this routes to the originating connection only. On the HTTP RPC transport this is a no-op with a DEV-mode warn — non-streaming transports have no channel for mid- request notifications. The streams field on an ActionSpec names the notification method this handler is expected to emit.

type (method: string, params: unknown) => void

signal

AbortSignal that fires when the originating request is cancelled (client disconnect on HTTP, socket close or per-request cancel notification on WebSocket). Streaming handlers should check this for early termination.

type AbortSignal

ActionHandler
#

actions/action_rpc.ts view source

ActionHandler<TInput, TOutput>

Handler function for an RPC action.

Receives validated input and an ActionContext with per-request deps. Returns the output value (serialized to JSON by the wrapper).

generics

TInput

default any

TOutput

default any

ActorActionHandler
#

AuthActionHandler
#

actions/action_rpc.ts view source

AuthActionHandler<TInput, TOutput>

Handler signature for an account-grain RPC action — auth.account === 'required' and auth.actor === 'none'. Mirrors ActionHandler but tightens the ctx.auth slot to the non-null RequestContext (with actor: null).

generics

TInput

default any

TOutput

default any

create_rpc_endpoint
#

actions/action_rpc.ts view source

(options: CreateRpcEndpointOptions): RouteSpec[]

Single JSON-RPC 2.0 endpoint — the canonical RPC transport binding.

Returns two RouteSpec entries (GET + POST on the same path) for apply_route_specs. The internal dispatcher handles:

1. Parse envelope — POST: JSON body as JsonrpcRequest. GET: method and params from query string. 2. Lookup method — find the RpcAction by method name. 3. Pre-validation auth — short-circuit unauthenticated when no account is on the request, before input validation runs. 4. Authorization phase — resolve the acting actor (when the action's auth requires role_grants or its input declares acting?: ActingActor) and build the request context. Runs before input validation so role-grant-grain auth checks return 403 before 400 invalid_params; acting is read from raw params via a string typeguard. 5. Post-authorization auth — enforce role / keeper requirements against the request context. 6. Validate params — parse input against the action's input schema. 7. Rate limit — per-action IP / account throttling. 8. Dispatch — acquire DB handle (transaction for mutations, pool for reads), construct ActionContext, call handler, return JSON-RPC response.

GET is restricted to side_effects: false actions (cacheable reads). All errors use JSON-RPC format: {jsonrpc, id, error: {code, message, data?}}.

The RouteSpecs use auth: {type: 'none'} because auth is checked per-action inside the dispatcher, and transaction: false because transaction scope is per-action (mutations get a transaction, reads get pool).

options

endpoint path, actions, and logger

returns

RouteSpec[]

route specs (GET + POST) ready for apply_route_specs

throws

  • Error - if two actions share the same `spec.method` (registration-time

CreateRpcEndpointOptions
#

actions/action_rpc.ts view source

CreateRpcEndpointOptions

Options for create_rpc_endpoint.

path

Mount path for the endpoint (e.g., /api/rpc).

type string

actions

RPC actions to serve.

type Array<RpcAction>

log

Logger instance for handler context.

type Logger

action_ip_rate_limiter

Per-IP rate limiter consulted for actions whose spec declares rate_limit: 'ip' or 'both'. null disables the IP check. Per-action gate via action.spec.rate_limit. Same limiter is shared with the WebSocket action dispatcher — one budget per action, not per transport.

type RateLimiter | null

action_account_rate_limiter

Per-account rate limiter consulted for actions whose spec declares rate_limit: 'account' or 'both'. Keyed on request_context.account.id (account-grain — billed to the authenticated account regardless of which actor was resolved). null disables the account check. Same limiter is shared with the WebSocket action dispatcher.

type RateLimiter | null

HandlerForSpec
#

actions/action_rpc.ts view source

HandlerForSpec<TSpec>

Conditional handler shape for rpc_action — picks the narrowest ctx.auth type the dispatcher's runtime guarantee allows:

- auth.actor === 'required' → ActorActionHandler (ctx.auth: RequestActorContext). - auth.account === 'required' && auth.actor === 'none' → AuthActionHandler (ctx.auth: RequestContext). - else (public, optional axes) → ActionHandler (ctx.auth: RequestContext | null).

The bracketed form [T] extends ['required'] defeats distributive conditionals so a degraded AuthAxisState union (when the spec was typed without preserving its literal) falls through to the loosest tier instead of collapsing to the narrowest.

generics

TSpec

rpc_action
#

actions/action_rpc.ts view source

<TSpec extends RequestResponseActionSpec>(spec: TSpec, handler: HandlerForSpec<TSpec>): RpcAction

Pair a spec with a handler while preserving per-method input/output types and selecting the narrowest ctx.auth shape the spec literal admits.

Constructing {spec, handler} literals widens handler to ActionHandler<any, any>, so spec/handler drift (renamed Zod schema, output field removal, input shape change) slips past the typechecker. rpc_action(spec, handler) binds the handler signature to (input: z.infer<spec.input>, ctx) => z.infer<spec.output> via the generic spec parameter — drift surfaces at the call site.

The ctx.auth narrowing follows the spec's auth.account / auth.actor literals (see HandlerForSpec): an actor-implying spec gets ctx.auth: RequestActorContext; an account-grain spec gets ctx.auth: RequestContext; everything else stays `ctx.auth: RequestContext | null`. Handlers can rely on the dispatcher's runtime guarantee without a manual narrowing call.

Fits fuz_app's factory-closure pattern (handlers close over grantable_roles, app_settings ref, notification_sender, etc.). zzz uses a different shape — a codegen-keyed Record<Method, Handler> map typed via generated ActionInputs/ActionOutputs — which works when handlers are pure (no closure state) and specs are codegen-enumerated. fuz_app's admin + role-grant-offer actions have neither, so per-pair typing at the registration site is the right fit.

Spec-literal preservation is load-bearing: declare specs with satisfies RequestResponseActionSpec (canonical) so auth.actor keeps its 'required' / 'none' literal type. A spec typed directly as RequestResponseActionSpec widens the axes to AuthAxisState and the handler defaults to the loosest tier — sound, but loses the ergonomic narrowing.

spec

type TSpec

handler

type HandlerForSpec<TSpec>

returns

RpcAction

examples

// actor-implying spec → ctx.auth: RequestActorContext rpc_action(role_grant_revoke_action_spec, async (input, ctx) => { const revoker_id = ctx.auth.actor.id; // no narrowing needed }); // account-grain spec → ctx.auth: RequestContext (actor: null) rpc_action(account_verify_action_spec, (_input, ctx) => { return to_session_account(ctx.auth.account); // no narrowing needed });

RpcAction
#

Depends on
#

Imported by
#