PhoenixKit.API.Confirm (phoenix_kit v2.63.0)

Copy Markdown View Source

Confirmation codes of dangerous API operations, and the helpers the approval queue shares.

A dangerous call without a code changes nothing and returns a preview plus a code (issue_token/6). Repeating the call with the same arguments and the code runs it. The code is valid only when all of these hold:

  • its row is unused and has not expired;
  • it was issued to the same actor (an API key, or a cart-token hash for a public tool);
  • it was issued for the same tool and the same arguments (input_hash, a hash of the arguments without the control fields);
  • the fingerprint (a hash of the current values the operation touches) is still the one in the row.

lookup_token/5 checks the cheap parts and changes nothing. claim_token/3 is the atomic part: one UPDATE that sets used only if the row is still unused, not expired, and its fingerprint equals the fresh one the caller computed by running the handler as a dry run. Exactly one caller can win, so two simultaneous confirmations run the operation once; the loser sees {:error, :used}. A batch that then fails part-way keeps its code spent.

live_actor/1 rebuilds an actor from a key row without a token, for the approval queue. expire_due/1, sweep_stuck/2 and prune/1 are the maintenance functions the cron workers call.

Summary

Functions

Spends a code, atomically. fresh_fingerprint is the fingerprint of the values the operation touches now. :ok for exactly one caller; {:error, :used}, {:error, :expired} or {:error, :changed} for the others.

Marks the pending approvals and the unused codes whose expires_at has passed as expired (an approval's input is cleared). Returns how many of each.

Issues a code for actor, tool_name and input_hash, holding preview and fingerprint. The row lives api_confirm_ttl_seconds.

The actor of an API key as it stands now, without a token: the key is read from the database (not the cache), must not be revoked or expired (Keys.dead_reason/1), and its owner must be active; scopes are the key's scopes filtered by the owner's live permissions. This is the request-path check of the executor, for code that has no request.

The cheap checks of a presented code. Changes nothing. {:ok, row} or the first reason it is refused.

Deletes the rows nobody needs any more. Returns how many rows went.

Fails every approval that is still approved or executing although nothing has touched it for stale_after_seconds (default 15 minutes): the process that was running it died. The error interrupted is added to whatever partial results it had reported, its input is cleared, and the outcome is logged and announced. Returns how many.

Functions

claim_token(confirmation, fresh_fingerprint, now \\ DateTime.utc_now())

@spec claim_token(PhoenixKit.API.Confirm.Confirmation.t(), String.t(), DateTime.t()) ::
  :ok | {:error, :used | :expired | :changed}

Spends a code, atomically. fresh_fingerprint is the fingerprint of the values the operation touches now. :ok for exactly one caller; {:error, :used}, {:error, :expired} or {:error, :changed} for the others.

expire_due(now \\ DateTime.utc_now())

@spec expire_due(DateTime.t()) :: %{
  approvals: non_neg_integer(),
  tokens: non_neg_integer()
}

Marks the pending approvals and the unused codes whose expires_at has passed as expired (an approval's input is cleared). Returns how many of each.

issue_token(actor, tool_name, input_hash, preview, fingerprint, now \\ DateTime.utc_now())

@spec issue_token(
  PhoenixKit.API.Actor.t(),
  String.t(),
  String.t(),
  map(),
  String.t(),
  DateTime.t()
) ::
  {:ok,
   %{
     token: String.t(),
     row: PhoenixKit.API.Confirm.Confirmation.t(),
     expires_in_seconds: non_neg_integer()
   }}
  | {:error, :no_secret | :no_binding | Ecto.Changeset.t()}

Issues a code for actor, tool_name and input_hash, holding preview and fingerprint. The row lives api_confirm_ttl_seconds.

A key that repeats the very same call (same tool, arguments and current values) while its earlier code is still unused and unexpired gets that code again, with the time it has left: an agent that polls a dangerous call does not pile up rows. A public actor is bound to a cart token it chose itself, so nothing is shared between two of its calls; what keeps that table small is the early prune of never-used codes (prune/1) and the rate limit of the public API.

live_actor(key_uuid)

@spec live_actor(String.t()) ::
  {:ok, PhoenixKit.API.Actor.t()}
  | {:error, :not_found | :revoked | :expired | :owner_inactive}

The actor of an API key as it stands now, without a token: the key is read from the database (not the cache), must not be revoked or expired (Keys.dead_reason/1), and its owner must be active; scopes are the key's scopes filtered by the owner's live permissions. This is the request-path check of the executor, for code that has no request.

lookup_token(token, actor, tool_name, input_hash, now \\ DateTime.utc_now())

@spec lookup_token(
  String.t(),
  PhoenixKit.API.Actor.t(),
  String.t(),
  String.t(),
  DateTime.t()
) ::
  {:ok, PhoenixKit.API.Confirm.Confirmation.t()}
  | {:error,
     :malformed | :unknown | :wrong_binding | :wrong_input | :used | :expired}

The cheap checks of a presented code. Changes nothing. {:ok, row} or the first reason it is refused.

prune(now \\ DateTime.utc_now())

@spec prune(DateTime.t()) :: non_neg_integer()

Deletes the rows nobody needs any more. Returns how many rows went.

  • a code that was never used (still unused, or marked expired) one hour after it ran out: every dangerous call without a code inserts one, and a public client can ask for many, so these must not stay for a month;
  • a code that was used 30 days after it ran out (it is the trace of what was confirmed);
  • an approval 30 days after its last change, once decided, executed, failed, found stale or expired. A pending, approved or executing approval is never deleted.

sweep_stuck(now \\ DateTime.utc_now(), stale_after_seconds \\ 900)

@spec sweep_stuck(DateTime.t(), pos_integer()) :: non_neg_integer()

Fails every approval that is still approved or executing although nothing has touched it for stale_after_seconds (default 15 minutes): the process that was running it died. The error interrupted is added to whatever partial results it had reported, its input is cleared, and the outcome is logged and announced. Returns how many.