PhoenixKit.API.Executor (phoenix_kit v2.64.1)

Copy Markdown View Source

The request flow of the machine API: what happens between "this key is authentic" and "here is the response".

PhoenixKit.API.Auth has already done steps 1 to 3 of the flow (API switch, refusal of a credential in the URL, authentication). execute/4 and visible/2 do the rest and return their outcome; call/4 and list/2 are thin senders over them for the REST transport:

  1. Tool visible. The tool exists for this audience (404 not_found), its module is enabled (404 module_disabled), and the key's effective scopes include the tool's scope (403 forbidden). A tool listed in api_disabled_tools, or of a module listed in api_disabled_modules, does not exist for PhoenixKit.API.Registry, so it is not_found too.
  2. Rate limit, cost weighted (PhoenixKit.API.RateLimit, cost/2): a call costs 1 unit; only a write tool of the admin audience (not read-only) costs 1 per 50 rows of its longest top-level list, counted up to the tool's max_items (or, for a tool without one, up to 1000 rows) at most, so a request that is refused for size cannot use up the key's budget. Reads and public tools cost 1 however long their lists are. A tool that needs another price (1 per query of a search batch) declares it with Tool.cost. The public audience pays 1 unit of its network bucket BEFORE the tool lookup (a miss in PhoenixKit.API.Registry.find/2 costs about 2 ms, so a flood of unknown names must end in 429 without it). The caller's client token is read only after the lookup, because the tool declares which input field holds it (Tool.client_token): the call then pays its full price to the token's bucket (when it carries a token) and the rest of it (price minus 1) to the network bucket. So a call that the token's bucket refuses has already spent 1 unit of the network's. The admin audience is charged once, after the scope check, because its price depends on the tool. A call the lookup or the scope check refuses (unknown, hidden, forbidden or disabled-module tool) never reaches that charge, so it pays one unit instead: a key cannot loop on invented names for free. The answer is the same refusal, or 429 when the key's budget is spent; the refusal writes no audit entry.
  3. Validate the input against max_items and against the tool's input_schema closed by PhoenixKit.API.Validator.close_schema/2 (so a schema that forgot additionalProperties: false still refuses unknown fields, the control fields included). list/2 shows the closed schema (input_schema/1), the one execute/4 validates against. Before that, over REST the Idempotency-Key header of a write call is merged into the input (Flow.merge_header/1), so it is validated like the field; MCP takes the key from the argument only.
  4. Run the handler, apply(module, function, [input, ctx]).
  5. Log. A call to a write tool (annotations.read_only is not true) from a key writes one api.request Activity entry: tool, key prefix, row count, outcome. Calls refused before step 6 (unknown tool, missing scope, rate limited) write nothing, so a refused request costs no write; so does a replay of a stored answer. The entry's outcome is ok, error, preview (a dry run), confirmation_required (the call asked for a code) or pending_approval (queued for an approver). The module logs its own domain changes.
  6. Shape the response: 200 with the handler's map as JSON, or a problem document (PhoenixKit.API.Problem).

Steps 7 and 8 are PhoenixKit.API.Flow:

  1. Idempotency (Flow.idempotency/1): a write call with an Idempotency-Key claims it; a finished twin is answered from the store, a running twin is 409.
  2. Danger (Flow.gate/1): dry_run answers the preview; a dangerous operation answers a preview and a confirmation code, and with a valid code it runs; every write of a key that requires approval, dangerous or not, is queued for approval instead (a repeat of an operation that already waits gets the same approval back, marked with the replay header but not stored, and still audited).

Each step is one function taking and returning the request state, chained in execute/4. A step may also end the request with {:done, state}: the body is already set (a preview, a replay) and the handler does not run. The executor adds the control fields dry_run, confirm_token and idempotency_key to the schema of every write tool and closes every schema (PhoenixKit.API.ControlFields; a write tool with replay: false gets no idempotency_key). A read tool has none of them, so a client that sends one gets 422 validation_failed: a field that would change what a write does is refused, never silently ignored.

Scopes and work per request

PhoenixKit.API.Keys.verify/2 (called by PhoenixKit.API.Auth) has already computed the effective scopes of this request into actor.scopes (the key's scopes filtered by the owner's permissions right now, never cached). The executor trusts those scopes and does not compute them again: it would only repeat the owner lookup and the scope filtering. What a request still scans twice is the module registry (ModuleRegistry.all_api_tools/0: once inside verify/2 to know the scopes that exist, once in PhoenixKit.API.Registry.find/2); that is two walks over a handful of modules, and sharing the result would need an API change in Registry, so 1a accepts it.

The handler

A tool's handler returns {:ok, map}, {:ok, map, effects} (the effects list is added to the response under "effects") or {:error, code, message, context} (code is an atom from PhoenixKit.API.Problem.statuses/0: core's own codes plus those the module declares in PhoenixKit.Module.api_problems/0; an undeclared code answers 500). Anything else, and any exception, exit or throw, answers 500 internal_error with a generic message; the detail goes to the log, never to the client.

The context is a map:

  • :actor the PhoenixKit.API.Actor; :scopes its effective scopes;
  • :scope, the PhoenixKit.Users.Auth.Scope behind those scopes, for a handler that filters rows by the caller's rights: the key owner's full scope (Scope.for_user(owner, narrow: false)) for a key, the session's scope for a live session, nil for a public caller (a handler answers nothing it would filter by it). A key's own scopes do not narrow it: rows filtered by ctx.scope are the ones the owner sees, whatever the key's scopes;
  • :audience, :language, :currency, :hosts, :base_url (see PhoenixKit.API.RequestContext); :base_url is nil for a live session whose caller passed none;
  • :request_id;
  • :via, what asks on behalf of a live session: the :via option of execute_as/5 and redeem_as/6 ("session" by default, e.g. "agent"), the same value as the audit entry's metadata.via, for a module's own audit of the change. nil for a key, a public caller and an approved operation;
  • :dry_run?, true while the danger gate asks for a preview (the handler must then write nothing and answer {:preview, preview, fingerprint}, where fingerprint is a hash of the current values the operation would touch: 1 to 64 bytes of UTF-8, e.g. a SHA-256 in hex; any other answer is a 500);
  • :approved_by_uuid, the person who approved the operation (nil unless it runs from the approval queue), :confirmation_uuid, the confirmation code or approval row it runs under (nil for an ordinary call) and :progress, a one-argument function a long batch may call with its partial results. It also keeps an approved operation alive: one that runs longer than 15 minutes without calling it is marked failed (interrupted) by the stuck-operation sweep, so a long handler calls it at least every few minutes.

On behalf of a live session

visible_for/3, execute_as/5 and redeem_as/6 run the same flow in process, without a Plug.Conn, for a person's live browser session (PhoenixKit.Users.Auth.SessionRef, an Actor of kind :user). What differs from a key:

  • the scope is rebuilt from the session on every call (SessionRef.scope_for_session/2): a logged-out or revoked session, a blocked user, a user the web's account gate would stop (unconfirmed email, missing referral code), a session of another user, or a reference that is not a {session_token_uuid, user_uuid} pair is 401 unauthorized, and the scope is narrowed to the role the session acts as;
  • the effective scopes are PhoenixKit.API.Keys.held_scopes_of/1 of that scope, and the tool's own permission is checked too (Scope.can?/2); a tool without a permission (system.*, which describe the calling key) is not callable;
  • reads only by default: allow: :read refuses a write tool (PhoenixKit.API.ControlFields.write_tool?/1) before validation and before Flow.gate/1, so nothing runs, and visible_for/3 lists none;
  • allow: :all opens the write tools of the modules listed in api_session_write_modules (the "Agent may act" boxes of Settings -> API, none by default; PhoenixKit.API.Config.session_write_modules/0). A write tool of another module is not listed and is refused with 403 module_write_disabled, before anything runs or is stored. API keys never read that list;
  • a write never runs on the first call, dangerous or not: Flow.gate/1 answers the preview and holds the call in a confirmation row bound to the person (PhoenixKit.API.Confirm.issue_for_user/6), {"status": "confirmation_required", "confirmation_id", "preview", "expires_at", "expires_in_seconds"}, with no code. The person confirms with redeem_as/6, which repeats the whole path of the call with the same tool and arguments (see there). A confirm_token a session sends is refused;
  • the request context comes from the :request_context option, not from a connection, and the host's :api_request_context hook (which expects a Plug.Conn) is not called;
  • the rate limit is the user's own bucket (RateLimit.check_user/2), shared by the user's sessions; api_admin_enabled does not apply (it switches the HTTP API);
  • reads are not audited, as for a key. A write call that was charged writes one api.request Activity entry with resource_type: "user", the user as actor and resource, and metadata via (the :via option, "session" by default), tool, request_id, outcome, code and confirmation_uuid (for a refused redeem_as/6, the uuid it was asked for), plus replaces_confirmation_uuid when a row whose values changed was replaced: never the input, the preview or the result. The handler reads the same via as ctx.via;
  • maintenance mode is not checked on this path: the web shows the maintenance layout at mount, where nothing that calls the Executor is rendered.

The {session_token_uuid, user_uuid} pair is not a secret, but it is an in-process credential: whatever code passes it to execute_as/5 or visible_for/3 acts as that session. Take it only from the signed session of the LiveView that runs in that session (SessionRef.from_session/1 in its connected mount), never from params or client events.

Summary

Types

One admin tool as tool_availability/1 shows it.

Why tool_availability/1 shows a tool closed to a live session.

Response headers to send with an outcome: the rate-limit headers.

What execute/4 returns: the handler's result (the decoded map and its JSON encoding) or the error in PhoenixKit.API.Problem terms, each with the headers of the rate limit.

Functions

Runs tool tool_name for the actor PhoenixKit.API.Auth assigned, with input (the decoded request body), and sends the response.

Rate-limit units one call of tool with input costs (the cost is clamped).

Runs tool tool_name for the actor PhoenixKit.API.Auth assigned, with input, and returns the outcome instead of sending it. This is what call/4 does, minus the HTTP response, and what the MCP transport uses.

Runs tool tool_name with input on behalf of the live session session_ref ({session_token_uuid, user_uuid}), in process, and returns the outcome as execute/5 does. The session's scope is rebuilt for this call; see "On behalf of a live session" above for what differs from a key.

The input schema the executor enforces for tool: the tool's own schema, closed at the root with additionalProperties: false, with string keys, and for a write tool with the control fields (dry_run, confirm_token, idempotency_key). MCP's inputSchema and OpenAPI's request body are built from it, so a client is told exactly what the validator will accept. Nested objects retain the module's policy; declare additionalProperties: false on each nested object that must reject unknown fields.

Sends the list of tools the actor may call: name, title, description, scope, schemas and annotations. For the admin audience the list is filtered by the key's effective scopes.

The person confirms the write their session's execute_as/5 call was held for (confirmation_uuid, the "confirmation_id" of that answer). The caller brings the same tool_name and input again (without control fields: a dry_run, confirm_token or idempotency_key is refused with 422); the core keeps only their hash.

Every admin tool of every registered module, switched on or off, with whether a person's live session may call it (visible_for/3 and execute_as/5 with allow: :all) and, if not, why. Read-only: nothing is charged or written (no audit entry). API keys are not affected (they never read api_session_write_modules).

The tools the actor may call, as list/2 shows them, returned instead of sent. For the admin audience: the tools whose scope the key effectively holds, plus the scope-less ones (system.*); for the public audience: every public tool. Costs one rate-limit unit, like list/2.

The tools the live session session_ref ({session_token_uuid, user_uuid}, see PhoenixKit.Users.Auth.SessionRef) may call, for the admin audience: the read tools whose scope the session's scope holds (PhoenixKit.API.Keys.held_scopes_of/1) and whose permission it holds. No system.* tool. Costs one unit of the user's rate-limit bucket.

Types

availability()

@type availability() :: %{
  module_key: String.t() | nil,
  module_name: String.t(),
  name: String.t(),
  kind: :read | :write,
  permission: String.t(),
  scope: String.t(),
  danger: nil | :always | {module(), atom()},
  status: :available | {:closed, [closed_reason(), ...]}
}

One admin tool as tool_availability/1 shows it.

closed_reason()

@type closed_reason() ::
  :module_disabled
  | :api_off
  | :tool_hidden
  | :agent_writes_off
  | :no_permission

Why tool_availability/1 shows a tool closed to a live session.

headers()

@type headers() :: [{String.t(), String.t()}]

Response headers to send with an outcome: the rate-limit headers.

outcome()

@type outcome() ::
  {:ok, %{result: map(), json: binary()}, headers()}
  | {:error, atom(), String.t(), map(), headers()}

What execute/4 returns: the handler's result (the decoded map and its JSON encoding) or the error in PhoenixKit.API.Problem terms, each with the headers of the rate limit.

Functions

call(conn, audience, tool_name, input)

Runs tool tool_name for the actor PhoenixKit.API.Auth assigned, with input (the decoded request body), and sends the response.

cost(tool, input)

@spec cost(PhoenixKit.API.Tool.t() | nil, term()) :: pos_integer()

Rate-limit units one call of tool with input costs (the cost is clamped).

1 for everything, except a tool of the admin audience that is not read-only: it costs max(1, ceil(rows / 50)), where rows is the length of the longest top-level list in the input, clamped:

  • a tool with max_items: rows counts up to max_items at most, so the price is at most ceil(max_items / 50). A list above max_items is refused with 422, and a refused request must not use up the key's budget, so it is charged as a full batch, not as its real size;
  • a tool without max_items: rows counts up to 1000 at most, so the price is at most 20 units, for the same reason (the 2 MB limit of PhoenixKit.API.BodyGuard bounds the work of such a request).

So an admin read with a long fields list costs 1, a public call with a 50-line list costs 1 and an admin write of 120 rows costs 3. nil (the discovery list) and an input that is not a map cost 1.

A tool that declares cost: (PhoenixKit.API.Tool) is priced by it instead.

execute(conn, audience, tool_name, input, opts \\ [])

Runs tool tool_name for the actor PhoenixKit.API.Auth assigned, with input, and returns the outcome instead of sending it. This is what call/4 does, minus the HTTP response, and what the MCP transport uses.

Returns {:ok, %{result: map, json: binary}, headers} or {:error, code, detail, context, headers} (code is an atom of PhoenixKit.API.Problem.statuses/0; context is the map the problem document carries). headers are the rate-limit headers when the request got as far as the limiter, else []. The api.request audit entry of a write call is written here, before returning.

The option :resolve_name is a function from the name as the caller gave it to the name of the tool to look up. It runs after the public charge, in the lookup step, so a name that costs registry walks to resolve is not resolved for a caller the limiter has already refused.

The option :idempotency_header (default true) says whether the Idempotency-Key HTTP header is read. The REST transport reads it; MCP passes false, because an MCP client sends the key as the idempotency_key argument and a header on the transport request is not part of the call.

execute_as(session_ref, atom, tool_name, input, opts \\ [])

@spec execute_as(
  PhoenixKit.Users.Auth.SessionRef.t(),
  :admin,
  String.t(),
  term(),
  keyword()
) ::
  outcome()

Runs tool tool_name with input on behalf of the live session session_ref ({session_token_uuid, user_uuid}), in process, and returns the outcome as execute/5 does. The session's scope is rebuilt for this call; see "On behalf of a live session" above for what differs from a key.

Options:

  • :allow - :read (the default): a write tool is refused with 403 forbidden before anything runs. :all: a write tool of a module in api_session_write_modules is held for the person's confirmation (the answer has "status": "confirmation_required" and the "confirmation_id" to pass to redeem_as/6); one of another module is refused with 403 module_write_disabled. Any other value raises ArgumentError.
  • :request_context - keyword list: :language, :base_url, :hosts, :currency (the handler context, PhoenixKit.API.RequestContext.from_options/1), :request_id (the context's :request_id) and :ip (the actor's ip).
  • :via - what asks, for the audit entry of a write (metadata.via) and the handler's ctx.via: a non-empty string of at most 64 bytes, "session" by default. Anything else raises ArgumentError.

The core does not tell an impersonated session from the person's own: a caller that passes allow: :all must refuse a session that PhoenixKitWeb.Users.MultiSession.impersonated?/1 marks, before it calls, so an administrator acting as somebody else cannot propose writes in their name.

input_schema(tool)

@spec input_schema(PhoenixKit.API.Tool.t()) :: map()

The input schema the executor enforces for tool: the tool's own schema, closed at the root with additionalProperties: false, with string keys, and for a write tool with the control fields (dry_run, confirm_token, idempotency_key). MCP's inputSchema and OpenAPI's request body are built from it, so a client is told exactly what the validator will accept. Nested objects retain the module's policy; declare additionalProperties: false on each nested object that must reject unknown fields.

list(conn, audience)

Sends the list of tools the actor may call: name, title, description, scope, schemas and annotations. For the admin audience the list is filtered by the key's effective scopes.

redeem_as(session_ref, atom, confirmation_uuid, tool_name, input, opts \\ [])

@spec redeem_as(
  PhoenixKit.Users.Auth.SessionRef.t(),
  :admin,
  term(),
  String.t(),
  term(),
  keyword()
) ::
  outcome()

The person confirms the write their session's execute_as/5 call was held for (confirmation_uuid, the "confirmation_id" of that answer). The caller brings the same tool_name and input again (without control fields: a dry_run, confirm_token or idempotency_key is refused with 422); the core keeps only their hash.

The call passes the whole path again, with the rights of the session that confirms now: the session must still act (401), the tool exist and its module be on (api_disabled_modules, api_disabled_tools), the module be in api_session_write_modules (403 module_write_disabled), the session hold the tool's scope and permission (403 forbidden), the rate limit allow it, the input validate. Then PhoenixKit.API.Flow.redeem_for_session/2: the row must be this person's, for the same tool and arguments, still waiting; a fresh dry run must give the fingerprint of the preview; the row is spent atomically, so a second confirmation of the same row is refused (used). Only then the handler runs, with ctx.confirmation_uuid set.

Returns outcome() as execute_as/5 does: the handler's result; the prompt of a new row when the values changed since the preview ("reason": "changed", the old row is expired); or a refusal, 409 confirm_token_invalid with context.reason one of invalid (unknown, or somebody else's: the same answer), other_arguments, used, rejected, expired. A refusal before the row is spent leaves it waiting.

Options: :request_context and :via, as for execute_as/5.

As with execute_as/5 and allow: :all, the caller must refuse an impersonated session (PhoenixKitWeb.Users.MultiSession.impersonated?/1) before it calls: the core cannot tell it from the person's own.

tool_availability(scope \\ nil)

@spec tool_availability(PhoenixKit.Users.Auth.Scope.t() | nil) :: [availability()]

Every admin tool of every registered module, switched on or off, with whether a person's live session may call it (visible_for/3 and execute_as/5 with allow: :all) and, if not, why. Read-only: nothing is charged or written (no audit entry). API keys are not affected (they never read api_session_write_modules).

Each entry is %{module_key, module_name, name, kind, permission, scope, danger, status}: kind is :read or :write (PhoenixKit.API.ControlFields.write_tool?/1), danger the tool's own, status :available or {:closed, reasons}, the reasons in this order:

  • :module_disabled - the module is switched off;
  • :api_off - the module's API is switched off (api_disabled_modules). When api_disabled_tools or api_disabled_modules cannot be read every tool is closed as :api_off, since the registry then serves nothing;
  • :tool_hidden - the tool is listed in api_disabled_tools;
  • :agent_writes_off - a write tool whose module is not in api_session_write_modules (the "Agent may act" boxes of Settings -> API; a list that cannot be read is empty);
  • :no_permission - only when scope (a PhoenixKit.Users.Auth.Scope) is given. For a tool the settings leave open: the scope fails the checks of visible_for/3, its permission (PhoenixKit.Users.Auth.Scope.can?/2) or its scope (PhoenixKit.API.Keys.held_scopes_of/1). For a tool the settings close: the scope's roles do not grant the tool's permission, whether or not the module behind it is on. The scope rule is not applied to a closed tool, so opening it may still leave it closed as :no_permission, when another tool on its scope needs a permission the scope lacks.

The scope is taken as given, account checks included: a session's own is SessionRef.scope_for_session/2 (which refuses an inactive or unconfirmed account), narrowed to the role it acts as; a scope built otherwise is the caller's to vouch for.

Without scope the reasons are the install's settings alone. With the scope of a live session, the :available tools are exactly what visible_for/3 lists for it with allow: :all. api_admin_enabled switches the HTTP API and does not apply to a session, so it is not a reason here. Core's system.* tools (never callable by a session) and public tools are not listed; neither is a tool the registry ignores because another module declared its name first. Sorted by module name, then by tool name.

The list names every admin tool whatever scope is given, so gate who may see it. It walks every module's tools and reads the settings on each call: call it once per page (mount or params), not per render.

visible(conn, audience)

@spec visible(Plug.Conn.t(), PhoenixKit.API.Tool.audience()) ::
  {:ok, [PhoenixKit.API.Tool.t()], headers()}
  | {:error, atom(), String.t(), map(), headers()}

The tools the actor may call, as list/2 shows them, returned instead of sent. For the admin audience: the tools whose scope the key effectively holds, plus the scope-less ones (system.*); for the public audience: every public tool. Costs one rate-limit unit, like list/2.

visible_for(session_ref, atom, opts \\ [])

@spec visible_for(PhoenixKit.Users.Auth.SessionRef.t(), :admin, keyword()) ::
  {:ok, [PhoenixKit.API.Tool.t()], headers()}
  | {:error, atom(), String.t(), map(), headers()}

The tools the live session session_ref ({session_token_uuid, user_uuid}, see PhoenixKit.Users.Auth.SessionRef) may call, for the admin audience: the read tools whose scope the session's scope holds (PhoenixKit.API.Keys.held_scopes_of/1) and whose permission it holds. No system.* tool. Costs one unit of the user's rate-limit bucket.

Options: :allow, :read (the default: no write tool) or :all (also the write tools the session's rights reach, of the modules in api_session_write_modules); any other value raises ArgumentError.

Returns {:ok, tools, headers}, or {:error, code, detail, context, headers} (:unauthorized with context.reason from t:SessionRef.refusal/0 when the session cannot act, :rate_limited).