From c446b8596af5b2ae75a52932cb95f0116f9c129d Mon Sep 17 00:00:00 2001 From: Steffen Beyer Date: Fri, 20 Mar 2026 01:31:57 +0100 Subject: [PATCH] feat: Official plug API --- README.md | 19 ++++- docs/LOCAL_API.md | 35 ++++++++- lib/parrhesia.ex | 2 + lib/parrhesia/plug.ex | 113 +++++++++++++++++++++++++++++ lib/parrhesia/web/listener.ex | 2 +- lib/parrhesia/web/listener_plug.ex | 14 ---- mix.exs | 1 + test/parrhesia/plug_test.exs | 43 +++++++++++ 8 files changed, 208 insertions(+), 21 deletions(-) create mode 100644 lib/parrhesia/plug.ex delete mode 100644 lib/parrhesia/web/listener_plug.ex create mode 100644 test/parrhesia/plug_test.exs diff --git a/README.md b/README.md index 409d135..1edd20d 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,8 @@ The intended in-process surface is `Parrhesia.API.*`, especially: - `Parrhesia.API.Admin` for management operations - `Parrhesia.API.Identity`, `Parrhesia.API.ACL`, and `Parrhesia.API.Sync` for relay identity, protected sync ACLs, and outbound relay sync +For host-managed HTTP/WebSocket ingress mounting, use `Parrhesia.Plug`. + Start with: - [`docs/LOCAL_API.md`](./docs/LOCAL_API.md) for the embedding model and a minimal host setup @@ -144,17 +146,30 @@ Start with: Important caveats for host applications: -- Parrhesia is still alpha; expect some public API and config churn. +- Parrhesia is pre-beta; expect some public API and config churn while we prepare for beta. - Parrhesia currently assumes a single runtime per BEAM node and uses globally registered process names. - The defaults in this repo's `config/*.exs` are not imported automatically when Parrhesia is used as a dependency. A host app must set `config :parrhesia, ...` explicitly. - The host app is responsible for migrating Parrhesia's schema, for example with `Parrhesia.Release.migrate()` or `mix ecto.migrate -r Parrhesia.Repo`. -If you only want the in-process API and not the HTTP/WebSocket edge, configure: +### Official embedding boundary + +For embedded use, the stable boundaries are: + +- `Parrhesia.API.*` for in-process publish/query/admin/sync operations +- `Parrhesia.Plug` for host-managed HTTP/WebSocket ingress mounting + +If your host app owns the public HTTPS endpoint, keep this as the baseline runtime config: ```elixir config :parrhesia, :listeners, %{} ``` +Notes: + +- `listeners: %{}` disables Parrhesia-managed HTTP/WebSocket ingress (`/relay`, `/management`, `/metrics`, etc.). +- Mount `Parrhesia.Plug` in your host endpoint/router when you still want Parrhesia ingress under the host's single HTTPS surface. +- `Parrhesia.Web.*` modules remain internal runtime wiring. Use `Parrhesia.Plug` as the documented mount API. + The config reference below still applies when embedded. That is the primary place to document basic setup and runtime configuration changes. --- diff --git a/docs/LOCAL_API.md b/docs/LOCAL_API.md index ace7a68..d1757c4 100644 --- a/docs/LOCAL_API.md +++ b/docs/LOCAL_API.md @@ -3,7 +3,7 @@ Parrhesia can run as a normal standalone relay application, but it also exposes a stable in-process API for Elixir callers that want to embed the relay inside a larger OTP system. -This document describes that embedding surface. The runtime is still alpha, so treat the API +This document describes that embedding surface. The runtime is pre-beta, so treat the API as usable but not yet frozen. ## What embedding means today @@ -14,6 +14,7 @@ Embedding currently means: - the host app provides `config :parrhesia, ...` explicitly - the host app migrates the Parrhesia database schema - callers interact with the relay through `Parrhesia.API.*` +- host-managed HTTP/WebSocket ingress is mounted through `Parrhesia.Plug` Current operational assumptions: @@ -63,9 +64,14 @@ config :parrhesia, ecto_repos: [Parrhesia.Repo] Notes: -- Set `listeners: %{}` if you only want the in-process API and no HTTP/WebSocket ingress. -- If you do want ingress, copy the listener shape from the config reference in - [README.md](../README.md). +- `listeners: %{}` is the official embedding pattern when your host app owns the HTTPS edge. +- `listeners: %{}` disables Parrhesia-managed ingress (`/relay`, `/management`, `/metrics`, etc.). +- Mount `Parrhesia.Plug` from the host app when you still want Parrhesia ingress behind that same + HTTPS edge. +- `Parrhesia.Web.*` modules are internal runtime wiring. Treat `Parrhesia.Plug` as the stable + mount API. +- If you prefer Parrhesia-managed ingress instead, copy the listener shape from the config + reference in [README.md](../README.md). - Production runtime overrides still use the `PARRHESIA_*` environment variables described in [README.md](../README.md). @@ -77,6 +83,27 @@ Parrhesia.Release.migrate() In development, `mix ecto.migrate -r Parrhesia.Repo` works too. +## Mounting `Parrhesia.Plug` from a host app + +When `listeners: %{}` is set, you can still expose Parrhesia ingress by mounting `Parrhesia.Plug` +in your host endpoint/router and passing an explicit listener config: + +```elixir +forward "/nostr", Parrhesia.Plug, + listener: %{ + id: :public, + transport: %{scheme: :https, tls: %{mode: :proxy_terminated}}, + proxy: %{trusted_cidrs: ["10.0.0.0/8"], honor_x_forwarded_for: true}, + features: %{ + nostr: %{enabled: true}, + admin: %{enabled: true}, + metrics: %{enabled: true, access: %{private_networks_only: true}} + } + } +``` + +Use the same listener schema documented in [README.md](../README.md). + ## Starting the runtime In the common case, letting OTP start the `:parrhesia` application is enough. diff --git a/lib/parrhesia.ex b/lib/parrhesia.ex index c28cb07..6625c30 100644 --- a/lib/parrhesia.ex +++ b/lib/parrhesia.ex @@ -3,6 +3,7 @@ defmodule Parrhesia do Parrhesia is a Nostr relay runtime that can run standalone or as an embedded OTP service. For embedded use, the main developer-facing surface is `Parrhesia.API.*`. + For host-managed HTTP/WebSocket ingress mounting, use `Parrhesia.Plug`. Start with: - `Parrhesia.API.Events` @@ -11,6 +12,7 @@ defmodule Parrhesia do - `Parrhesia.API.Identity` - `Parrhesia.API.ACL` - `Parrhesia.API.Sync` + - `Parrhesia.Plug` The host application is responsible for: diff --git a/lib/parrhesia/plug.ex b/lib/parrhesia/plug.ex new file mode 100644 index 0000000..85842ed --- /dev/null +++ b/lib/parrhesia/plug.ex @@ -0,0 +1,113 @@ +defmodule Parrhesia.Plug do + @moduledoc """ + Official Plug interface for mounting Parrhesia HTTP/WebSocket ingress in a host app. + + This plug serves the same route surface as the built-in listener endpoint: + + - `GET /health` + - `GET /ready` + - `GET /relay` (NIP-11 + websocket transport) + - `POST /management` + - `GET /metrics` + + ## Options + + * `:listener` - listener configuration used to authorize and serve requests. + Supported values: + * an atom listener id from `config :parrhesia, :listeners` (for example `:public`) + * a listener config map/keyword list (same schema as `:listeners` entries) + + When a host app owns the HTTPS edge, a common pattern is: + + config :parrhesia, :listeners, %{} + + and mount `Parrhesia.Plug` with an explicit `:listener` map. + """ + + @behaviour Plug + + alias Parrhesia.Web.Listener + alias Parrhesia.Web.Router + + @type listener_option :: atom() | map() | keyword() + @type option :: {:listener, listener_option()} + + @spec init([option()]) :: keyword() + @impl Plug + def init(opts) do + opts = Keyword.validate!(opts, listener: :public) + listener = opts |> Keyword.fetch!(:listener) |> resolve_listener!() + [listener: listener] + end + + @spec call(Plug.Conn.t(), keyword()) :: Plug.Conn.t() + @impl Plug + def call(conn, opts) do + conn + |> Listener.put_conn(opts) + |> Router.call([]) + end + + defp resolve_listener!(listener_id) when is_atom(listener_id) do + listeners = Application.get_env(:parrhesia, :listeners, %{}) + + case lookup_listener_by_id(listeners, listener_id) do + nil -> + raise ArgumentError, + "listener #{inspect(listener_id)} not found in config :parrhesia, :listeners; " <> + "configure it there or pass :listener as a map" + + listener -> + Listener.from_opts(listener: listener) + end + end + + defp resolve_listener!(listener) when is_map(listener) do + Listener.from_opts(listener: listener) + end + + defp resolve_listener!(listener) when is_list(listener) do + if Keyword.keyword?(listener) do + Listener.from_opts(listener: Map.new(listener)) + else + raise ArgumentError, + ":listener keyword list must be a valid keyword configuration" + end + end + + defp resolve_listener!(other) do + raise ArgumentError, + ":listener must be an atom id, map, or keyword list, got: #{inspect(other)}" + end + + defp lookup_listener_by_id(listeners, listener_id) when is_map(listeners) do + case Map.fetch(listeners, listener_id) do + {:ok, listener} when is_map(listener) -> + Map.put_new(listener, :id, listener_id) + + {:ok, listener} when is_list(listener) -> + listener |> Map.new() |> Map.put_new(:id, listener_id) + + _other -> + nil + end + end + + defp lookup_listener_by_id(listeners, listener_id) when is_list(listeners) do + case Enum.find(listeners, fn + {id, _listener} -> id == listener_id + _other -> false + end) do + {^listener_id, listener} when is_map(listener) -> + Map.put_new(listener, :id, listener_id) + + {^listener_id, listener} when is_list(listener) -> + listener |> Map.new() |> Map.put_new(:id, listener_id) + + _other -> + nil + end + end + + defp lookup_listener_by_id(_listeners, _listener_id), do: nil +end diff --git a/lib/parrhesia/web/listener.ex b/lib/parrhesia/web/listener.ex index a71136a..b9ea751 100644 --- a/lib/parrhesia/web/listener.ex +++ b/lib/parrhesia/web/listener.ex @@ -177,7 +177,7 @@ defmodule Parrhesia.Web.Listener do ip: listener.bind.ip, port: listener.bind.port, scheme: scheme, - plug: {Parrhesia.Web.ListenerPlug, listener: listener} + plug: {Parrhesia.Plug, listener: listener} ] ++ TLS.bandit_options(listener.transport.tls) ++ [thousand_island_options: thousand_island_options] ++ diff --git a/lib/parrhesia/web/listener_plug.ex b/lib/parrhesia/web/listener_plug.ex deleted file mode 100644 index c6fd76a..0000000 --- a/lib/parrhesia/web/listener_plug.ex +++ /dev/null @@ -1,14 +0,0 @@ -defmodule Parrhesia.Web.ListenerPlug do - @moduledoc false - - alias Parrhesia.Web.Listener - alias Parrhesia.Web.Router - - def init(opts), do: opts - - def call(conn, opts) do - conn - |> Listener.put_conn(opts) - |> Router.call([]) - end -end diff --git a/mix.exs b/mix.exs index cfee547..898f2c4 100644 --- a/mix.exs +++ b/mix.exs @@ -109,6 +109,7 @@ defmodule Parrhesia.MixProject do ], Runtime: [ Parrhesia, + Parrhesia.Plug, Parrhesia.Release, Parrhesia.Runtime ] diff --git a/test/parrhesia/plug_test.exs b/test/parrhesia/plug_test.exs new file mode 100644 index 0000000..5c5e9da --- /dev/null +++ b/test/parrhesia/plug_test.exs @@ -0,0 +1,43 @@ +defmodule Parrhesia.PlugTest do + use ExUnit.Case, async: true + + import Plug.Test + + alias Parrhesia.Plug + + test "init resolves configured listener id" do + opts = Plug.init(listener: :public) + + assert is_list(opts) + assert Keyword.fetch!(opts, :listener).id == :public + end + + test "init accepts inline listener map" do + opts = + Plug.init( + listener: %{ + id: :host_mount, + features: %{nostr: %{enabled: true}} + } + ) + + assert Keyword.fetch!(opts, :listener).id == :host_mount + end + + test "init raises for unknown listener id" do + assert_raise ArgumentError, ~r/listener :does_not_exist not found/, fn -> + Plug.init(listener: :does_not_exist) + end + end + + test "call serves health route" do + opts = Plug.init(listener: :public) + + response = + conn(:get, "/health") + |> Plug.call(opts) + + assert response.status == 200 + assert response.resp_body == "ok" + end +end