Optional caller-started bounded discovery cache; no timers or background refresh.
Source identity includes all configuration and trust roots. A full cache
returns :cache_overloaded; it never silently evicts revocation tombstones.
Fetches are single-flight per source, and the cache never serves stale keys.
A failed fetch retains the last observed snapshot without extending its
freshness. If that snapshot is stale, resolve/4 calls within negative_ttl
return :source_unavailable without another connection; refresh/2 still
fetches. CIMD failures are never cached, as required by CIMD-02 Section 5.2.
No-store/no-cache snapshots serve only current waiters and are never retained.
Thumbprints denied with remove/3 stay denied across refresh and
invalidation. Removing the source's denial requires a caller-created new
cache/configuration. Last-waiter cancellation kills the socket-owning fetcher;
cache termination also cancels all fetches.
Summary
Functions
Returns a specification to start this module under a supervisor.
Discard a source's snapshot, negative freshness and pending fetch, preserving explicit removals.
Explicitly fetch a source and replace its snapshot on success.
Deny a thumbprint persistently for this cache and source.
Resolve a source-selected key ID, fetching the source on a miss or expiry under caller limits.
Start the explicitly caller-owned cache, linked to the caller.
Functions
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec invalidate(GenServer.server(), RequestSeal.Discovery.Source.t()) :: :ok | {:error, RequestSeal.Discovery.Error.t()}
Discard a source's snapshot, negative freshness and pending fetch, preserving explicit removals.
Callers waiting on the canceled fetch receive :source_unavailable. The next
resolve/4 for the source fetches again.
Parameters
cache- a running cache: pid or registered name.source- aRequestSeal.Discovery.Source.
Returns
:ok- the source state is discarded.{:error, %RequestSeal.Discovery.Error{}}- reasons this function can return::invalid_source,:invalid_options,:cache_overloaded,:source_unavailable(including a cache that is not running). Meanings:RequestSeal.Discovery.Error.
Examples
iex> {:ok, cache} = RequestSeal.Discovery.Cache.start_link()
iex> {:ok, source} =
...> RequestSeal.Discovery.Source.new(%{type: :jwks_uri, location: "https://keys.example/jwks.json"})
iex> RequestSeal.Discovery.Cache.invalidate(cache, source)
:ok
iex> GenServer.stop(cache)
:ok
iex> RequestSeal.Discovery.Cache.invalidate(cache, source)
{:error, %RequestSeal.Discovery.Error{reason: :source_unavailable, retryable: true}}
@spec refresh(GenServer.server(), RequestSeal.Discovery.Source.t()) :: {:ok, RequestSeal.Discovery.KeySet.t()} | {:error, RequestSeal.Discovery.Error.t()}
Explicitly fetch a source and replace its snapshot on success.
The fetch runs even during negative freshness; if a fetch for the source is already in progress, the call waits for that fetch. On success the old snapshot is replaced immediately; a failure keeps it without extending its freshness.
Parameters
cache- a running cache: pid or registered name.source- aRequestSeal.Discovery.Source; the fetch uses its:timeoutand the cache's clock.
Returns
{:ok, %RequestSeal.Discovery.KeySet{}}- the new snapshot, without thumbprints denied byremove/3.{:error, %RequestSeal.Discovery.Error{}}- reasons this function can return::invalid_source,:invalid_options,:source_unavailable(invalidation, a failed fetcher, or, from 0.5.0, a cache that is not running; 0.4.1 returns:invalid_responsefor that case),:cache_overloaded,:deadline_exceeded, and any error fromRequestSeal.Discovery.fetch/2. Meanings:RequestSeal.Discovery.Error.
Examples
iex> {:ok, cache} = RequestSeal.Discovery.Cache.start_link()
iex> RequestSeal.Discovery.Cache.refresh(cache, %{type: :jwks_uri})
{:error, %RequestSeal.Discovery.Error{reason: :invalid_source, retryable: false}}
iex> GenServer.stop(cache)
:okFragment (requires a started :ssl application and an HTTPS peer at
keys.example; not executed here):
{:ok, %RequestSeal.Discovery.KeySet{} = set} =
RequestSeal.Discovery.Cache.refresh(cache, source)
@spec remove(GenServer.server(), RequestSeal.Discovery.Source.t(), binary()) :: :ok | {:error, RequestSeal.Discovery.Error.t()}
Deny a thumbprint persistently for this cache and source.
The thumbprint is dropped from any held snapshot and from fetches completing later.
Parameters
cache- a running cache: pid or registered name.source- aRequestSeal.Discovery.Source.thumbprint- an RFC 7638 SHA-256 thumbprint (43 base64url characters).
Returns
:ok- the thumbprint is denied.{:error, %RequestSeal.Discovery.Error{}}- reasons this function can return::invalid_source,:invalid_options,:unknown_key(not a thumbprint),:limit(more than 256 denials for the source),:cache_overloaded,:source_unavailable(including a cache that is not running). Meanings:RequestSeal.Discovery.Error.
Examples
iex> {:ok, cache} = RequestSeal.Discovery.Cache.start_link()
iex> {:ok, source} =
...> RequestSeal.Discovery.Source.new(%{type: :jwks_uri, location: "https://keys.example/jwks.json"})
iex> thumbprint = Base.url_encode64(:crypto.hash(:sha256, "retired key"), padding: false)
iex> RequestSeal.Discovery.Cache.remove(cache, source, thumbprint)
:ok
iex> RequestSeal.Discovery.Cache.remove(cache, source, "build-signer-1")
{:error, %RequestSeal.Discovery.Error{reason: :unknown_key, retryable: false}}
iex> GenServer.stop(cache)
:ok
@spec resolve( GenServer.server(), RequestSeal.Discovery.Source.t(), binary(), keyword() ) :: {:ok, RequestSeal.Discovery.Resolution.t()} | {:error, RequestSeal.Discovery.Error.t()}
Resolve a source-selected key ID, fetching the source on a miss or expiry under caller limits.
A fresh snapshot answers directly; unknown keys in a fresh set do not trigger a refetch. Otherwise the call joins or starts the source's single fetch and looks the key up in the result. In thumbprint mode, source revocations and tombstones reject before fetching. In directory ID mode, they filter computed thumbprints after discovery; a matching directory ID cannot override or become a revocation identity.
Parameters
cache- a running cache: pid or registered name.source- aRequestSeal.Discovery.Source.keyid- an ID under the source'skey_idmode (seeRequestSeal.Discovery.KeySet.lookup/3).opts- keyword list without unknown or duplicate keys; see Options.
Options
:algorithms- 1 to 64 algorithm identifiers accepted by a policy; the first one compatible with the key is selected. Default:RequestSeal.Crypto.algorithms/0.:timeout- milliseconds, 1 through 300,000, capped by the source's:timeout. Default: the source's:timeout.:clock- arity-zero function returning Unix seconds from 0 through 253,402,300,799, used for this call's freshness checks and any fetch it starts. Default: the cache's clock.
Returns
{:ok, %RequestSeal.Discovery.Resolution{}}- the key with its selected algorithm and provenance.{:error, %RequestSeal.Discovery.Error{}}- reasons this function can return::invalid_source,:invalid_options,:unknown_key(malformed ID, or no fresh, compatible key with that ID),:revoked_key,:source_unavailable(negative freshness, invalidation, a failed fetcher, or, from 0.5.0, a cache that is not running; 0.4.1 returns:invalid_responsefor that case),:cache_overloaded,:deadline_exceeded, and any error fromRequestSeal.Discovery.fetch/2. Meanings:RequestSeal.Discovery.Error.
Examples
iex> {:ok, cache} = RequestSeal.Discovery.Cache.start_link()
iex> {:ok, source} =
...> RequestSeal.Discovery.Source.new(%{type: :jwks_uri, location: "https://keys.example/jwks.json"})
iex> RequestSeal.Discovery.Cache.resolve(cache, source, "not-a-thumbprint")
{:error, %RequestSeal.Discovery.Error{reason: :unknown_key, retryable: false}}
iex> thumbprint = Base.url_encode64(:crypto.hash(:sha256, "example key"), padding: false)
iex> :ok = RequestSeal.Discovery.Cache.remove(cache, source, thumbprint)
iex> RequestSeal.Discovery.Cache.resolve(cache, source, thumbprint, algorithms: ["ed25519"])
{:error, %RequestSeal.Discovery.Error{reason: :revoked_key, retryable: false}}
iex> GenServer.stop(cache)
:okFragment (requires a started :ssl application and an HTTPS peer at
keys.example; not executed here):
{:ok, resolution} =
RequestSeal.Discovery.Cache.resolve(cache, source, thumbprint,
algorithms: ["ed25519", "ecdsa-p256-sha256"],
timeout: 2_000
)
@spec start_link(keyword()) :: GenServer.on_start() | {:error, RequestSeal.Discovery.Error.t()}
Start the explicitly caller-owned cache, linked to the caller.
The cache starts no timer and fetches nothing until a call asks for a source.
Use {RequestSeal.Discovery.Cache, opts} as a child specification.
Parameters
opts- keyword list without unknown or duplicate keys; see Options.
Options
:name- aGenServername to register. Default: none.:max_sources- distinct sources the cache tracks, 1 through 4,096; default 64. Calls for a further source return:cache_overloaded.:max_in_flight- concurrent source fetches, 1 through 256; default 8. Pending waiters across all fetches are bounded to 32 per slot.:clock- arity-zero function returning Unix seconds from 0 through 253,402,300,799, called once at start to validate it. Default: system time.
Returns
{:ok, pid}- the started cache.{:error, %RequestSeal.Discovery.Error{}}- reason:invalid_options. Meanings:RequestSeal.Discovery.Error.- Any other
GenServer.start_link/3error, such as{:error, {:already_started, pid}}.
Examples
iex> {:ok, cache} = RequestSeal.Discovery.Cache.start_link(max_sources: 16, max_in_flight: 4)
iex> is_pid(cache)
true
iex> GenServer.stop(cache)
:ok
iex> RequestSeal.Discovery.Cache.start_link(max_sources: 0)
{:error, %RequestSeal.Discovery.Error{reason: :invalid_options, retryable: false}}