RequestSeal.AshHooks.Http (RequestSeal v0.5.2)

Copy Markdown View Source

ash_hooks HTTP adapter signing final method, URL, headers, and body bytes.

Configure http: RequestSeal.AshHooks.Http and http_opts: [request_seal: [spec: signing_spec, signer: custody_handle]]; request/5 lists every option. Each delivery attempt signs once with fresh metadata and hands the signed request to ash_hooks' bounded transport. ash_hooks continues to own SSRF resolve-and-pin, durable attempts, retries, secret references, rotation, and response classification. ash_hooks still requires its Standard Webhooks secret reference and emits those headers alongside RFC 9421 fields. Select the RFC label explicitly. Adapter options receive no endpoint or tenant identity; choose custody in static http_opts or its ash_hooks MFA resolver. No client or pool starts here.

Summary

Functions

Signs a final webhook request and delegates it to ash_hooks' bounded transport.

Functions

request(method, url, headers, body, opts \\ [])

@spec request(atom() | binary(), binary(), map(), binary() | nil, keyword()) ::
  {:ok, %{status: integer(), headers: list(), body: binary() | nil}}
  | {:error, term()}

Signs a final webhook request and delegates it to ash_hooks' bounded transport.

ash_hooks calls this as its AshHooks.Http adapter. Header names are lowercased, the request is signed with RequestSeal.sign/4, and the signed headers go to AshHooks.Http.Bounded with the remaining total timeout and otherwise unchanged transport options.

Parameters

  • method - :connect, :delete, :get, :head, :options, :patch, :post, :put, :trace, or a binary method (uppercased).
  • url - the absolute target URL.
  • headers - a map of request headers. Names are lowercased; names that collide after lowercasing reject, as do host, connection, content-length, transfer-encoding, signature, and signature-input.
  • body - the exact body bytes, or nil.
  • opts - keyword list of the options below.

Options

  • :timeout - total milliseconds for signing and transport, 1..300,000. Default 15,000.
  • :request_seal (required) - keyword list of these keys; others reject:
    • :spec (required) - a RequestSeal.signing_spec/0. :digest defaults to ["sha-256"] and must not be nil. The components must include "@method", "@authority", "@path", "content-digest", "content-type", and "webhook-id" without component parameters, plus "@query" when the URL has a query.
    • :signer (required) - a RequestSeal.KeyHandle; function signers reject.
    • :clock - arity-zero function returning Unix seconds.
    • :signing_timeout - milliseconds, default 5,000, capped at the time left before the total deadline.
  • any other option - passed unchanged to AshHooks.Http.Bounded, with :timeout replaced by the remaining time and :request_seal removed.

Every attempt regenerates created, expires, and a random nonce. From 0.5.0, a nonce is required on every signed attempt: a spec with nonce: nil is refused as a signing failure before any request is sent.

Returns

  • {:ok, %{status: status, headers: headers, body: body}} - the response from AshHooks.Http.Bounded.
  • {:error, :timeout} - :timeout is outside 1..300,000, or signing used up the whole deadline.
  • {:error, {:terminal, :request_seal_signing_failed}} - any signing failure: invalid :request_seal options or spec, missing coverage, a rejected header or method, or a RequestSeal.sign/4 error. The terminal tag is for caller classification; ash_hooks 2.0 retries returned adapter errors other than :unsafe_destination.
  • {:error, term} - any other error returned by AshHooks.Http.Bounded, such as :unsafe_destination. Transport exceptions propagate unchanged.

Examples

iex> {_public, seed} = :crypto.generate_key(:eddsa, :ed25519)
iex> {:ok, handle} = RequestSeal.Custody.Local.new("ed25519", {:ed25519, seed})
iex> spec = %{
...>   label: "sig",
...>   algorithm: "ed25519",
...>   components:
...>     ~s[("@method" "@authority" "@path" "content-digest" "content-type" "webhook-id")],
...>   expires_in: 300
...> }
iex> url = "https://hooks.example.com/builds"
iex> headers = %{"content-type" => "application/json", "webhook-id" => "msg-1"}
iex> RequestSeal.AshHooks.Http.request(:post, url, headers, "{}", timeout: 0)
{:error, :timeout}
iex> RequestSeal.AshHooks.Http.request(:post, url <> "?page=2", headers, "{}",
...>   request_seal: [spec: spec, signer: handle]
...> )
{:error, {:terminal, :request_seal_signing_failed}}
iex> RequestSeal.AshHooks.Http.request(:post, url, Map.put(headers, "host", "other.example.com"), "{}",
...>   request_seal: [spec: spec, signer: handle]
...> )
{:error, {:terminal, :request_seal_signing_failed}}
iex> RequestSeal.Custody.Local.release(handle)
:ok

Fragment (requires an ash_hooks delivery resource with a PostgreSQL data layer and a reachable HTTPS receiver; not executed here):

AshHooks.Delivery.run(%{"endpoint_id" => endpoint_id, "event_uuid" => event_id},
  deliveries: MyApp.Hooks.Delivery,
  endpoints: MyApp.Hooks.Endpoint,
  secret_resolver: secret_resolver,
  http: RequestSeal.AshHooks.Http,
  http_opts: [request_seal: [spec: spec, signer: handle]]
)