Skip to main content

lib/skill_kit/webhook/plug.ex

defmodule SkillKit.Webhook.Plug do
  @moduledoc """
  Plug that routes inbound webhook requests.

  Mounted by the host (typically via Phoenix `forward`) at the base path
  configured as the public URL root:

      forward "/webhooks", to: SkillKit.Webhook.Plug

  The path after the forward prefix is interpreted as the webhook id. A
  single request passes through `resolve/3`: look up the webhook record,
  the running agent, verify the signature, check idempotency. Depending
  on the outcome the Plug responds with one of 200 / 202 / 401 / 404 /
  500 / 503. The handshake short-circuit (Slack `url_verification`) is
  returned as-is — the verifier wrote the response itself.

  On success the Plug hands the delivery to the configured
  `SkillKit.Webhook.Inbox` impl via `Inbox.put/2`. The Inbox owns
  persistence, retention, and dispatch timing; it calls
  `SkillKit.send_event/3` on the bound agent when it decides to emit.
  The Plug returns 202 as soon as `put/2` returns `:ok`.
  """

  @behaviour Plug

  alias SkillKit.AgentRef
  alias SkillKit.Webhook
  alias SkillKit.Webhook.Idempotency
  alias SkillKit.Webhook.Registry, as: WebhookRegistry
  alias SkillKit.Webhook.Supervisor, as: WebhookSupervisor

  @impl true
  def init(opts) do
    Keyword.put_new(opts, :supervisor, SkillKit.Webhook)
  end

  @impl true
  def call(%Plug.Conn{path_info: [id]} = conn, opts) do
    SkillKit.Telemetry.span(
      [:webhook, :request],
      %{id: id},
      fn ->
        result = resolve(conn, id, opts)
        final_conn = dispatch_outcome(result, conn, opts)
        {final_conn, %{outcome: request_outcome(result), status: final_conn.status}}
      end
    )
  end

  def call(%Plug.Conn{} = conn, _opts) do
    Plug.Conn.send_resp(conn, 404, "")
  end

  # -- Resolution pipeline --------------------------------------------------

  defp resolve(conn, id, opts) do
    with raw <- conn.assigns[:raw_body] || "",
         {:ok, webhook} <- Webhook.get(id, opts),
         {:ok, agent} <-
           WebhookRegistry.whereis(webhook.agent_name, registry: registry_name(opts)),
         :ok <- verify(webhook, raw, conn, agent),
         :ok <- check_idempotency(webhook, conn, opts) do
      {:ok, webhook, agent, raw}
    end
  end

  defp verify(%Webhook{verifier: {module, config}}, raw, conn, agent) do
    apply(module, :verify, [raw, conn, config, agent])
  end

  defp check_idempotency(%Webhook{idempotency: nil}, _conn, _opts), do: :ok

  defp check_idempotency(%Webhook{idempotency: config} = _webhook, conn, opts) do
    case Idempotency.extract_key(conn, config) do
      :no_key -> :ok
      {:error, :missing} -> :ok
      {:ok, key} -> dedup(key, config, opts)
    end
  end

  defp dedup(key, config, opts) do
    name = idempotency_name(opts)
    ttl = Map.get(config, :ttl, 86_400)
    Idempotency.check(name, key, ttl: ttl)
  end

  # -- Outcome dispatch -----------------------------------------------------

  defp dispatch_outcome({:ok, webhook, agent, raw}, conn, opts) do
    {inbox_mod, inbox_name} = WebhookSupervisor.inbox_ref(Keyword.fetch!(opts, :supervisor))

    entry = build_entry(webhook, agent, raw, conn)
    :ok = inbox_mod.put(inbox_name, entry)
    Plug.Conn.send_resp(conn, 202, "")
  end

  defp dispatch_outcome({:error, :not_found}, conn, _opts),
    do: Plug.Conn.send_resp(conn, 404, "")

  defp dispatch_outcome({:error, :agent_not_running}, conn, _opts),
    do: Plug.Conn.send_resp(conn, 503, "")

  defp dispatch_outcome({:error, :invalid_signature}, conn, _opts),
    do: Plug.Conn.send_resp(conn, 401, "")

  defp dispatch_outcome({:error, :stale_timestamp}, conn, _opts),
    do: Plug.Conn.send_resp(conn, 401, "")

  defp dispatch_outcome({:error, :misconfigured}, conn, _opts),
    do: Plug.Conn.send_resp(conn, 500, "")

  defp dispatch_outcome(:duplicate, conn, _opts),
    do: Plug.Conn.send_resp(conn, 200, "")

  defp dispatch_outcome({:handshake, conn}, _conn, _opts), do: conn

  # -- Helpers --------------------------------------------------------------

  defp request_outcome({:ok, _, _, _}), do: :dispatched
  defp request_outcome({:error, reason}), do: reason
  defp request_outcome(:duplicate), do: :duplicate
  defp request_outcome({:handshake, _conn}), do: :handshake

  defp registry_name(opts),
    do: WebhookSupervisor.registry_name(Keyword.fetch!(opts, :supervisor))

  defp idempotency_name(opts),
    do: WebhookSupervisor.idempotency_name(Keyword.fetch!(opts, :supervisor))

  defp build_entry(webhook, agent, raw, conn) do
    fetched = Plug.Conn.fetch_query_params(conn)

    %{
      agent: AgentRef.from_agent(agent),
      prompt: webhook.prompt,
      delivery: %{
        id: delivery_id(webhook, conn),
        webhook_id: webhook.id,
        agent_name: webhook.agent_name,
        received_at: DateTime.utc_now(),
        method: conn.method,
        headers: Map.new(conn.req_headers),
        query: fetched.query_params,
        body: raw
      }
    }
  end

  # Use the idempotency key as the delivery id when available (lets the
  # inbox dedup naturally), else generate a random id.
  defp delivery_id(%Webhook{idempotency: config}, conn) do
    case Idempotency.extract_key(conn, config) do
      {:ok, key} -> key
      _ -> "dlv_" <> Base.url_encode64(:crypto.strong_rand_bytes(12), padding: false)
    end
  end
end