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.Admin` for management operations
- `Parrhesia.API.Identity`, `Parrhesia.API.ACL`, and `Parrhesia.API.Sync` for relay identity, protected sync ACLs, and outbound relay sync - `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: Start with:
- [`docs/LOCAL_API.md`](./docs/LOCAL_API.md) for the embedding model and a minimal host setup - [`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: 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. - 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 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`. - 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 ```elixir
config :parrhesia, :listeners, %{} 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. 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 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. 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. as usable but not yet frozen.
## What embedding means today ## What embedding means today
@@ -14,6 +14,7 @@ Embedding currently means:
- the host app provides `config :parrhesia, ...` explicitly - the host app provides `config :parrhesia, ...` explicitly
- the host app migrates the Parrhesia database schema - the host app migrates the Parrhesia database schema
- callers interact with the relay through `Parrhesia.API.*` - callers interact with the relay through `Parrhesia.API.*`
- host-managed HTTP/WebSocket ingress is mounted through `Parrhesia.Plug`
Current operational assumptions: Current operational assumptions:
@@ -63,9 +64,14 @@ config :parrhesia, ecto_repos: [Parrhesia.Repo]
Notes: Notes:
- Set `listeners: %{}` if you only want the in-process API and no HTTP/WebSocket ingress. - `listeners: %{}` is the official embedding pattern when your host app owns the HTTPS edge.
- If you do want ingress, copy the listener shape from the config reference in - `listeners: %{}` disables Parrhesia-managed ingress (`/relay`, `/management`, `/metrics`, etc.).
[README.md](../README.md). - 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 - Production runtime overrides still use the `PARRHESIA_*` environment variables described in
[README.md](../README.md). [README.md](../README.md).
@@ -77,6 +83,27 @@ Parrhesia.Release.migrate()
In development, `mix ecto.migrate -r Parrhesia.Repo` works too. 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 ## Starting the runtime
In the common case, letting OTP start the `:parrhesia` application is enough. 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. 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 embedded use, the main developer-facing surface is `Parrhesia.API.*`.
For host-managed HTTP/WebSocket ingress mounting, use `Parrhesia.Plug`.
Start with: Start with:
- `Parrhesia.API.Events` - `Parrhesia.API.Events`
@@ -11,6 +12,7 @@ defmodule Parrhesia do
- `Parrhesia.API.Identity` - `Parrhesia.API.Identity`
- `Parrhesia.API.ACL` - `Parrhesia.API.ACL`
- `Parrhesia.API.Sync` - `Parrhesia.API.Sync`
- `Parrhesia.Plug`
The host application is responsible for: 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, ip: listener.bind.ip,
port: listener.bind.port, port: listener.bind.port,
scheme: scheme, scheme: scheme,
plug: {Parrhesia.Web.ListenerPlug, listener: listener} plug: {Parrhesia.Plug, listener: listener}
] ++ ] ++
TLS.bandit_options(listener.transport.tls) ++ TLS.bandit_options(listener.transport.tls) ++
[thousand_island_options: thousand_island_options] ++ [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: [ Runtime: [
Parrhesia, Parrhesia,
Parrhesia.Plug,
Parrhesia.Release, Parrhesia.Release,
Parrhesia.Runtime 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