Skip to main content

lib/domain_connect.ex

defmodule DomainConnect do
  @moduledoc """
  Elixir client for the [Domain Connect](https://www.domainconnect.org) protocol.

  Domain Connect lets a *Service Provider* (you) set up the DNS a custom domain
  needs to point at your app **without** the domain owner having to understand
  CNAME/TXT records. The owner clicks "connect", consents at their own DNS
  provider, and the records are applied. ~20 providers implement it, including
  GoDaddy, IONOS, Cloudflare, Squarespace Domains, WordPress.com, and Plesk.

  This library covers the Service Provider side of the **synchronous** flow:

      # 1. Discover whether (and how) the domain's DNS provider supports it.
      {:ok, config} = DomainConnect.discover("rent.theirplace.com")

      # 2. Build the URL to send the owner to. They click "apply" at their
      #    provider, the records land, your custom domain goes live.
      {:ok, url} =
        DomainConnect.apply_url(config,
          provider_id: "exampleservice.com",
          service_id: "rentals",
          params: %{"target" => "portal.unitops.app"}
        )

  `provider_id`/`service_id` identify **your** Domain Connect template (the
  record set you register with each DNS provider), not the DNS provider. See
  `apply_url/2`.

  The asynchronous OAuth flow (programmatic record writes) is supported via
  `async_consent_url/2`, `async_token/2`, `async_apply/3`, and `async_refresh/2`
  (see `DomainConnect.Async`). Signed templates are supported on both flows by
  passing `:private_key` + `:key_id` (see `apply_url/2` and `DomainConnect.Signing`).
  """

  alias DomainConnect.{Async, Config, Discovery, Signing, Token}

  @doc """
  Discovers the Domain Connect configuration for `domain`. See
  `DomainConnect.Discovery.discover/2` for options.
  """
  @spec discover(String.t(), keyword()) ::
          {:ok, Config.t()} | {:error, :not_supported | term()}
  defdelegate discover(domain, opts \\ []), to: Discovery

  @doc """
  Splits a domain into its registrable zone and sub-host via the Public Suffix
  List, without any DNS lookup.

      iex> DomainConnect.zone_and_host("rent.theirplace.co.uk")
      {:ok, "theirplace.co.uk", "rent"}

  Useful for showing the owner exactly what you'll configure ("we'll set up
  `rent` on `theirplace.co.uk`") before discovery.
  """
  @spec zone_and_host(String.t()) ::
          {:ok, String.t(), String.t() | nil} | {:error, :invalid_domain}
  defdelegate zone_and_host(domain), to: Discovery, as: :registrable

  @doc """
  Asynchronous (OAuth) flow: build the consent URL to redirect the owner to.
  See `DomainConnect.Async.consent_url/2`.
  """
  @spec async_consent_url(Config.t(), keyword()) :: {:ok, String.t()} | {:error, term()}
  defdelegate async_consent_url(config, opts), to: Async, as: :consent_url

  @doc """
  Asynchronous flow: exchange an authorization code for a token.
  See `DomainConnect.Async.get_token/2`.
  """
  @spec async_token(Config.t(), keyword()) :: {:ok, Token.t()} | {:error, term()}
  defdelegate async_token(config, opts), to: Async, as: :get_token

  @doc """
  Asynchronous flow: refresh an access token. See `DomainConnect.Async.refresh/2`.
  """
  @spec async_refresh(Config.t(), keyword()) :: {:ok, Token.t()} | {:error, term()}
  defdelegate async_refresh(config, opts), to: Async, as: :refresh

  @doc """
  Asynchronous flow: apply a template using a token.
  See `DomainConnect.Async.apply/3`.
  """
  @spec async_apply(Config.t(), Token.t(), keyword()) :: :ok | {:error, term()}
  defdelegate async_apply(config, token, opts), to: Async, as: :apply

  @doc """
  Convenience: `true` if `domain`'s provider supports the synchronous apply flow.

  Swallows discovery errors into `false`. Accepts the same options as
  `discover/2`.
  """
  @spec supported?(String.t(), keyword()) :: boolean()
  def supported?(domain, opts \\ []) do
    case discover(domain, opts) do
      {:ok, config} -> Config.sync_supported?(config)
      _ -> false
    end
  end

  @doc """
  Builds the synchronous apply URL to redirect the domain owner to.

  On arrival the DNS provider shows a consent screen for your template and, on
  approval, writes the records. Resolves to:

      <url_sync_ux>/v2/domainTemplates/providers/<provider_id>/services/<service_id>/apply?...

  ## Required options

    * `:provider_id` — your template's `providerId` (e.g. `"exampleservice.com"`).
    * `:service_id` — your template's `serviceId` (e.g. `"hosting"`).

  ## Optional options

    * `:params` — a map of template variable values (string keys), merged into
      the query (e.g. `%{"target" => "portal.unitops.app", "ttl" => "3600"}`).
      Must not contain the reserved keys `domain`, `host`, `redirect_uri`,
      `state`, or `groupId`.
    * `:redirect_uri` — where the provider returns the owner after applying.
    * `:state` — opaque value echoed back to `:redirect_uri`.
    * `:group_ids` — record group ids for partial templates; a list of strings
      or a comma-separated string.

  Returns `{:error, :sync_not_supported}` if the provider advertised no sync UX,
  `{:error, {:missing, key}}` if a required id is absent, or
  `{:error, {:reserved_params, keys}}` if `:params` collides with a reserved key.

  ## Signed templates

  For templates that require a signed request, pass `:private_key` (an
  unencrypted RSA private-key PEM) and `:key_id` (the TXT record name where the
  matching public key is published). The query is signed (RSA-SHA256) and `sig`
  /`key` are appended. Without both, an unsigned URL is built.
  """
  @spec apply_url(Config.t(), keyword()) :: {:ok, String.t()} | {:error, term()}
  def apply_url(%Config{} = config, opts) do
    with :ok <- require_sync(config),
         {:ok, provider_id} <- fetch(opts, :provider_id),
         {:ok, service_id} <- fetch(opts, :service_id),
         :ok <- reject_reserved(Keyword.get(opts, :params, %{})),
         :ok <- validate_group_ids(Keyword.get(opts, :group_ids)),
         {:ok, query} <- Signing.build_query(query(config, opts), opts) do
      base =
        String.trim_trailing(config.url_sync_ux, "/") <>
          "/v2/domainTemplates/providers/#{encode_segment(provider_id)}" <>
          "/services/#{encode_segment(service_id)}/apply"

      {:ok, base <> "?" <> query}
    end
  end

  @reserved_params ~w(domain host redirect_uri state groupId sig key)

  defp require_sync(config) do
    if Config.sync_supported?(config), do: :ok, else: {:error, :sync_not_supported}
  end

  # A non-empty id that is not a bare dot-segment (`.`/`..` would inject a
  # relative path component even after segment-encoding).
  defp fetch(opts, key) do
    case Keyword.fetch(opts, key) do
      {:ok, value} when is_binary(value) and value not in ["", ".", ".."] -> {:ok, value}
      {:ok, _} -> {:error, {:invalid, key}}
      :error -> {:error, {:missing, key}}
    end
  end

  defp reject_reserved(params) when is_map(params) do
    case params |> Map.keys() |> Enum.filter(&(to_string(&1) in @reserved_params)) do
      [] -> :ok
      keys -> {:error, {:reserved_params, keys}}
    end
  end

  defp reject_reserved(_params), do: {:error, {:invalid, :params}}

  defp validate_group_ids(nil), do: :ok
  defp validate_group_ids(value) when is_binary(value), do: :ok

  defp validate_group_ids(value) when is_list(value) do
    if Enum.all?(value, &is_binary/1), do: :ok, else: {:error, {:invalid, :group_ids}}
  end

  defp validate_group_ids(_value), do: {:error, {:invalid, :group_ids}}

  # `URI.encode/1` leaves `/ ? #` unescaped, which would let a crafted id rewrite
  # the path; encode each id as a strict path segment.
  defp encode_segment(value), do: URI.encode(value, &URI.char_unreserved?/1)

  # Ordered so the canonical params lead; template params follow.
  defp query(config, opts) do
    leading =
      [domain: config.domain]
      |> maybe_put(:host, config.host)

    params = opts |> Keyword.get(:params, %{}) |> Enum.into([])

    trailing =
      []
      |> maybe_put(:redirect_uri, Keyword.get(opts, :redirect_uri))
      |> maybe_put(:state, Keyword.get(opts, :state))
      |> maybe_put(:groupId, group_ids(Keyword.get(opts, :group_ids)))

    leading ++ params ++ trailing
  end

  defp group_ids(nil), do: nil
  defp group_ids(list) when is_list(list), do: Enum.join(list, ",")
  defp group_ids(string) when is_binary(string), do: string

  defp maybe_put(list, _key, nil), do: list
  defp maybe_put(list, _key, ""), do: list
  defp maybe_put(list, key, value), do: list ++ [{key, value}]
end