Ctrl K
ComunidadGuías…

lib/audio_proxy/config.ex

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
defmodule AudioProxy.Config do
  @moduledoc """
  Runtime configuration, read from `AP_`-prefixed environment variables.

  The variable surface is defined by `docs/audio-proxy-api-v1.md` §6. Values are
  parsed and validated exactly once, during `AudioProxy.Application.start/2`, and
  stored in `:persistent_term` — reads on the request path are then a cheap
  global lookup with no GenServer in the way.

  Malformed values raise `AudioProxy.Config.Error` at boot, so a misconfigured
  container fails immediately instead of serving traffic with surprising
  defaults. Every error message names the offending variable.

  ## Variables

  | Variable | Type | Default |
  |---|---|---|
  | `AP_KEY` | hex-encoded binary, ≥ 32 bytes decoded | unset (`nil`) |
  | `AP_SALT` | hex-encoded binary | unset (`nil`) |
  | `AP_ALLOW_INSECURE` | boolean | `false` |
  | `AP_SOURCE_ALLOWLIST` | comma-separated list | `[]` |
  | `AP_LOCAL_ROOT` | existing directory, not `/` | unset (`nil`) — local sources disabled |
  | `AP_VARIANT_STORE` | scheme-tagged URL (`file:///path`, `s3://bucket`) | unset (`nil`) — no variant cache |
  | `AP_MAX_CONCURRENCY` | positive integer | `System.schedulers_online/0` |
  | `AP_MAX_PROBE_CONCURRENCY` | positive integer | `4 × AP_MAX_CONCURRENCY` |
  | `AP_QUEUE_SIZE` | non-negative integer | `32` |
  | `AP_READY_QUEUE_THRESHOLD` | non-negative integer, ≤ `AP_QUEUE_SIZE` | half the queue, floored, min 1 (`0` disables) |
  | `AP_MAX_SRC_BYTES` | positive integer | `2_000_000_000` |
  | `AP_MAX_VARIANT_BYTES` | positive integer | the effective `AP_MAX_SRC_BYTES` |
  | `AP_RENDER_TIMEOUT` | positive integer (seconds) | `300` |
  | `AP_PROBE_TIMEOUT` | positive integer (seconds) | `10` |
  | `AP_SERVE_MODE` | `redirect` \\| `proxy` | `:redirect` |
  | `AP_PRESIGN_TTL` | positive integer (seconds) | `300` |
  | `AP_LOG_LEVEL` | `debug` \\| `info` \\| `warning` \\| `error` | `:info` |
  | `AP_METRICS_BIND` | IP address literal | `127.0.0.1` |
  | `AP_METRICS_PORT` | positive integer | `9568` |
  | `AP_S3_ENDPOINT` | `http(s)://host[:port]` | unset (`nil`) — AWS proper |
  | `AP_S3_ADDRESSING` | `virtual` \\| `path` | `:virtual`, or `:path` with an endpoint |
  | `AP_S3_CA_BUNDLE` | readable PEM file | unset (`nil`) — system trust store |
  | `AP_VARIANT_S3_ENDPOINT` | `http(s)://host[:port]` | the source-side endpoint |
  | `AP_VARIANT_S3_ADDRESSING` | `virtual` \\| `path` | derived; see below |
  | `AP_VARIANT_S3_CA_BUNDLE` | readable PEM file | the source-side bundle |
  | `AP_VARIANT_S3_ACCESS_KEY_ID` | string | the source-side credential |
  | `AP_VARIANT_S3_SECRET_ACCESS_KEY` | string | the source-side credential |
  | `AP_VARIANT_S3_REGION` | string | the source-side credential |
  | `AP_VARIANT_S3_SESSION_TOKEN` | string | the source-side token with the rest of an inherited identity; unset once the store has its own |
  | `AP_ALLOW_ORIGIN` | `*` or `scheme://host[:port]` | unset (`nil`) — no CORS headers |

  The listener port is read from `AP_PORT`, falling back to `PORT` (which the
  worktree workflow sets to the branch's hashed port), then to `4000`.

  ## The AWS variables

  S3 credentials are the one thing here **not** `AP_`-prefixed:
  `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` and
  `AWS_REGION` (or `AWS_DEFAULT_REGION`). Every tool that produces credentials
  already writes those names, and renaming them would mean an operator
  translating a working environment into this proxy's dialect for no gain.

  They are read *here* rather than left to `ex_aws`'s own resolution, which
  would otherwise reach into application env and, on EC2, into IMDS. Keeping
  them in this map means the whole configuration surface is still one thing,
  validated once, at boot — and that what the proxy will use is what the
  operator set, not whatever a metadata endpoint happened to answer.
  `AudioProxy.S3` hands them to `ex_aws` per request.

  Validated all-or-nothing: a half-configured client fails at its first
  request instead of at boot, which is the failure this module exists to
  prevent.

  ## The two S3 profiles

  Reading sources and running the variant store are two jobs, and a
  deployment may want to give them different providers (sources on R2,
  variants on AWS) or different principals (a read-only credential for the
  source buckets, a writing one for the store). So the config carries two
  profiles of the same shape: `:s3`, read by everything that fetches a
  source, and `:variant_s3`, read by every variant-store operation.

  `AP_VARIANT_S3_*` is an *override* group. Each variable that is unset falls
  back to the source-side value, so a deployment that sets none of them has
  two identical profiles and behaves exactly as it did before the split.

  Two rules make that fallback well-behaved:

    * **Credentials are group-atomic.** Set any of the variant credential
      variables and the identity is the variant's own: `ACCESS_KEY_ID`,
      `SECRET_ACCESS_KEY` and `REGION` must all be set, and `SESSION_TOKEN`
      is then optional and read only from `AP_VARIANT_S3_SESSION_TOKEN` — a
      token belongs to the principal that minted it, so the source's is not
      carried onto another identity. Set *none* of them and the whole source
      identity is inherited, token included, which is what makes an
      un-overridden deployment identical to the pre-split one. A store
      half-borrowing the source's identity is never a deployment intent, so
      the partial set aborts boot naming exactly what is missing.

    * **Addressing derives per side.** With `AP_VARIANT_S3_ENDPOINT` set and
      `AP_VARIANT_S3_ADDRESSING` unset, the style is derived from the
      *variant* endpoint by the same rule the shared pair uses — so an R2
      store behind AWS sources gets path-style without being told. With no
      variant endpoint there is nothing to derive from that the source has not
      already derived from, so the source's effective style is inherited
      whole; that is what keeps an explicit `AP_S3_ADDRESSING` from being
      silently dropped on the store side.

  There is no way to spell "the variant store is on AWS proper while the
  sources are not" by *unsetting* the endpoint — an unset override means
  inherit. Write it out instead: `AP_VARIANT_S3_ENDPOINT=https://s3.<region>.amazonaws.com`
  with `AP_VARIANT_S3_ADDRESSING=virtual`, which is the request AWS's own
  default produces. `docs/s3-providers.md` has the worked example.
  """

  defmodule Error do
    @moduledoc "Raised when an `AP_`-prefixed variable holds an unusable value."
    defexception [:message]
  end

  @storage_key __MODULE__

  # RFC 2104 §3 recommends an HMAC key at least as long as the hash output
  # (32 bytes for SHA-256); a shorter key caps the effective security at its
  # own length, so anything below the recommendation is rejected at boot.
  @min_key_bytes 32

  @default_port 4000
  @default_queue_size 32
  @default_max_src_bytes 2_000_000_000
  @default_render_timeout 300
  @serve_modes [:redirect, :proxy]

  # A probe reads container headers and stops; it never decodes a stream, so it
  # has no reason to share the render budget. Short on purpose: `/info` sits in
  # front of a player deciding what to request, and a source that cannot answer
  # in ten seconds is one the client should hear about rather than wait on.
  @default_probe_timeout 10

  # `AP_MAX_PROBE_CONCURRENCY`'s default, as a multiple of the render cap. A
  # probe turns out to cost ~45 ms, a good part of it contended CPU — measured
  # in `openspec/changes/archive/2026-08-07-bound-probe-concurrency/design.md`,
  # and mostly dynamic linking rather than the header read
  # — so probes and renders do compete for the same cores, and a ceiling has to
  # be some function of how many there are. Four times the render cap puts the
  # default past the measured throughput knee while keeping the two pools
  # visibly different sizes: the probe path exists so that a header read does
  # not queue behind an encoder, and a ratio of one would hide that in the
  # arithmetic even though the code still had it.
  @probe_concurrency_factor 4

  # How long a HIT's presigned URL stays valid. Short on purpose: the URL is
  # handed to one client for one playback, and the response carrying it is
  # `no-store`, so nothing needs it to outlive the redirect it arrived in.
  @default_presign_ttl 300

  # The scrape endpoint is unsigned, so the bind address *is* its access
  # control (API doc §2). Loopback by default: a sidecar scraper and a
  # `kubectl port-forward` both reach it, and nothing else does. Widening it to
  # `0.0.0.0` is a deliberate act with a network policy behind it, not
  # something an operator arrives at by leaving a variable unset.
  @default_metrics_bind {127, 0, 0, 1}

  # The port the Prometheus project's own allocation list reserves for a
  # generic exporter, so a scrape config written from habit finds it.
  @default_metrics_port 9568

  # Logger's own levels, narrowed to the four an operator has a reason to pick.
  # `:notice` and friends exist in OTP's scale but nothing here emits them, so
  # offering them would only be a way to configure a level with no meaning.
  @log_levels [:debug, :info, :warning, :error]
  @default_log_level :info

  # The two ways an S3 request can name its bucket. `:virtual` puts it in the
  # host (`bucket.host/key`), `:path` in the path (`host/bucket/key`). No
  # third style exists today, but the enum leaves room for one — and reads
  # better in a config table than a boolean whose default depends on another
  # variable.
  @s3_addressing_styles [:virtual, :path]

  # The key prefix the `s3://` boot probe writes under. Neither the leading
  # dot nor the slash is in `AudioProxy.CacheKey`'s alphabet, and
  # `AudioProxy.VariantStore.S3` refuses anything that is not a cache key — so
  # this names an object no variant can ever be stored as.
  @store_probe_prefix ".audio-proxy-boot-probe/"

  @truthy ~w(1 true yes on)
  @falsy ~w(0 false no off)

  @type t :: %{
          port: pos_integer(),
          key: binary() | nil,
          salt: binary() | nil,
          allow_insecure: boolean(),
          source_allowlist: [String.t()],
          local_root: String.t() | nil,
          variant_store: AudioProxy.VariantStore.config() | nil,
          max_concurrency: pos_integer(),
          max_probe_concurrency: pos_integer(),
          queue_size: non_neg_integer(),
          ready_queue_threshold: non_neg_integer(),
          max_src_bytes: pos_integer(),
          max_variant_bytes: pos_integer(),
          render_timeout: pos_integer(),
          probe_timeout: pos_integer(),
          serve_mode: :redirect | :proxy,
          presign_ttl: pos_integer(),
          log_level: :debug | :info | :warning | :error,
          metrics_bind: :inet.ip_address(),
          metrics_port: pos_integer(),
          allow_origin: String.t() | nil,
          s3: s3(),
          variant_s3: s3()
        }

  @typedoc """
  The S3 settings, as one group.

  All of `region`, `access_key_id` and `secret_access_key` are `nil` together
  when S3 is not configured — the partial set is refused at boot, so anything
  that finds one of them finds all three.

  `addressing` and `ca_bundle` are independent of the credentials: both have a
  usable value whether or not S3 is configured at all.

  Two of these are built, not one: see "The two S3 profiles" above.
  """
  @type s3 :: %{
          region: String.t() | nil,
          access_key_id: String.t() | nil,
          secret_access_key: String.t() | nil,
          session_token: String.t() | nil,
          endpoint: URI.t() | nil,
          addressing: :virtual | :path,
          ca_bundle: String.t() | nil
        }

  @doc """
  Parses and validates the environment, then stores the result for `all/0`.

  Called from `AudioProxy.Application.start/2`. Raises `Error` on the first
  unusable value.
  """
  @spec load!(map()) :: t()
  def load!(env \\ System.get_env()) do
    env |> build!() |> put_all() |> probe_store!()
  end

  @doc """
  Builds a validated config map from an environment map, without storing it.

  This is the function tests exercise for parsing and validation. It writes
  nothing to `:persistent_term`; it does touch the filesystem where a value
  *is* a filesystem claim — `AP_LOCAL_ROOT` must be an existing directory,
  and a `file://` variant store is probed for writability.

  It does **not** touch the network. An `s3://` variant store is parsed and
  shape-checked here and probed by `load!/1`, after the config is stored —
  because the probe is an S3 request, and `AudioProxy.S3` reads its
  credentials, endpoint and addressing from the very map being built. The
  asymmetry with the `file://` probe is that one: a path is provable from
  itself, a bucket is not.
  """
  @spec build!(map()) :: t()
  def build!(env) when is_map(env) do
    # Resolved before the map so `AP_MAX_VARIANT_BYTES` can fall back to the
    # *effective* source ceiling rather than to the shipped default: an operator
    # who already raised `AP_MAX_SRC_BYTES` to accept large masters keeps the
    # retention bound they have today instead of being silently tightened.
    # Resolving here, once, also keeps the coordinator's hot path reading one
    # number with no fallback logic in it.
    max_src_bytes = integer(env, "AP_MAX_SRC_BYTES", @default_max_src_bytes, :positive)

    # Read ahead of the map for the same shape of reason: the default for
    # `AP_READY_QUEUE_THRESHOLD` is a fraction of the queue, and a fixed number
    # would be unreachable under a small queue and trip far too late under a
    # large one.
    queue_size = integer(env, "AP_QUEUE_SIZE", @default_queue_size, :non_negative)

    # Read ahead of the map for the same reason again: the probe ceiling's
    # default is a multiple of the render cap, so the render cap has to exist
    # before it is computed.
    max_concurrency = integer(env, "AP_MAX_CONCURRENCY", System.schedulers_online(), :positive)

    # Read ahead of the map because the store-side profile is defined by
    # fallback: every `AP_VARIANT_S3_*` an operator did not set is the
    # source-side value, so the source-side group has to exist first.
    s3 = s3(env)

    validate!(%{
      port: port(env),
      key: hex(env, "AP_KEY", @min_key_bytes),
      salt: hex(env, "AP_SALT"),
      allow_insecure: boolean(env, "AP_ALLOW_INSECURE", false),
      source_allowlist: list(env, "AP_SOURCE_ALLOWLIST"),
      local_root: directory(env, "AP_LOCAL_ROOT"),
      variant_store: store(env, "AP_VARIANT_STORE"),
      max_concurrency: max_concurrency,
      max_probe_concurrency:
        integer(
          env,
          "AP_MAX_PROBE_CONCURRENCY",
          max_concurrency * @probe_concurrency_factor,
          :positive
        ),
      queue_size: queue_size,
      ready_queue_threshold:
        integer(
          env,
          "AP_READY_QUEUE_THRESHOLD",
          default_ready_queue_threshold(queue_size),
          :non_negative
        ),
      max_src_bytes: max_src_bytes,
      max_variant_bytes: integer(env, "AP_MAX_VARIANT_BYTES", max_src_bytes, :positive),
      render_timeout: integer(env, "AP_RENDER_TIMEOUT", @default_render_timeout, :positive),
      probe_timeout: integer(env, "AP_PROBE_TIMEOUT", @default_probe_timeout, :positive),
      serve_mode: enum(env, "AP_SERVE_MODE", @serve_modes, :redirect),
      presign_ttl: integer(env, "AP_PRESIGN_TTL", @default_presign_ttl, :positive),
      log_level: enum(env, "AP_LOG_LEVEL", @log_levels, @default_log_level),
      metrics_bind: ip(env, "AP_METRICS_BIND", @default_metrics_bind),
      metrics_port: integer(env, "AP_METRICS_PORT", @default_metrics_port, :positive),
      allow_origin: origin(env, "AP_ALLOW_ORIGIN"),
      s3: s3,
      variant_s3: variant_s3(env, s3)
    })
  end

  @doc "Returns the whole stored config."
  @spec all() :: t()
  def all, do: :persistent_term.get(@storage_key)

  @doc "Returns one stored config value."
  @spec get(atom()) :: term()
  def get(key) when is_atom(key), do: Map.fetch!(all(), key)

  @doc """
  Replaces the stored config wholesale.

  Used by `load!/1` at boot and by `AudioProxy.ConfigHelper` in tests. Nothing on
  the request path should call this.
  """
  @spec put_all(t()) :: t()
  def put_all(config) when is_map(config) do
    :persistent_term.put(@storage_key, config)
    config
  end

  # The `AP_` surface, as data, so a document claiming to list it can be checked
  # against something rather than reviewed. Hand-maintained, one screen away
  # from the `build!/1` lines that read each of these — the same trade
  # `AudioProxy.ErrorJSON`'s `@representative_errors` makes, and with the same
  # backstop: `AudioProxy.ConfigTest` scans this file for `(env, "AP_…")` reads
  # and fails when the two disagree, so forgetting a row here is a red test on
  # the line you were already editing rather than a document that quietly
  # stopped covering a variable.
  #
  # `AWS_*` is deliberately absent: those names are the AWS ecosystem's, not
  # this proxy's, and both documents describe them in prose beside the table.
  @variables ~w(
    AP_PORT
    AP_KEY
    AP_SALT
    AP_ALLOW_INSECURE
    AP_SOURCE_ALLOWLIST
    AP_LOCAL_ROOT
    AP_VARIANT_STORE
    AP_MAX_CONCURRENCY
    AP_MAX_PROBE_CONCURRENCY
    AP_QUEUE_SIZE
    AP_READY_QUEUE_THRESHOLD
    AP_MAX_SRC_BYTES
    AP_MAX_VARIANT_BYTES
    AP_RENDER_TIMEOUT
    AP_PROBE_TIMEOUT
    AP_SERVE_MODE
    AP_PRESIGN_TTL
    AP_LOG_LEVEL
    AP_METRICS_BIND
    AP_METRICS_PORT
    AP_ALLOW_ORIGIN
    AP_S3_ENDPOINT
    AP_S3_ADDRESSING
    AP_S3_CA_BUNDLE
    AP_VARIANT_S3_ENDPOINT
    AP_VARIANT_S3_ADDRESSING
    AP_VARIANT_S3_CA_BUNDLE
    AP_VARIANT_S3_ACCESS_KEY_ID
    AP_VARIANT_S3_SECRET_ACCESS_KEY
    AP_VARIANT_S3_REGION
    AP_VARIANT_S3_SESSION_TOKEN
  )

  @doc """
  The `AP_`-prefixed environment variables this module reads.

  Published so the configuration tables in `README.md` and `llms-full.txt` can
  be checked against the implementation instead of against a reviewer's memory
  — a stale default in a file an agent reads *instead of* the code is a wrong
  answer delivered confidently.

  Names only, not defaults: several defaults are derived rather than literal
  (`AP_MAX_PROBE_CONCURRENCY` is a multiple of `AP_MAX_CONCURRENCY`,
  `AP_MAX_VARIANT_BYTES` inherits the effective `AP_MAX_SRC_BYTES`), and the
  two documents render those differently on purpose, for different readers.

  Excludes the `AWS_*` credentials variables, which are read here too but are
  not this proxy's to define; see the moduledoc.
  """
  @spec variables() :: [String.t()]
  def variables, do: @variables

  @doc "The serve modes `AP_SERVE_MODE` accepts."
  @spec serve_modes() :: [atom()]
  def serve_modes, do: @serve_modes

  @doc "The levels `AP_LOG_LEVEL` accepts."
  @spec log_levels() :: [atom()]
  def log_levels, do: @log_levels

  @doc "The addressing styles `AP_S3_ADDRESSING` accepts."
  @spec s3_addressing_styles() :: [atom()]
  def s3_addressing_styles, do: @s3_addressing_styles

  ## Parsers

  # Half the queue: deep enough that a node shrugging off a burst is not
  # instantly ejected, shallow enough that the orchestrator learns about the
  # backlog well before the queue is full and requests start becoming 429s.
  # A queue of zero has no depth to measure, so readiness is disabled rather
  # than permanently tripped by a gauge that can only ever read `0`.
  defp default_ready_queue_threshold(0), do: 0
  defp default_ready_queue_threshold(queue_size), do: max(1, div(queue_size, 2))

  defp port(env) do
    cond do
      value = fetch(env, "AP_PORT") -> parse_integer!("AP_PORT", value, :positive)
      value = fetch(env, "PORT") -> parse_integer!("PORT", value, :positive)
      true -> @default_port
    end
  end

  defp hex(env, var, min_bytes \\ 0) do
    with value when is_binary(value) <- fetch(env, var) do
      case Base.decode16(value, case: :mixed) do
        {:ok, decoded} ->
          if byte_size(decoded) >= min_bytes do
            decoded
          else
            raise Error,
                  "#{var} must decode to at least #{min_bytes} bytes (≥ #{min_bytes * 2} hex characters), got #{byte_size(decoded)} bytes"
          end

        :error ->
          raise Error,
                "#{var} must be hex-encoded (an even number of characters in 0-9a-fA-F), got: #{inspect(value)}"
      end
    end
  end

  defp boolean(env, var, default) do
    with value when is_binary(value) <- fetch(env, var) do
      downcased = String.downcase(value)

      cond do
        downcased in @truthy ->
          true

        downcased in @falsy ->
          false

        true ->
          raise Error,
                "#{var} must be one of #{inspect(@truthy ++ @falsy)}, got: #{inspect(value)}"
      end
    else
      nil -> default
    end
  end

  defp list(env, var) do
    case fetch(env, var) do
      nil ->
        []

      value ->
        value
        |> String.split(",")
        |> Enum.map(&String.trim/1)
        |> Enum.reject(&(&1 == ""))
    end
  end

  # `AP_VARIANT_STORE` is a scheme-tagged URL, parsed the way a source is:
  # the scheme picks the backend, the rest is the backend's address. Rejected
  # here rather than at first use so a container pointed at a store it cannot
  # write fails to boot instead of rendering everything twice, silently.
  defp store(env, var) do
    with value when is_binary(value) <- fetch(env, var) do
      case URI.new(value) do
        # A query or fragment has no meaning here; dropping one silently
        # would leave the operator believing it did something.
        {:ok, %URI{scheme: "file", query: query, fragment: fragment}}
        when is_binary(query) or is_binary(fragment) ->
          raise Error,
                "#{var} must be a bare directory URL with no query or fragment, got: #{inspect(value)}"

        {:ok, %URI{scheme: "file", host: host, path: path}}
        when host in [nil, ""] and is_binary(path) and path != "" ->
          store_directory!(var, value, path)

        # `file://var/cache` parses `var` as a *host* — an easy two-slash typo
        # that would otherwise fail as a nonexistent directory named `/cache`.
        {:ok, %URI{scheme: "file", host: host}} when is_binary(host) and host != "" ->
          raise Error,
                "#{var} file URLs take three slashes (file:///path); #{inspect(value)} reads #{inspect(host)} as a host"

        # The same refusal as the `file://` branch above, for the same reason:
        # a query or fragment means nothing to a bucket, and dropping one
        # silently would leave the operator believing it did something.
        {:ok, %URI{scheme: "s3", query: query, fragment: fragment}}
        when is_binary(query) or is_binary(fragment) ->
          raise Error,
                "#{var} must be a bare bucket URL with no query or fragment, got: #{inspect(value)}"

        # Refused for the sharper version of the query/fragment reason, and the
        # same one `s3_endpoint/2` gives twenty lines down: S3 credentials come
        # from the AWS variables and nothing reads userinfo, so
        # `s3://KEY:SECRET@bucket` would look to an operator like credentials
        # supplied and behave like credentials omitted — failing later as a
        # signature error that names nothing. The message does not echo the
        # value, so a boot failure cannot put a secret in a log.
        {:ok, %URI{scheme: "s3", userinfo: userinfo}} when is_binary(userinfo) ->
          raise Error,
                "#{var} must not carry credentials — S3 credentials come from AWS_ACCESS_KEY_ID " <>
                  "and AWS_SECRET_ACCESS_KEY, and userinfo here would be silently ignored"

        # A port names an endpoint, and the endpoint is `AP_S3_ENDPOINT`'s.
        # Accepting one here would drop it and address the store somewhere else
        # entirely.
        {:ok, %URI{scheme: "s3", port: port}} when is_integer(port) ->
          raise Error,
                "#{var} s3 URLs name a bucket only; a port belongs in AP_S3_ENDPOINT, got: #{inspect(value)}"

        {:ok, %URI{scheme: "s3", host: bucket, path: path}}
        when is_binary(bucket) and bucket != "" and path in [nil, "", "/"] ->
          {:s3, bucket!(var, value, bucket)}

        # A key prefix would have to be prepended to every cache key and
        # stripped from every lookup, and nothing here does either — so
        # `s3://bucket/variants` would quietly cache into `bucket` and ignore
        # the half the operator wrote it for.
        {:ok, %URI{scheme: "s3", host: bucket}} when is_binary(bucket) and bucket != "" ->
          raise Error,
                "#{var} s3 URLs name a bucket and nothing else (s3://bucket); a key prefix is not supported, got: #{inspect(value)}"

        {:ok, %URI{scheme: "s3"}} ->
          raise Error, "#{var} must name a bucket (s3://bucket), got: #{inspect(value)}"

        {:ok, %URI{scheme: scheme}} ->
          raise Error,
                "#{var} must be a scheme-tagged URL (file:///path or s3://bucket), got scheme #{inspect(scheme)} in #{inspect(value)}"

        {:error, _part} ->
          raise Error,
                "#{var} must be a scheme-tagged URL (file:///path or s3://bucket), got: #{inspect(value)}"
      end
    end
  end

  # The S3 group, parsed together because it only means anything together. An
  # access key with no secret, or either with no region, cannot sign — and
  # refusing the partial set at boot turns a run of 500s on the first S3
  # request into a container that does not start.
  defp s3(env) do
    id = fetch(env, "AWS_ACCESS_KEY_ID")
    secret = fetch(env, "AWS_SECRET_ACCESS_KEY")
    region = fetch(env, "AWS_REGION") || fetch(env, "AWS_DEFAULT_REGION")

    credentials!(id, secret, region)

    endpoint = s3_endpoint(env, "AP_S3_ENDPOINT")

    %{
      region: region,
      access_key_id: id,
      secret_access_key: secret,
      session_token: fetch(env, "AWS_SESSION_TOKEN"),
      endpoint: endpoint,
      addressing:
        enum(env, "AP_S3_ADDRESSING", @s3_addressing_styles, default_addressing(endpoint)),
      ca_bundle: readable_file(env, "AP_S3_CA_BUNDLE")
    }
  end

  # The store-side profile: the same shape as `s3/1`, built by overriding it.
  # Every variable an operator did not set reads through to `source`, so a
  # deployment with no `AP_VARIANT_S3_*` at all gets two equal maps and the
  # store behaves exactly as it did when there was one profile.
  #
  # The endpoint is read before the credentials are merged because addressing
  # needs to know whether *this* side was given an endpoint of its own —
  # inheriting one is not the same as being handed one, and only the second
  # re-runs the derivation. See the moduledoc.
  defp variant_s3(env, source) do
    endpoint = s3_endpoint(env, "AP_VARIANT_S3_ENDPOINT")

    credentials =
      variant_credentials!(
        source,
        fetch(env, "AP_VARIANT_S3_ACCESS_KEY_ID"),
        fetch(env, "AP_VARIANT_S3_SECRET_ACCESS_KEY"),
        fetch(env, "AP_VARIANT_S3_REGION"),
        fetch(env, "AP_VARIANT_S3_SESSION_TOKEN")
      )

    Map.merge(credentials, %{
      endpoint: endpoint || source.endpoint,
      addressing: variant_addressing(env, source, endpoint),
      ca_bundle: readable_file(env, "AP_VARIANT_S3_CA_BUNDLE") || source.ca_bundle
    })
  end

  # With an endpoint of its own, the store derives its style from that endpoint
  # exactly as the shared pair does — which is what makes an R2 store behind
  # AWS sources work unconfigured. With no endpoint of its own there is nothing
  # to derive from that the source did not already derive from, so the source's
  # *effective* style is inherited: re-deriving would discard an explicit
  # `AP_S3_ADDRESSING` on the store side only, which is the one asymmetry
  # nobody asked for.
  defp variant_addressing(env, source, nil) do
    enum(env, "AP_VARIANT_S3_ADDRESSING", @s3_addressing_styles, source.addressing)
  end

  defp variant_addressing(env, _source, endpoint) do
    enum(env, "AP_VARIANT_S3_ADDRESSING", @s3_addressing_styles, default_addressing(endpoint))
  end

  # Identity does not degrade per variable. An access key from one principal
  # with a secret from another signs nothing, and a variant key against the
  # source's region is a signature the store rejects — so either the store has
  # an identity of its own or it has the source's, and the half-set case is a
  # boot failure naming what is missing.
  #
  # The session token follows the identity rather than the variable, and the
  # two clauses below are where that is decided. Nothing set: the source
  # identity is taken whole, token included — anything less would make an
  # un-overridden deployment differ from the pre-split one, which is the
  # guarantee this group is built around. Anything set: the token is read from
  # `AP_VARIANT_S3_SESSION_TOKEN` alone, because it is scoped to the principal
  # that minted it and carrying the source's onto the variant's key produces
  # credentials no store will accept.
  defp variant_credentials!(source, nil, nil, nil, nil) do
    Map.take(source, [:region, :access_key_id, :secret_access_key, :session_token])
  end

  defp variant_credentials!(_source, id, secret, region, token) do
    missing =
      for {var, nil} <- [
            {"AP_VARIANT_S3_ACCESS_KEY_ID", id},
            {"AP_VARIANT_S3_SECRET_ACCESS_KEY", secret},
            {"AP_VARIANT_S3_REGION", region}
          ],
          do: var

    unless missing == [] do
      raise Error,
            "#{Enum.join(missing, ", ")} must be set too — the AP_VARIANT_S3_* credentials are " <>
              "all-or-nothing, and a variant store cannot sign with half of one identity and " <>
              "half of another. Unset every AP_VARIANT_S3_* credential variable to have the " <>
              "store use the AWS_* credentials the sources use"
    end

    %{region: region, access_key_id: id, secret_access_key: secret, session_token: token}
  end

  # The asymmetry is the point. No endpoint means AWS, where virtual-hosted is
  # what regions launched after 2019 accept at all. An endpoint means an
  # S3-compatible store, and every one this project documents — MinIO, Ceph,
  # B2, DigitalOcean, Scaleway, Hetzner — is working today on path-style.
  # Defaulting a custom endpoint to virtual-hosted would break every existing
  # deployment to fix one that does not exist yet.
  defp default_addressing(nil), do: :virtual
  defp default_addressing(%URI{}), do: :path

  defp credentials!(nil, nil, _region), do: :ok

  defp credentials!(id, secret, region) do
    cond do
      is_nil(id) or is_nil(secret) ->
        raise Error,
              "AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must be set together — one without the other cannot sign a request"

      # Signing is region-scoped: the region is inside the credential scope,
      # so a wrong or missing one is a signature every store rejects. There is
      # no safe default to guess, least of all against a non-AWS endpoint.
      is_nil(region) ->
        raise Error,
              "AWS_REGION (or AWS_DEFAULT_REGION) must be set when AWS credentials are — it is part of every signature"

      true ->
        :ok
    end
  end

  # An origin and nothing more: the path is decided per request by the
  # addressing style, so a path here would either be ignored or silently
  # prefixed onto every key. Both are worse than refusing it.
  #
  # Userinfo is refused for the sharper version of the same reason. S3
  # credentials come from the AWS variables, and nothing downstream reads
  # userinfo — so `http://key:secret@minio:9000` would look to an operator
  # like credentials supplied and behave like credentials omitted, failing
  # later as a signature error that names nothing. The message does not echo
  # the value, so a boot failure cannot put a secret in a log.
  defp s3_endpoint(env, var) do
    with value when is_binary(value) <- fetch(env, var) do
      case URI.new(value) do
        {:ok, %URI{userinfo: userinfo}} when is_binary(userinfo) ->
          raise Error,
                "#{var} must not carry credentials — S3 credentials come from AWS_ACCESS_KEY_ID " <>
                  "and AWS_SECRET_ACCESS_KEY, and userinfo here would be silently ignored"

        {:ok, %URI{scheme: scheme, host: host, path: path, query: nil, fragment: nil} = uri}
        when scheme in ["http", "https"] and is_binary(host) and host != "" and
               path in [nil, "", "/"] ->
          %{uri | path: nil}

        _otherwise ->
          raise Error,
                "#{var} must be an origin URL with no path, query or fragment (http://minio:9000), got: #{inspect(value)}"
      end
    end
  end

  # An origin, in the sense `Access-Control-Allow-Origin` means it: scheme,
  # host, optional port, and nothing else. `*` is the other accepted value,
  # verbatim.
  #
  # The header is emitted exactly as configured and the browser compares it to
  # its own `Origin` **byte for byte**, so the value has to be spelled the way
  # a browser spells it. That makes a whole family of near-misses worth
  # refusing rather than accepting: a trailing slash, an uppercase scheme or
  # host, a trailing dot on the host, an explicitly written default port. Each
  # is a URL that means the right origin to a person and matches nothing at
  # all in a browser.
  #
  # Refused at boot for the reason every value here is, and more sharply: a
  # CORS header that cannot match is indistinguishable at the client from no
  # CORS at all, so the misconfiguration surfaces as an opaque fetch failure
  # in a page the operator is not watching, with nothing anywhere naming the
  # variable. The error carries the canonical spelling, since knowing the
  # value is wrong is only half of what an operator needs.
  defp origin(env, var) do
    case fetch(env, var) do
      nil -> nil
      "*" -> "*"
      value -> origin!(var, value, URI.new(value))
    end
  end

  defp origin!(var, value, {:ok, %URI{scheme: scheme, host: host, path: path} = uri})
       when scheme in ["http", "https"] and is_binary(host) and host != "" and path in [nil, ""] and
              uri.query == nil and uri.fragment == nil and uri.userinfo == nil do
    case canonical_origin(uri) do
      ^value -> value
      canonical -> raise Error, origin_message(var, value, canonical)
    end
  end

  defp origin!(var, value, _parsed), do: raise(Error, origin_message(var, value, nil))

  # `URI` downcases the scheme but not the host, so both are done here. What
  # is left after case is the trailing dot (a legal FQDN root that no browser
  # sends), the default port (which a browser omits), and the brackets IPv6
  # needs and `URI` strips.
  defp canonical_origin(%URI{scheme: scheme, host: host, port: port}) do
    host = host |> String.downcase() |> String.trim_trailing(".") |> bracket_ipv6()

    case {scheme, port} do
      {"https", 443} -> "https://#{host}"
      {"http", 80} -> "http://#{host}"
      {scheme, port} -> "#{scheme}://#{host}:#{port}"
    end
  end

  defp bracket_ipv6(host) do
    if String.contains?(host, ":"), do: "[#{host}]", else: host
  end

  defp origin_message(var, value, nil) do
    "#{var} must be * or an origin — scheme, host and optional port, with no " <>
      "path, query, fragment or credentials (https://app.example.com), got: #{inspect(value)}"
  end

  defp origin_message(var, value, canonical) do
    "#{var} must be * or an origin in its canonical form, since a browser compares it " <>
      "byte for byte against the Origin it sends — got: #{inspect(value)}, " <>
      "did you mean #{inspect(canonical)}?"
  end

  # Existence and writability are both proved at boot, and writability by the
  # exact write the store performs — a file created under `<root>/tmp` — not
  # by mode bits, which say nothing about read-only mounts.
  defp store_directory!(var, value, path) do
    expanded = Path.expand(path)

    # Same refusal, and the same accident, as AP_LOCAL_ROOT's: nobody types
    # `file:///` deliberately, but `file://${DIR}/` with `DIR` unset produces
    # exactly it — and a store rooted at `/` would fan its variants out into
    # the filesystem root.
    if expanded == "/" do
      raise Error,
            "#{var} must not be the filesystem root — point it at the directory you mean, e.g. file:///var/cache/audio_proxy"
    end

    unless File.dir?(expanded) do
      raise Error, "#{var} must name an existing directory, got: #{inspect(value)}"
    end

    probe =
      Path.join([expanded, "tmp", ".boot-probe-#{System.unique_integer([:positive])}"])

    with :ok <- File.mkdir_p(Path.dirname(probe)),
         :ok <- File.write(probe, ""),
         :ok <- File.rm(probe) do
      {:file, expanded}
    else
      {:error, reason} ->
        raise Error,
              "#{var} must name a writable directory, got: #{inspect(value)} (#{inspect(reason)})"
    end
  end

  # S3's own bounds, checked here so a typo fails at boot naming the variable
  # rather than three layers down as a signature the store rejects. The
  # character set is left to the store: the rules differ between AWS and its
  # compatibles, and refusing a name a store would have accepted is worse than
  # forwarding one it will not.
  #
  # The name arrives lowercased, because `URI` lowercases a host and this one is
  # written as one. Bucket names are lowercase everywhere a bucket can be
  # created today, so the only value this loses is a legacy uppercase AWS name
  # from before 2018 — and that one cannot be addressed virtual-hosted either.
  defp bucket!(_var, _value, bucket) when byte_size(bucket) in 3..63, do: bucket

  defp bucket!(var, value, _bucket) do
    raise Error, "#{var} bucket names are 3–63 characters, got: #{inspect(value)}"
  end

  # The `s3://` half of "validated at boot by the operation the store actually
  # needs". Runs from `load!/1` rather than `build!/1` — see `build!/1` for
  # why — and does what the `file://` branch does with a probe file: write,
  # then take it back, so a bucket that is reachable and readable but refuses
  # writes fails the container instead of discarding every write-back in
  # silence.
  #
  # The key is outside the cache-key alphabet, so the probe cannot collide
  # with a variant however unlucky the timing.
  defp probe_store!(%{variant_store: {:s3, bucket}} = config) do
    unless AudioProxy.S3.configured?(:store) do
      raise Error,
            "AP_VARIANT_STORE=s3://#{bucket} needs AWS credentials — set AWS_ACCESS_KEY_ID, " <>
              "AWS_SECRET_ACCESS_KEY and AWS_REGION (or the AP_VARIANT_S3_* equivalents, if " <>
              "the store has an identity of its own), or use a file:/// store"
    end

    # The probe is a real S3 request, and a real S3 request needs the `:httpc`
    # profile `AudioProxy.S3.HttpClient` drives — which `AudioProxy.Application`
    # starts *after* this, because it sizes the pool from the config being built
    # here. Starting it is idempotent, so asking for it rather than depending on
    # boot order is cheaper than the ordering constraint: without this the probe
    # exits with `noproc` from inside `:gen_server.call`, and the container dies
    # with a stack trace instead of the message this function exists to print.
    AudioProxy.S3.HttpClient.setup!(config.max_concurrency)

    key = @store_probe_prefix <> Integer.to_string(System.unique_integer([:positive]))

    # Not a `with`: the two failures are different failures and an operator
    # needs to be told which one happened. A refused *write* means the bucket
    # cannot back a variant store at all. A refused *delete* means it can — the
    # write worked — and that this probe just left an object behind, which a
    # crashloop would repeat once per attempt. Saying "refused the boot probe"
    # for both would send someone to check write permissions that are fine.
    # Under the store's own profile, like every other store operation: a probe
    # that proved the *source* credentials could write would prove nothing
    # about the deployment it is guarding.
    case AudioProxy.S3.put_stream(bucket, key, [], [], :store) do
      :ok ->
        case AudioProxy.S3.delete(bucket, key, :store) do
          :ok ->
            config

          {:error, reason} ->
            raise Error,
                  "AP_VARIANT_STORE=s3://#{bucket} accepted the boot probe and would not let it " <>
                    "be removed (#{inspect(reason)}); the object is still at #{key} and these " <>
                    "credentials need DeleteObject as well as PutObject"
        end

      {:error, reason} ->
        raise Error,
              "AP_VARIANT_STORE must name a bucket this deployment can write, and " <>
                "s3://#{bucket} refused the boot probe write (#{inspect(reason)})"
    end
  end

  defp probe_store!(config), do: config

  # Every cross-field rule, in one place. Each stage takes the whole config and
  # returns it, so adding one is adding a line here rather than another clause
  # on a function that was already matching on two unrelated keys.
  defp validate!(config) do
    config
    |> validate_redirect_presign!()
    |> validate_ready_queue_threshold!()
    |> validate_metrics_port!()
  end

  # Cross-field: two Bandit listeners cannot share a port. Left to the runtime
  # this is an `:eaddrinuse` from inside a supervisor, which names a number and
  # neither of the two variables that produced it — and the operator has to know
  # there is a second listener at all to read it.
  #
  # It is not only a typo. The listener port falls back to `PORT`, which the
  # worktree workflow sets to the branch's hashed port, so a branch can hash to
  # 9568 and collide with a metrics default nobody touched.
  #
  # Only equality is checked. A bind address would have to be compared against
  # `0.0.0.0`, loopback and every interface the host actually has, and a rule
  # that refused a legitimate pairing would be worse than the error it saved.
  defp validate_metrics_port!(%{port: port, metrics_port: port}) do
    raise Error,
          "AP_METRICS_PORT (#{port}) must differ from the listener port — /metrics is served " <>
            "on a second listener, and two cannot share one port. The listener port comes from " <>
            "AP_PORT, or from PORT if that is unset"
  end

  defp validate_metrics_port!(config), do: config

  # Cross-field: the semaphore never queues more than `AP_QUEUE_SIZE` waiters,
  # so a threshold above it is a readiness check that can never trip. Refused
  # at boot rather than left to be discovered as a `/ready` that answered 200
  # through an outage.
  defp validate_ready_queue_threshold!(
         %{ready_queue_threshold: threshold, queue_size: queue_size} = config
       ) do
    if threshold > queue_size do
      raise Error,
            "AP_READY_QUEUE_THRESHOLD (#{threshold}) must not exceed AP_QUEUE_SIZE (#{queue_size}) — " <>
              "queue depth cannot reach it, so /ready could never report the node as unready"
    end

    config
  end

  # Cross-field: redirect serving is a 302 to a presigned variant URL, which
  # only a backend with the `:presign` capability can produce. Refused at boot
  # — naming both variables, since either can resolve it — rather than failing
  # on every HIT.
  defp validate_redirect_presign!(
         %{serve_mode: :redirect, variant_store: {scheme, _} = store} = config
       ) do
    backend = AudioProxy.VariantStore.backend_for(store)

    if :presign in backend.capabilities() do
      config
    else
      raise Error,
            "AP_SERVE_MODE=redirect serves cache hits via presigned URLs, which a #{scheme}:// " <>
              "AP_VARIANT_STORE cannot produce; set AP_SERVE_MODE=proxy or use a store that can presign"
    end
  end

  defp validate_redirect_presign!(config), do: config

  # Checked here rather than on the request path so that a container pointed at
  # a directory that is not mounted fails to boot, instead of answering every
  # local source with a 404 that looks like a missing file.
  defp directory(env, var) do
    with value when is_binary(value) <- fetch(env, var) do
      expanded = Path.expand(value)

      cond do
        # Every relative path is "under" `/`, so a root of `/` is confinement
        # switched off. Nobody types it deliberately; `AP_LOCAL_ROOT=${DIR}/`
        # with `DIR` unset produces it, which is exactly why it fails loudly at
        # boot rather than serving the filesystem.
        expanded == "/" ->
          raise Error,
                "#{var} must not be the filesystem root — it would serve every file on the host; point it at the directory you mean to serve"

        not File.dir?(expanded) ->
          raise Error, "#{var} must be an existing directory, got: #{inspect(value)}"

        true ->
          expanded
      end
    end
  end

  # Readability is proved by reading, not by mode bits — which say nothing
  # about a secret mounted for another user or a path that is a directory. The
  # contents are discarded; `:ssl` reads the file itself on the first
  # handshake, and the point here is that a bad path fails at boot rather than
  # on the first upload, the same posture as `AP_LOCAL_ROOT`.
  defp readable_file(env, var) do
    with value when is_binary(value) <- fetch(env, var) do
      expanded = Path.expand(value)

      case File.read(expanded) do
        {:ok, _contents} ->
          expanded

        {:error, reason} ->
          raise Error,
                "#{var} must name a readable file, got: #{inspect(value)} (#{inspect(reason)})"
      end
    end
  end

  # An address literal, never a hostname. A bind address has to name an
  # interface this host actually has, and resolving a name to one would make
  # the access control of an unsigned endpoint depend on DNS — which can
  # change under a running process, and which an operator reading the variable
  # cannot check. `:inet.parse_address/1` takes both families, so an IPv6
  # deployment writes `::1` and gets loopback.
  defp ip(env, var, default) do
    case fetch(env, var) do
      nil ->
        default

      value ->
        case :inet.parse_address(String.to_charlist(value)) do
          {:ok, address} ->
            address

          {:error, :einval} ->
            raise Error,
                  "#{var} must be an IP address literal, not a hostname (127.0.0.1, 0.0.0.0, ::1), got: #{inspect(value)}"
        end
    end
  end

  defp integer(env, var, default, bound) do
    case fetch(env, var) do
      nil -> default
      value -> parse_integer!(var, value, bound)
    end
  end

  defp enum(env, var, allowed, default) do
    case fetch(env, var) do
      nil ->
        default

      value ->
        Enum.find(allowed, fn candidate -> Atom.to_string(candidate) == value end) ||
          raise(
            Error,
            "#{var} must be one of #{inspect(Enum.map(allowed, &Atom.to_string/1))}, got: #{inspect(value)}"
          )
    end
  end

  defp parse_integer!(var, value, bound) do
    with {integer, ""} <- Integer.parse(value),
         true <- in_bound?(integer, bound) do
      integer
    else
      _ -> raise Error, "#{var} must be #{describe(bound)}, got: #{inspect(value)}"
    end
  end

  defp in_bound?(integer, :positive), do: integer > 0
  defp in_bound?(integer, :non_negative), do: integer >= 0

  defp describe(:positive), do: "a positive integer"
  defp describe(:non_negative), do: "a non-negative integer"

  # Treats an empty or whitespace-only value as unset, so `AP_KEY=` in a compose
  # file or shell profile means "no key" rather than "the empty key".
  defp fetch(env, var) do
    case Map.get(env, var) do
      nil -> nil
      value -> if String.trim(value) == "", do: nil, else: String.trim(value)
    end
  end
end