<!--
livebook:{"persist_outputs":true}
-->

# One-time nonces — at-most-once admission

```elixir
Mix.install(
  [
    {:ash_onetime, "~> 1.5.0"},
    {:ash_postgres, "~> 2.13"}
  ],
  consolidate_protocols: false
)
```

## What this notebook covers

A one-time nonce protects a request against replay. The first spend of a verified proof
succeeds; every reuse is rejected. There is no stored result to replay — only a fail-closed
spend. Use this when an action must run *at most once* even if the client retries (single-use
redemptions, anti-replay of a captured request).

This notebook walks through the spend and the reuse rejection — against a real PostgreSQL.

> Requires Elixir 1.20+, Ash 3.34.3+, and PostgreSQL 18 reachable at
> `DATABASE_URL`. Use a development database where you can create and remove
> schemas. This notebook signs and verifies real Ed25519 proofs; its simple
> proof format illustrates nonce admission and is not a DPoP implementation.

## Connect to PostgreSQL

```elixir
database_url = System.fetch_env!("DATABASE_URL")
Logger.configure(level: :warning)

:ok = Application.put_env(:ash, :disable_async?, true)
# Ash >= 3.33 requires an explicit string-length counting mode; codepoints
# matches how the SQL data layer counts (EEF-CVE-2026-82752 remediation).
:ok = Application.put_env(:ash, :default_string_length_count, :codepoints)
:ok = Application.put_env(:ash_onetime_demo, AshOnetimeDemo.Repo, url: database_url, pool_size: 10)
```

## Define the Repo, verifier, and resource

```elixir
defmodule AshOnetimeDemo.Repo do
  use AshPostgres.Repo, otp_app: :ash_onetime_demo

  @impl AshPostgres.Repo
  def installed_extensions, do: ["ash-functions"]

  @impl AshPostgres.Repo
  def min_pg_version, do: %Version{major: 18, minor: 0, patch: 0}
end

{:ok, _} = AshOnetimeDemo.Repo.start_link()
```

```elixir
# Only the public key enters server-side verification configuration. The signing
# key remains with the issuer in this notebook's binding.
{public_key, private_key} = :crypto.generate_key(:eddsa, :ed25519)
:ok = Application.put_env(:ash_onetime_demo, :proof_public_key, public_key)

sign_proof = fn nonce ->
  payload = Jason.encode!(%{nonce: nonce, issued_at: DateTime.to_unix(DateTime.utc_now())})
  signature = :crypto.sign(:eddsa, :none, payload, [private_key, :ed25519])
  Base.url_encode64(payload, padding: false) <> "." <> Base.url_encode64(signature, padding: false)
end

defmodule AshOnetimeDemo.ProofVerifier do
  @behaviour AshOnetime.Verifier

  @impl true
  def verify(proof, _context) when is_binary(proof) do
    public_key = Application.fetch_env!(:ash_onetime_demo, :proof_public_key)

    with [encoded_payload, encoded_signature] <- String.split(proof, "."),
         {:ok, payload} <- Base.url_decode64(encoded_payload, padding: false),
         {:ok, signature} <- Base.url_decode64(encoded_signature, padding: false),
         true <- :crypto.verify(:eddsa, :none, payload, signature, [public_key, :ed25519]),
         {:ok, %{"nonce" => nonce, "issued_at" => issued_at}} <- Jason.decode(payload),
         true <- is_binary(nonce) and is_integer(issued_at),
         {:ok, timestamp} <- DateTime.from_unix(issued_at) do
      AshOnetime.Verified.new(key: nonce, issued_at: timestamp, verifier_id: "notebook-issuer")
    else
      _ -> {:error, :invalid_signature}
    end
  end

  @impl true
  def algorithm, do: :ed25519
  @impl true
  def trust_model, do: :separated
end

defmodule AshOnetimeDemo.FailRun do
  use Ash.Resource.Actions.Implementation

  @impl true
  def run(_input, _opts, _context),
    do: {:error, RuntimeError.exception("the action body failed")}
end

# A protected generic action's `run` must be a module-based implementation.
defmodule AshOnetimeDemo.RedeemRun do
  use Ash.Resource.Actions.Implementation

  @impl true
  def run(input, _opts, _context), do: {:ok, Ash.ActionInput.get_argument(input, :value)}
end

defmodule AshOnetimeDemo.Domain do
  use Ash.Domain, validate_config_inclusion?: false

  resources do
    resource AshOnetimeDemo.Redeemable
  end
end

defmodule AshOnetimeDemo.Redeemable do
  use Ash.Resource,
    domain: AshOnetimeDemo.Domain,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshOnetime.Resource]

  postgres do
    table "demo_redeemables"
    repo AshOnetimeDemo.Repo
  end

  multitenancy do
    strategy :context
  end

  attributes do
    uuid_primary_key :id
  end

  actions do
    defaults [:read]

    action :redeem, :integer do
      transaction? true
      argument :value, :integer, allow_nil?: false
      argument :proof, :string, allow_nil?: false
      run {AshOnetimeDemo.RedeemRun, []}
    end
  end

  onetime do
    protect :redeem do
      strategy :one_time_nonce
      scope([{:static, "redeem"}])
      key({:verified, :proof, AshOnetimeDemo.ProofVerifier})
      window(max_age: {1, :hour}, clock_skew: {5, :second})
    end
  end
end

:ok
```

## Install the admission store

```elixir
alias AshOnetimeDemo.Repo
alias Ecto.Adapters.SQL

unique = Ecto.UUID.generate() |> String.replace("-", "")
schema = "ash_onetime_demo_#{unique}"
temp = Path.join(System.tmp_dir!(), "ash_onetime_demo_#{unique}")
File.mkdir_p!(temp)

{schema, temp}
```

```elixir

source =
  Mix.Tasks.AshOnetime.Gen.Migrations.render(Repo,
    hash_partitions: nil,
    partition_start: Date.utc_today() |> Date.beginning_of_month()
  )

timestamp = "20260101000000"
path = Path.join(temp, "#{timestamp}_install_ash_onetime.exs")
File.write!(path, source)
[{module, _}] = Code.compile_file(path)
version = String.to_integer(timestamp)

SQL.query!(Repo, ~s{CREATE SCHEMA "#{schema}"}, [])
:ok = Ecto.Migrator.up(Repo, version, module, prefix: schema, log: false)

# The business resource table (the redeemable has only an id; the nonce action is a generic
# action with no row, but the resource still needs its table for read defaults).
SQL.query!(
  Repo,
  """
  CREATE TABLE IF NOT EXISTS "#{schema}"."demo_redeemables" (
    id uuid PRIMARY KEY
  )
  """,
  []
)

{schema, version, module}
```

## Spend once

The first `:redeem` spends the proof and runs the action body.

```elixir
proof = sign_proof.("proof-once")

{:ok, value} =
  AshOnetimeDemo.Redeemable
  |> Ash.ActionInput.for_action(:redeem, %{value: 7, proof: proof})
  |> Ash.ActionInput.set_tenant(schema)
  |> Ash.run_action()

value
```

<!-- livebook:{"output":true} -->

```
7
```

## Reuse is rejected

Reusing the same `proof` is rejected as `:nonce_already_used`. The action body does not run.

```elixir
{:error, error} =
  AshOnetimeDemo.Redeemable
  |> Ash.ActionInput.for_action(:redeem, %{value: 7, proof: proof})
  |> Ash.ActionInput.set_tenant(schema)
  |> Ash.run_action()

AshOnetime.Error.code(error)
```

<!-- livebook:{"output":true} -->

```
:nonce_already_used
```

## A changed proof is not trusted

Changing a signed payload without issuing a new signature fails verification.
It cannot supply trusted nonce or timestamp facts:

```elixir
[encoded_payload, encoded_signature] = String.split(proof, ".")
{:ok, payload} = Base.url_decode64(encoded_payload, padding: false)
changed_payload = String.replace(payload, "proof-once", "other-nonce")
tampered = Base.url_encode64(changed_payload, padding: false) <> "." <> encoded_signature

{:error, :invalid_signature} = AshOnetimeDemo.ProofVerifier.verify(tampered, %{})
:tampered_proof_rejected
```

<!-- livebook:{"output":true} -->

```
:tampered_proof_rejected
```

## Keep the spend after body failure (`commit: :independent`)

By default the nonce spend commits **inside** the action's transaction, so an action-body
failure rolls the spend back — correct when a retry will bear a fresh proof. For
[RFC 9449 (DPoP)](https://datatracker.ietf.org/doc/html/rfc9449#section-11.1) §11.1 replay
protection, declare `commit: :independent` so the claim commits in its own transaction **before**
the body runs. A DPoP verifier must separately validate the complete RFC proof; this
cell demonstrates the transaction rule with the signed nonce format above. A body
failure leaves that nonce spent for the acceptance window:

```elixir
defmodule AshOnetimeDemo.FailureDomain do
  use Ash.Domain, validate_config_inclusion?: false

  resources do
    resource AshOnetimeDemo.RedeemableFail
  end
end

defmodule AshOnetimeDemo.RedeemableFail do
  use Ash.Resource,
    domain: AshOnetimeDemo.FailureDomain,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshOnetime.Resource]

  postgres do
    table "demo_redeemable_fail"
    repo AshOnetimeDemo.Repo
  end

  multitenancy do
    strategy :context
  end

  attributes do
    uuid_primary_key :id
  end

  actions do
    defaults [:read]

    action :redeem_fail, :integer do
      transaction? true
      argument :value, :integer, allow_nil?: false
      argument :proof, :string, allow_nil?: false
      run {AshOnetimeDemo.FailRun, []}
    end
  end

  onetime do
    protect :redeem_fail do
      strategy :one_time_nonce
      scope([{:static, "redeem_fail"}])
      key({:verified, :proof, AshOnetimeDemo.ProofVerifier})
      window(max_age: {1, :hour}, clock_skew: {5, :second})
      commit :independent
    end
  end
end
```

The body fails, but the spend survived — a retry with the same proof is rejected:

```elixir
failing_proof = sign_proof.("independent-proof")

{:error, failure} =
  AshOnetimeDemo.RedeemableFail
  |> Ash.ActionInput.for_action(:redeem_fail, %{value: 1, proof: failing_proof})
  |> Ash.ActionInput.set_tenant(schema)
  |> Ash.run_action()

true = Exception.message(failure) =~ "the action body failed"

{:error, retry} =
  AshOnetimeDemo.RedeemableFail
  |> Ash.ActionInput.for_action(:redeem_fail, %{value: 1, proof: failing_proof})
  |> Ash.ActionInput.set_tenant(schema)
  |> Ash.run_action()

AshOnetime.Error.code(retry)
```

<!-- livebook:{"output":true} -->

```
:nonce_already_used
```

See ADR-0003 (Independent-commit nonce) for the contract and the
[operations guide](../operations.md#dpop-replay-fence-operational-characteristics) for
pool-sizing notes.

## Clean up

```elixir
:ok = Ecto.Migrator.down(Repo, version, module, prefix: schema, log: false)
SQL.query!(Repo, ~s{DROP SCHEMA IF EXISTS "#{schema}" CASCADE}, [])
File.rm_rf!(temp)
:ok
```

## Where to next

- [Idempotency](idempotency.livemd) — safe retries with stored-result replay.
- [External effects and recovery](external-recovery.livemd) — committing side effects safely.
- [One-time nonces guide](https://hexdocs.pm/ash_onetime/one-time-nonces.html).
- [Transaction-owned admission](https://hexdocs.pm/ash_onetime/transaction-owned-admission.html) — spend nonces inside a host-owned transaction.
