feat: Official plug API
Some checks failed
CI / Test (OTP 27.2 / Elixir 1.18.2) (push) Failing after 0s
CI / Test (OTP 28.4 / Elixir 1.19.4 + E2E) (push) Failing after 0s

This commit is contained in:
2026-03-20 01:31:57 +01:00
parent be9d348660
commit c446b8596a
8 changed files with 208 additions and 21 deletions

View File

@@ -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.
---

View File

@@ -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.

View File

@@ -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:

113
lib/parrhesia/plug.ex Normal file
View File

@@ -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

View File

@@ -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] ++

View File

@@ -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

View File

@@ -109,6 +109,7 @@ defmodule Parrhesia.MixProject do
],
Runtime: [
Parrhesia,
Parrhesia.Plug,
Parrhesia.Release,
Parrhesia.Runtime
]

View File

@@ -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