# `PhoenixKit.API.Confirm`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v2.64.1/lib/phoenix_kit/api/confirm.ex#L1)

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

## A person's live session

A write a live session asks for (`PhoenixKit.API.Executor.execute_as/5`) is held in a
row bound to the person (`actor_user_uuid`), not to a code: the session is never given
one (`issue_token/6` refuses a `:user` actor, and `lookup_token/5` never accepts a
session's row). `issue_for_user/6` stores the row, every call a new one, for
`api_user_confirm_ttl_seconds`; the executor redeems it by its uuid
(`PhoenixKit.API.Executor.redeem_as/6`), repeating the call with the arguments the caller
brings, and checks them against the row as for a code. This module only stores and
finds those rows:

  * `lookup_for_user/2` finds the person's row by uuid;
  * `get_for_user/2` reads it for the page that shows the confirmation, while the session
    may still write with its tool;
  * `reject_for_user/2` turns it down;
  * `reissue_for_user/7` replaces it when the values changed under it.

A uuid that does not exist and one of another person get the same `{:error, :unknown}`,
so nobody learns whether somebody else's confirmation exists.

# `claim_token`

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

Spends a code, atomically. `fresh_fingerprint` is the fingerprint of the values the
operation touches *now*. `:ok` for exactly one caller; `{:error, :used}`,
`{:error, :rejected}` (a session's row the person turned down), `{:error, :expired}` or
`{:error, :changed}` for the others.

# `expire_due`

```elixir
@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.

# `get_for_user`

```elixir
@spec get_for_user(term(), term(), DateTime.t()) ::
  {:ok,
   %{
     uuid: String.t(),
     tool: String.t(),
     preview: map(),
     status: String.t(),
     expires_at: DateTime.t()
   }}
  | {:error, :unauthorized | :unknown | :forbidden}
```

What the page that shows a session's confirmation needs: `%{uuid, tool, preview, status,
expires_at}` of the row `confirmation_uuid`, if it belongs to the user of the live
session `session_ref` (`{session_token_uuid, user_uuid}`). `status` is `"unused"`,
`"used"`, `"rejected"` or `"expired"` (an unused row past its time reads `"expired"`).

`{:error, :unauthorized}` when the session cannot act any more
(`SessionRef.scope_for_session/2`), `{:error, :unknown}` as in `lookup_for_user/2`, and
`{:error, :forbidden}`, without the preview, when the session may no longer write with the
row's tool, whatever the row's status: the tool must still be callable (its module on, the
module's API on, the tool not hidden), its scope and permission held by the session's
scope now, and its module in `api_session_write_modules`, as
`PhoenixKit.API.Executor.redeem_as/6` checks before it runs.

The core does not tell an impersonated session from the person's own: the caller must
refuse a session that `PhoenixKitWeb.Users.MultiSession.impersonated?/1` marks before it
calls, so an administrator acting as somebody else does not read their previews.

# `issue_for_user`

```elixir
@spec issue_for_user(
  PhoenixKit.API.Actor.t(),
  String.t(),
  String.t(),
  map(),
  String.t(),
  DateTime.t()
) ::
  {:ok, PhoenixKit.API.Confirm.Confirmation.t()}
  | {:error, :no_binding | Ecto.Changeset.t()}
```

Holds a write of the live session of `actor` (a `:user`) for `tool_name` and
`input_hash`, with `preview` and `fingerprint`, until the person confirms it, for
`api_user_confirm_ttl_seconds`. Every call stores a new row: an earlier one is never
handed out again. No code is made. The insert is not logged (`log: false`): its
parameters carry the preview, the data the person works on.

# `issue_token`

```elixir
@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 client 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`

```elixir
@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_for_user`

```elixir
@spec lookup_for_user(term(), term()) ::
  {:ok, PhoenixKit.API.Confirm.Confirmation.t()} | {:error, :unknown}
```

The person's row `confirmation_uuid`, whatever its status. `{:error, :unknown}` for a
uuid that names no row, a row of somebody else, an approval, a code of a key, or a value
that is not a uuid: the answer never tells them apart.

# `lookup_token`

```elixir
@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`

```elixir
@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`, marked `expired`, or `rejected` by the
    person of a session) 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.

# `redeemable`

```elixir
@spec redeemable(
  PhoenixKit.API.Confirm.Confirmation.t(),
  String.t(),
  String.t(),
  DateTime.t()
) ::
  :ok | {:error, :wrong_input | :used | :rejected | :expired}
```

The cheap checks of a session's row before it is redeemed, as for a code: the same tool
and arguments (`:wrong_input`), still `unused` and not expired (`:used`, `:rejected`,
`:expired`). Changes nothing.

# `reissue_for_user`

```elixir
@spec reissue_for_user(
  PhoenixKit.API.Confirm.Confirmation.t(),
  PhoenixKit.API.Actor.t(),
  String.t(),
  String.t(),
  map(),
  String.t(),
  DateTime.t()
) ::
  {:ok, PhoenixKit.API.Confirm.Confirmation.t()}
  | {:error,
     :used | :rejected | :expired | :changed | :no_binding | Ecto.Changeset.t()}
```

Replaces a session's row whose values changed since its preview (`claim_token/3` said
`:changed`) by a new one holding the fresh `preview` and `fingerprint`. The old row goes
from `unused` to `expired` in the same transaction, so it can never be redeemed after
the new one exists. `{:error, reason}` when the old row is no longer `unused` (it was
used or rejected meanwhile), and nothing is stored. Neither statement is logged
(`log: false`), as in `issue_for_user/6`.

# `reject_for_user`

```elixir
@spec reject_for_user(term(), term(), DateTime.t()) ::
  :ok
  | {:error, :unauthorized | :unknown | :used | :rejected | :expired | :changed}
```

The person turns down their confirmation `confirmation_uuid`: one atomic `UPDATE` from
`unused` (and not expired) to `rejected`, so a rejected row can never be redeemed and a
redeemed one never rejected. `{:error, :used | :rejected | :expired}` when it is no longer
waiting; `{:error, :unauthorized}` and `{:error, :unknown}` as in `get_for_user/2`. The
person may turn a row down after losing the right to the tool: nothing runs.

As for `get_for_user/2`, the caller must refuse an impersonated session
(`PhoenixKitWeb.Users.MultiSession.impersonated?/1`) before it calls.

# `sweep_stuck`

```elixir
@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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
