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

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

```elixir
Mix.install(
  [
    {:ash_onetime, "~> 1.6.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.6+, 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: {5, :minute}, clock_skew: {5, :second}, retain_for: {664, :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
```

## Retention stays separate from acceptance

The resource accepts proofs for five minutes (plus five seconds of skew), but keeps claims
for 664 seconds plus skew and the cleanup margin. The stored deadline records that choice:

```elixir
%{rows: [[issued_at, retain_until]]} =
  SQL.query!(Repo, ~s{SELECT issued_at, retain_until FROM "#{schema}".ash_onetime_nonce_claims}, [])

true = retain_until == AshOnetime.Window.cleanup_after(issued_at, 664, 5)
:retention_verified
```

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

```
:retention_verified
```

## 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 retention 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: {5, :minute}, clock_skew: {5, :second}, retain_for: {664, :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"

# Both the action-owned and independently committed claims use the declared retention.
%{rows: retained_claims} =
  SQL.query!(Repo, ~s{SELECT issued_at, retain_until FROM "#{schema}".ash_onetime_nonce_claims}, [])
2 = length(retained_claims)
true = Enum.all?(retained_claims, fn [issued, deadline] ->
  deadline == AshOnetime.Window.cleanup_after(issued, 664, 5)
end)


{: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.
