diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..8fec40e
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,20 @@
+PARRHESIA_IMAGE=parrhesia:latest
+PARRHESIA_HOST_PORT=4000
+
+POSTGRES_DB=parrhesia
+POSTGRES_USER=parrhesia
+POSTGRES_PASSWORD=parrhesia
+
+DATABASE_URL=ecto://parrhesia:parrhesia@db:5432/parrhesia
+POOL_SIZE=20
+
+# Optional runtime overrides:
+# PARRHESIA_RELAY_URL=ws://localhost:4000/relay
+# PARRHESIA_POLICIES_AUTH_REQUIRED_FOR_WRITES=false
+# PARRHESIA_POLICIES_AUTH_REQUIRED_FOR_READS=false
+# PARRHESIA_POLICIES_MIN_POW_DIFFICULTY=0
+# PARRHESIA_FEATURES_VERIFY_EVENT_SIGNATURES=true
+# PARRHESIA_METRICS_ENABLED_ON_MAIN_ENDPOINT=true
+# PARRHESIA_METRICS_PRIVATE_NETWORKS_ONLY=true
+# PARRHESIA_METRICS_AUTH_TOKEN=
+# PARRHESIA_EXTRA_CONFIG=/config/parrhesia.runtime.exs
diff --git a/README.md b/README.md
index d76f317..d05521e 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# Parrhesia
+
+
Parrhesia is a Nostr relay server written in Elixir/OTP with PostgreSQL storage.
It exposes:
@@ -20,6 +22,7 @@ Current `supported_nips` list:
- Elixir `~> 1.19`
- Erlang/OTP 28
- PostgreSQL (18 used in the dev environment; 16+ recommended)
+- Docker or Podman plus Docker Compose support if you want to run the published container image
---
@@ -65,78 +68,177 @@ ws://localhost:4000/relay
## Production configuration
+### Minimal setup
+
+Before a Nostr client can publish its first event successfully, make sure these pieces are in place:
+
+1. PostgreSQL is reachable from Parrhesia.
+ Set `DATABASE_URL` and create/migrate the database with `Parrhesia.Release.migrate()` or `mix ecto.migrate`.
+
+2. Parrhesia is reachable behind your reverse proxy.
+ Parrhesia itself listens on plain HTTP on port `4000`, and the reverse proxy is expected to terminate TLS and forward WebSocket traffic to `/relay`.
+
+3. `:relay_url` matches the public relay URL clients should use.
+ Set `PARRHESIA_RELAY_URL` to the public relay URL exposed by the reverse proxy.
+ In the normal deployment model, this should be your public `wss://.../relay` URL.
+
+4. The database schema is migrated before starting normal traffic.
+ The app image does not auto-run migrations on boot.
+
+That is the actual minimum. With default policy settings, writes do not require auth, event signatures are verified, and no extra Nostr-specific bootstrap step is needed before posting ordinary events.
+
In `prod`, these environment variables are used:
- `DATABASE_URL` (**required**), e.g. `ecto://USER:PASS@HOST/parrhesia_prod`
-- `POOL_SIZE` (optional, default `10`)
+- `POOL_SIZE` (optional, default `32`)
- `PORT` (optional, default `4000`)
+- `PARRHESIA_*` runtime overrides for relay config, limits, policies, metrics, and features
+- `PARRHESIA_EXTRA_CONFIG` (optional path to an extra runtime config file)
`config/runtime.exs` reads these values at runtime in production releases.
-### Typical relay config
+### Runtime env naming
-Add/override in config files (for example in `config/prod.exs` or a `config/runtime.exs`):
+For runtime overrides, use the `PARRHESIA_...` prefix:
-```elixir
-config :parrhesia, Parrhesia.Web.Endpoint,
- ip: {0, 0, 0, 0},
- port: 4000
+- `PARRHESIA_RELAY_URL`
+- `PARRHESIA_MODERATION_CACHE_ENABLED`
+- `PARRHESIA_ENABLE_EXPIRATION_WORKER`
+- `PARRHESIA_LIMITS_*`
+- `PARRHESIA_POLICIES_*`
+- `PARRHESIA_METRICS_*`
+- `PARRHESIA_FEATURES_*`
+- `PARRHESIA_METRICS_ENDPOINT_*`
-# Optional dedicated metrics listener (keep this internal)
-config :parrhesia, Parrhesia.Web.MetricsEndpoint,
- enabled: true,
- ip: {127, 0, 0, 1},
- port: 9568
+Examples:
-config :parrhesia,
- metrics: [
- enabled_on_main_endpoint: false,
- public: false,
- private_networks_only: true,
- allowed_cidrs: [],
- auth_token: nil
- ],
- limits: [
- max_frame_bytes: 1_048_576,
- max_event_bytes: 262_144,
- max_filters_per_req: 16,
- max_filter_limit: 500,
- max_subscriptions_per_connection: 32,
- max_event_future_skew_seconds: 900,
- max_outbound_queue: 256,
- outbound_drain_batch_size: 64,
- outbound_overflow_strategy: :close
- ],
- policies: [
- auth_required_for_writes: false,
- auth_required_for_reads: false,
- min_pow_difficulty: 0,
- accept_ephemeral_events: true,
- mls_group_event_ttl_seconds: 300,
- marmot_require_h_for_group_queries: true,
- marmot_group_max_h_values_per_filter: 32,
- marmot_group_max_query_window_seconds: 2_592_000,
- marmot_media_max_imeta_tags_per_event: 8,
- marmot_media_max_field_value_bytes: 1024,
- marmot_media_max_url_bytes: 2048,
- marmot_media_allowed_mime_prefixes: [],
- marmot_media_reject_mip04_v1: true,
- marmot_push_server_pubkeys: [],
- marmot_push_max_relay_tags: 16,
- marmot_push_max_payload_bytes: 65_536,
- marmot_push_max_trigger_age_seconds: 120,
- marmot_push_require_expiration: true,
- marmot_push_max_expiration_window_seconds: 120,
- marmot_push_max_server_recipients: 1
- ],
- features: [
- nip_45_count: true,
- nip_50_search: true,
- nip_77_negentropy: true,
- marmot_push_notifications: false
- ]
+```bash
+export PARRHESIA_POLICIES_AUTH_REQUIRED_FOR_WRITES=true
+export PARRHESIA_FEATURES_VERIFY_EVENT_SIGNATURES=true
+export PARRHESIA_METRICS_ALLOWED_CIDRS="10.0.0.0/8,192.168.0.0/16"
+export PARRHESIA_LIMITS_OUTBOUND_OVERFLOW_STRATEGY=drop_oldest
```
+For settings that are awkward to express as env vars, mount an extra config file and set `PARRHESIA_EXTRA_CONFIG` to its path inside the container.
+
+### Config reference
+
+CSV env vars use comma-separated values. Boolean env vars accept `1/0`, `true/false`, `yes/no`, or `on/off`.
+
+#### Top-level `:parrhesia`
+
+| Atom key | ENV | Default | Notes |
+| --- | --- | --- | --- |
+| `:relay_url` | `PARRHESIA_RELAY_URL` | `ws://localhost:4000/relay` | Advertised relay URL and auth relay tag target |
+| `:moderation_cache_enabled` | `PARRHESIA_MODERATION_CACHE_ENABLED` | `true` | Toggle moderation cache |
+| `:enable_expiration_worker` | `PARRHESIA_ENABLE_EXPIRATION_WORKER` | `true` | Toggle background expiration worker |
+| `:limits` | `PARRHESIA_LIMITS_*` | see table below | Runtime override group |
+| `:policies` | `PARRHESIA_POLICIES_*` | see table below | Runtime override group |
+| `:metrics` | `PARRHESIA_METRICS_*` | see table below | Runtime override group |
+| `:features` | `PARRHESIA_FEATURES_*` | see table below | Runtime override group |
+| `:storage.events` | `-` | `Parrhesia.Storage.Adapters.Postgres.Events` | Config-file override only |
+| `:storage.moderation` | `-` | `Parrhesia.Storage.Adapters.Postgres.Moderation` | Config-file override only |
+| `:storage.groups` | `-` | `Parrhesia.Storage.Adapters.Postgres.Groups` | Config-file override only |
+| `:storage.admin` | `-` | `Parrhesia.Storage.Adapters.Postgres.Admin` | Config-file override only |
+
+#### `Parrhesia.Repo`
+
+| Atom key | ENV | Default | Notes |
+| --- | --- | --- | --- |
+| `:url` | `DATABASE_URL` | required | Example: `ecto://USER:PASS@HOST/DATABASE` |
+| `:pool_size` | `POOL_SIZE` | `32` | DB connection pool size |
+| `:queue_target` | `DB_QUEUE_TARGET_MS` | `1000` | Ecto queue target in ms |
+| `:queue_interval` | `DB_QUEUE_INTERVAL_MS` | `5000` | Ecto queue interval in ms |
+| `:types` | `-` | `Parrhesia.PostgresTypes` | Internal config-file setting |
+
+#### `Parrhesia.Web.Endpoint`
+
+| Atom key | ENV | Default | Notes |
+| --- | --- | --- | --- |
+| `:port` | `PORT` | `4000` | Main HTTP/WebSocket listener |
+
+#### `Parrhesia.Web.MetricsEndpoint`
+
+| Atom key | ENV | Default | Notes |
+| --- | --- | --- | --- |
+| `:enabled` | `PARRHESIA_METRICS_ENDPOINT_ENABLED` | `false` | Enables dedicated metrics listener |
+| `:ip` | `PARRHESIA_METRICS_ENDPOINT_IP` | `127.0.0.1` | IPv4 only |
+| `:port` | `PARRHESIA_METRICS_ENDPOINT_PORT` | `9568` | Dedicated metrics port |
+
+#### `:limits`
+
+| Atom key | ENV | Default |
+| --- | --- | --- |
+| `:max_frame_bytes` | `PARRHESIA_LIMITS_MAX_FRAME_BYTES` | `1048576` |
+| `:max_event_bytes` | `PARRHESIA_LIMITS_MAX_EVENT_BYTES` | `262144` |
+| `:max_filters_per_req` | `PARRHESIA_LIMITS_MAX_FILTERS_PER_REQ` | `16` |
+| `:max_filter_limit` | `PARRHESIA_LIMITS_MAX_FILTER_LIMIT` | `500` |
+| `:max_subscriptions_per_connection` | `PARRHESIA_LIMITS_MAX_SUBSCRIPTIONS_PER_CONNECTION` | `32` |
+| `:max_event_future_skew_seconds` | `PARRHESIA_LIMITS_MAX_EVENT_FUTURE_SKEW_SECONDS` | `900` |
+| `:max_event_ingest_per_window` | `PARRHESIA_LIMITS_MAX_EVENT_INGEST_PER_WINDOW` | `120` |
+| `:event_ingest_window_seconds` | `PARRHESIA_LIMITS_EVENT_INGEST_WINDOW_SECONDS` | `1` |
+| `:auth_max_age_seconds` | `PARRHESIA_LIMITS_AUTH_MAX_AGE_SECONDS` | `600` |
+| `:max_outbound_queue` | `PARRHESIA_LIMITS_MAX_OUTBOUND_QUEUE` | `256` |
+| `:outbound_drain_batch_size` | `PARRHESIA_LIMITS_OUTBOUND_DRAIN_BATCH_SIZE` | `64` |
+| `:outbound_overflow_strategy` | `PARRHESIA_LIMITS_OUTBOUND_OVERFLOW_STRATEGY` | `:close` |
+| `:max_negentropy_payload_bytes` | `PARRHESIA_LIMITS_MAX_NEGENTROPY_PAYLOAD_BYTES` | `4096` |
+| `:max_negentropy_sessions_per_connection` | `PARRHESIA_LIMITS_MAX_NEGENTROPY_SESSIONS_PER_CONNECTION` | `8` |
+| `:max_negentropy_total_sessions` | `PARRHESIA_LIMITS_MAX_NEGENTROPY_TOTAL_SESSIONS` | `10000` |
+| `:negentropy_session_idle_timeout_seconds` | `PARRHESIA_LIMITS_NEGENTROPY_SESSION_IDLE_TIMEOUT_SECONDS` | `60` |
+| `:negentropy_session_sweep_interval_seconds` | `PARRHESIA_LIMITS_NEGENTROPY_SESSION_SWEEP_INTERVAL_SECONDS` | `10` |
+
+#### `:policies`
+
+| Atom key | ENV | Default |
+| --- | --- | --- |
+| `:auth_required_for_writes` | `PARRHESIA_POLICIES_AUTH_REQUIRED_FOR_WRITES` | `false` |
+| `:auth_required_for_reads` | `PARRHESIA_POLICIES_AUTH_REQUIRED_FOR_READS` | `false` |
+| `:min_pow_difficulty` | `PARRHESIA_POLICIES_MIN_POW_DIFFICULTY` | `0` |
+| `:accept_ephemeral_events` | `PARRHESIA_POLICIES_ACCEPT_EPHEMERAL_EVENTS` | `true` |
+| `:mls_group_event_ttl_seconds` | `PARRHESIA_POLICIES_MLS_GROUP_EVENT_TTL_SECONDS` | `300` |
+| `:marmot_require_h_for_group_queries` | `PARRHESIA_POLICIES_MARMOT_REQUIRE_H_FOR_GROUP_QUERIES` | `true` |
+| `:marmot_group_max_h_values_per_filter` | `PARRHESIA_POLICIES_MARMOT_GROUP_MAX_H_VALUES_PER_FILTER` | `32` |
+| `:marmot_group_max_query_window_seconds` | `PARRHESIA_POLICIES_MARMOT_GROUP_MAX_QUERY_WINDOW_SECONDS` | `2592000` |
+| `:marmot_media_max_imeta_tags_per_event` | `PARRHESIA_POLICIES_MARMOT_MEDIA_MAX_IMETA_TAGS_PER_EVENT` | `8` |
+| `:marmot_media_max_field_value_bytes` | `PARRHESIA_POLICIES_MARMOT_MEDIA_MAX_FIELD_VALUE_BYTES` | `1024` |
+| `:marmot_media_max_url_bytes` | `PARRHESIA_POLICIES_MARMOT_MEDIA_MAX_URL_BYTES` | `2048` |
+| `:marmot_media_allowed_mime_prefixes` | `PARRHESIA_POLICIES_MARMOT_MEDIA_ALLOWED_MIME_PREFIXES` | `[]` |
+| `:marmot_media_reject_mip04_v1` | `PARRHESIA_POLICIES_MARMOT_MEDIA_REJECT_MIP04_V1` | `true` |
+| `:marmot_push_server_pubkeys` | `PARRHESIA_POLICIES_MARMOT_PUSH_SERVER_PUBKEYS` | `[]` |
+| `:marmot_push_max_relay_tags` | `PARRHESIA_POLICIES_MARMOT_PUSH_MAX_RELAY_TAGS` | `16` |
+| `:marmot_push_max_payload_bytes` | `PARRHESIA_POLICIES_MARMOT_PUSH_MAX_PAYLOAD_BYTES` | `65536` |
+| `:marmot_push_max_trigger_age_seconds` | `PARRHESIA_POLICIES_MARMOT_PUSH_MAX_TRIGGER_AGE_SECONDS` | `120` |
+| `:marmot_push_require_expiration` | `PARRHESIA_POLICIES_MARMOT_PUSH_REQUIRE_EXPIRATION` | `true` |
+| `:marmot_push_max_expiration_window_seconds` | `PARRHESIA_POLICIES_MARMOT_PUSH_MAX_EXPIRATION_WINDOW_SECONDS` | `120` |
+| `:marmot_push_max_server_recipients` | `PARRHESIA_POLICIES_MARMOT_PUSH_MAX_SERVER_RECIPIENTS` | `1` |
+| `:management_auth_required` | `PARRHESIA_POLICIES_MANAGEMENT_AUTH_REQUIRED` | `true` |
+
+#### `:metrics`
+
+| Atom key | ENV | Default |
+| --- | --- | --- |
+| `:enabled_on_main_endpoint` | `PARRHESIA_METRICS_ENABLED_ON_MAIN_ENDPOINT` | `true` |
+| `:public` | `PARRHESIA_METRICS_PUBLIC` | `false` |
+| `:private_networks_only` | `PARRHESIA_METRICS_PRIVATE_NETWORKS_ONLY` | `true` |
+| `:allowed_cidrs` | `PARRHESIA_METRICS_ALLOWED_CIDRS` | `[]` |
+| `:auth_token` | `PARRHESIA_METRICS_AUTH_TOKEN` | `nil` |
+
+#### `:features`
+
+| Atom key | ENV | Default |
+| --- | --- | --- |
+| `:verify_event_signatures` | `PARRHESIA_FEATURES_VERIFY_EVENT_SIGNATURES` | `true` |
+| `:nip_45_count` | `PARRHESIA_FEATURES_NIP_45_COUNT` | `true` |
+| `:nip_50_search` | `PARRHESIA_FEATURES_NIP_50_SEARCH` | `true` |
+| `:nip_77_negentropy` | `PARRHESIA_FEATURES_NIP_77_NEGENTROPY` | `true` |
+| `:marmot_push_notifications` | `PARRHESIA_FEATURES_MARMOT_PUSH_NOTIFICATIONS` | `false` |
+
+#### Extra runtime config
+
+| Atom key | ENV | Default | Notes |
+| --- | --- | --- | --- |
+| extra runtime config file | `PARRHESIA_EXTRA_CONFIG` | unset | Imports an additional runtime `.exs` file |
+
---
## Deploy
@@ -150,15 +252,15 @@ export POOL_SIZE=20
mix deps.get --only prod
mix compile
-mix ecto.migrate
mix release
+_build/prod/rel/parrhesia/bin/parrhesia eval "Parrhesia.Release.migrate()"
_build/prod/rel/parrhesia/bin/parrhesia foreground
```
For systemd/process managers, run the release command in foreground mode.
-### Option B: Nix package (`default.nix`)
+### Option B: Nix release package (`default.nix`)
Build:
@@ -168,6 +270,110 @@ nix-build
Run the built release from `./result/bin/parrhesia` (release command interface).
+### Option C: Docker image via Nix flake
+
+Build the image tarball:
+
+```bash
+nix build .#dockerImage
+# or with explicit build target:
+nix build .#packages.x86_64-linux.dockerImage
+```
+
+Load it into Docker:
+
+```bash
+docker load < result
+```
+
+Run database migrations:
+
+```bash
+docker run --rm \
+ -e DATABASE_URL="ecto://USER:PASS@HOST/parrhesia_prod" \
+ parrhesia:latest \
+ eval "Parrhesia.Release.migrate()"
+```
+
+Start the relay:
+
+```bash
+docker run --rm \
+ -p 4000:4000 \
+ -e DATABASE_URL="ecto://USER:PASS@HOST/parrhesia_prod" \
+ -e POOL_SIZE=20 \
+ parrhesia:latest
+```
+
+### Option D: Docker Compose with PostgreSQL
+
+The repo includes [`compose.yaml`](./compose.yaml) and [`.env.example`](./.env.example) so Docker users can run Postgres and Parrhesia together.
+
+Set up the environment file:
+
+```bash
+cp .env.example .env
+```
+
+If you are building locally from source, build and load the image first:
+
+```bash
+nix build .#dockerImage
+docker load < result
+```
+
+Then start the stack:
+
+```bash
+docker compose up -d db
+docker compose run --rm migrate
+docker compose up -d parrhesia
+```
+
+The relay will be available on:
+
+```text
+ws://localhost:4000/relay
+```
+
+Notes:
+
+- `compose.yaml` keeps PostgreSQL in a separate container; the Parrhesia image only runs the app release.
+- The container listens on port `4000`; use `PARRHESIA_HOST_PORT` if you want a different published host port.
+- Migrations are run explicitly through the one-shot `migrate` service instead of on every app boot.
+- Common runtime overrides can go straight into `.env`; see [`.env.example`](./.env.example) for examples.
+- For more specialized overrides, mount a file and set `PARRHESIA_EXTRA_CONFIG=/path/in/container/runtime.exs`.
+- When a GHCR image is published, set `PARRHESIA_IMAGE=ghcr.io//parrhesia:` in `.env` and reuse the same compose flow.
+
+---
+
+## Benchmark
+
+The benchmark compares Parrhesia against [`strfry`](https://github.com/hoytech/strfry) and [`nostr-rs-relay`](https://sr.ht/~gheartsfield/nostr-rs-relay/) using [`nostr-bench`](https://github.com/rnostr/nostr-bench).
+
+Run it with:
+
+```bash
+mix bench
+```
+
+Current comparison results from [BENCHMARK.md](./BENCHMARK.md):
+
+| metric | parrhesia | strfry | nostr-rs-relay | strfry/parrhesia | nostr-rs/parrhesia |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| connect avg latency (ms) ↓ | 13.50 | 3.00 | 2.00 | **0.22x** | **0.15x** |
+| connect max latency (ms) ↓ | 22.50 | 5.50 | 3.00 | **0.24x** | **0.13x** |
+| echo throughput (TPS) ↑ | 80385.00 | 61673.00 | 164516.00 | 0.77x | **2.05x** |
+| echo throughput (MiB/s) ↑ | 44.00 | 34.45 | 90.10 | 0.78x | **2.05x** |
+| event throughput (TPS) ↑ | 2000.00 | 3404.50 | 788.00 | **1.70x** | 0.39x |
+| event throughput (MiB/s) ↑ | 1.30 | 2.20 | 0.50 | **1.69x** | 0.38x |
+| req throughput (TPS) ↑ | 3664.00 | 1808.50 | 877.50 | 0.49x | 0.24x |
+| req throughput (MiB/s) ↑ | 20.75 | 11.75 | 2.45 | 0.57x | 0.12x |
+
+Higher is better for `↑` metrics. Lower is better for `↓` metrics.
+
+(Results from a Linux container on a 6-core Intel i5-8400T with NVMe drive)
+
---
## Development quality checks
@@ -178,13 +384,13 @@ Before opening a PR:
mix precommit
```
-For external CLI end-to-end checks with `nak`:
+Additional external CLI end-to-end checks with `nak`:
```bash
mix test.nak_e2e
```
-For Marmot client end-to-end checks (TypeScript/Node suite using `marmot-ts`):
+For Marmot client end-to-end checks (TypeScript/Node suite using `marmot-ts`, included in `precommit`):
```bash
mix test.marmot_e2e
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 0000000..165513f
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,42 @@
+services:
+ db:
+ image: postgres:17
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: ${POSTGRES_DB:-parrhesia}
+ POSTGRES_USER: ${POSTGRES_USER:-parrhesia}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-parrhesia}
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
+ interval: 5s
+ timeout: 5s
+ retries: 12
+ volumes:
+ - postgres-data:/var/lib/postgresql/data
+
+ migrate:
+ image: ${PARRHESIA_IMAGE:-parrhesia:latest}
+ profiles: ["tools"]
+ restart: "no"
+ depends_on:
+ db:
+ condition: service_healthy
+ environment:
+ DATABASE_URL: ${DATABASE_URL:-ecto://parrhesia:parrhesia@db:5432/parrhesia}
+ POOL_SIZE: ${POOL_SIZE:-20}
+ command: ["eval", "Parrhesia.Release.migrate()"]
+
+ parrhesia:
+ image: ${PARRHESIA_IMAGE:-parrhesia:latest}
+ restart: unless-stopped
+ depends_on:
+ db:
+ condition: service_healthy
+ environment:
+ DATABASE_URL: ${DATABASE_URL:-ecto://parrhesia:parrhesia@db:5432/parrhesia}
+ POOL_SIZE: ${POOL_SIZE:-20}
+ ports:
+ - "${PARRHESIA_HOST_PORT:-4000}:4000"
+
+volumes:
+ postgres-data:
diff --git a/config/runtime.exs b/config/runtime.exs
index 58c223f..d4eee1b 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -1,33 +1,373 @@
import Config
+string_env = fn name, default ->
+ case System.get_env(name) do
+ nil -> default
+ "" -> default
+ value -> value
+ end
+end
+
+int_env = fn name, default ->
+ case System.get_env(name) do
+ nil -> default
+ value -> String.to_integer(value)
+ end
+end
+
+bool_env = fn name, default ->
+ case System.get_env(name) do
+ nil ->
+ default
+
+ value ->
+ case String.downcase(value) do
+ "1" -> true
+ "true" -> true
+ "yes" -> true
+ "on" -> true
+ "0" -> false
+ "false" -> false
+ "no" -> false
+ "off" -> false
+ _other -> raise "environment variable #{name} must be a boolean value"
+ end
+ end
+end
+
+csv_env = fn name, default ->
+ case System.get_env(name) do
+ nil ->
+ default
+
+ value ->
+ value
+ |> String.split(",", trim: true)
+ |> Enum.map(&String.trim/1)
+ |> Enum.reject(&(&1 == ""))
+ end
+end
+
+outbound_overflow_strategy_env = fn name, default ->
+ case System.get_env(name) do
+ nil ->
+ default
+
+ "close" ->
+ :close
+
+ "drop_oldest" ->
+ :drop_oldest
+
+ "drop_newest" ->
+ :drop_newest
+
+ _other ->
+ raise "environment variable #{name} must be one of: close, drop_oldest, drop_newest"
+ end
+end
+
+ipv4_env = fn name, default ->
+ case System.get_env(name) do
+ nil ->
+ default
+
+ value ->
+ case String.split(value, ".", parts: 4) do
+ [a, b, c, d] ->
+ octets = Enum.map([a, b, c, d], &String.to_integer/1)
+
+ if Enum.all?(octets, &(&1 >= 0 and &1 <= 255)) do
+ List.to_tuple(octets)
+ else
+ raise "environment variable #{name} must be a valid IPv4 address"
+ end
+
+ _other ->
+ raise "environment variable #{name} must be a valid IPv4 address"
+ end
+ end
+end
+
if config_env() == :prod do
database_url =
System.get_env("DATABASE_URL") ||
raise "environment variable DATABASE_URL is missing. Example: ecto://USER:PASS@HOST/DATABASE"
repo_defaults = Application.get_env(:parrhesia, Parrhesia.Repo, [])
+ relay_url_default = Application.get_env(:parrhesia, :relay_url)
+
+ moderation_cache_enabled_default =
+ Application.get_env(:parrhesia, :moderation_cache_enabled, true)
+
+ enable_expiration_worker_default =
+ Application.get_env(:parrhesia, :enable_expiration_worker, true)
+
+ limits_defaults = Application.get_env(:parrhesia, :limits, [])
+ policies_defaults = Application.get_env(:parrhesia, :policies, [])
+ metrics_defaults = Application.get_env(:parrhesia, :metrics, [])
+ features_defaults = Application.get_env(:parrhesia, :features, [])
+ metrics_endpoint_defaults = Application.get_env(:parrhesia, Parrhesia.Web.MetricsEndpoint, [])
default_pool_size = Keyword.get(repo_defaults, :pool_size, 32)
default_queue_target = Keyword.get(repo_defaults, :queue_target, 1_000)
default_queue_interval = Keyword.get(repo_defaults, :queue_interval, 5_000)
- pool_size =
- case System.get_env("POOL_SIZE") do
- nil -> default_pool_size
- value -> String.to_integer(value)
- end
+ pool_size = int_env.("POOL_SIZE", default_pool_size)
+ queue_target = int_env.("DB_QUEUE_TARGET_MS", default_queue_target)
+ queue_interval = int_env.("DB_QUEUE_INTERVAL_MS", default_queue_interval)
- queue_target =
- case System.get_env("DB_QUEUE_TARGET_MS") do
- nil -> default_queue_target
- value -> String.to_integer(value)
- end
+ limits = [
+ max_frame_bytes:
+ int_env.(
+ "PARRHESIA_LIMITS_MAX_FRAME_BYTES",
+ Keyword.get(limits_defaults, :max_frame_bytes, 1_048_576)
+ ),
+ max_event_bytes:
+ int_env.(
+ "PARRHESIA_LIMITS_MAX_EVENT_BYTES",
+ Keyword.get(limits_defaults, :max_event_bytes, 262_144)
+ ),
+ max_filters_per_req:
+ int_env.(
+ "PARRHESIA_LIMITS_MAX_FILTERS_PER_REQ",
+ Keyword.get(limits_defaults, :max_filters_per_req, 16)
+ ),
+ max_filter_limit:
+ int_env.(
+ "PARRHESIA_LIMITS_MAX_FILTER_LIMIT",
+ Keyword.get(limits_defaults, :max_filter_limit, 500)
+ ),
+ max_subscriptions_per_connection:
+ int_env.(
+ "PARRHESIA_LIMITS_MAX_SUBSCRIPTIONS_PER_CONNECTION",
+ Keyword.get(limits_defaults, :max_subscriptions_per_connection, 32)
+ ),
+ max_event_future_skew_seconds:
+ int_env.(
+ "PARRHESIA_LIMITS_MAX_EVENT_FUTURE_SKEW_SECONDS",
+ Keyword.get(limits_defaults, :max_event_future_skew_seconds, 900)
+ ),
+ max_event_ingest_per_window:
+ int_env.(
+ "PARRHESIA_LIMITS_MAX_EVENT_INGEST_PER_WINDOW",
+ Keyword.get(limits_defaults, :max_event_ingest_per_window, 120)
+ ),
+ event_ingest_window_seconds:
+ int_env.(
+ "PARRHESIA_LIMITS_EVENT_INGEST_WINDOW_SECONDS",
+ Keyword.get(limits_defaults, :event_ingest_window_seconds, 1)
+ ),
+ auth_max_age_seconds:
+ int_env.(
+ "PARRHESIA_LIMITS_AUTH_MAX_AGE_SECONDS",
+ Keyword.get(limits_defaults, :auth_max_age_seconds, 600)
+ ),
+ max_outbound_queue:
+ int_env.(
+ "PARRHESIA_LIMITS_MAX_OUTBOUND_QUEUE",
+ Keyword.get(limits_defaults, :max_outbound_queue, 256)
+ ),
+ outbound_drain_batch_size:
+ int_env.(
+ "PARRHESIA_LIMITS_OUTBOUND_DRAIN_BATCH_SIZE",
+ Keyword.get(limits_defaults, :outbound_drain_batch_size, 64)
+ ),
+ outbound_overflow_strategy:
+ outbound_overflow_strategy_env.(
+ "PARRHESIA_LIMITS_OUTBOUND_OVERFLOW_STRATEGY",
+ Keyword.get(limits_defaults, :outbound_overflow_strategy, :close)
+ ),
+ max_negentropy_payload_bytes:
+ int_env.(
+ "PARRHESIA_LIMITS_MAX_NEGENTROPY_PAYLOAD_BYTES",
+ Keyword.get(limits_defaults, :max_negentropy_payload_bytes, 4096)
+ ),
+ max_negentropy_sessions_per_connection:
+ int_env.(
+ "PARRHESIA_LIMITS_MAX_NEGENTROPY_SESSIONS_PER_CONNECTION",
+ Keyword.get(limits_defaults, :max_negentropy_sessions_per_connection, 8)
+ ),
+ max_negentropy_total_sessions:
+ int_env.(
+ "PARRHESIA_LIMITS_MAX_NEGENTROPY_TOTAL_SESSIONS",
+ Keyword.get(limits_defaults, :max_negentropy_total_sessions, 10_000)
+ ),
+ negentropy_session_idle_timeout_seconds:
+ int_env.(
+ "PARRHESIA_LIMITS_NEGENTROPY_SESSION_IDLE_TIMEOUT_SECONDS",
+ Keyword.get(limits_defaults, :negentropy_session_idle_timeout_seconds, 60)
+ ),
+ negentropy_session_sweep_interval_seconds:
+ int_env.(
+ "PARRHESIA_LIMITS_NEGENTROPY_SESSION_SWEEP_INTERVAL_SECONDS",
+ Keyword.get(limits_defaults, :negentropy_session_sweep_interval_seconds, 10)
+ )
+ ]
- queue_interval =
- case System.get_env("DB_QUEUE_INTERVAL_MS") do
- nil -> default_queue_interval
- value -> String.to_integer(value)
- end
+ policies = [
+ auth_required_for_writes:
+ bool_env.(
+ "PARRHESIA_POLICIES_AUTH_REQUIRED_FOR_WRITES",
+ Keyword.get(policies_defaults, :auth_required_for_writes, false)
+ ),
+ auth_required_for_reads:
+ bool_env.(
+ "PARRHESIA_POLICIES_AUTH_REQUIRED_FOR_READS",
+ Keyword.get(policies_defaults, :auth_required_for_reads, false)
+ ),
+ min_pow_difficulty:
+ int_env.(
+ "PARRHESIA_POLICIES_MIN_POW_DIFFICULTY",
+ Keyword.get(policies_defaults, :min_pow_difficulty, 0)
+ ),
+ accept_ephemeral_events:
+ bool_env.(
+ "PARRHESIA_POLICIES_ACCEPT_EPHEMERAL_EVENTS",
+ Keyword.get(policies_defaults, :accept_ephemeral_events, true)
+ ),
+ mls_group_event_ttl_seconds:
+ int_env.(
+ "PARRHESIA_POLICIES_MLS_GROUP_EVENT_TTL_SECONDS",
+ Keyword.get(policies_defaults, :mls_group_event_ttl_seconds, 300)
+ ),
+ marmot_require_h_for_group_queries:
+ bool_env.(
+ "PARRHESIA_POLICIES_MARMOT_REQUIRE_H_FOR_GROUP_QUERIES",
+ Keyword.get(policies_defaults, :marmot_require_h_for_group_queries, true)
+ ),
+ marmot_group_max_h_values_per_filter:
+ int_env.(
+ "PARRHESIA_POLICIES_MARMOT_GROUP_MAX_H_VALUES_PER_FILTER",
+ Keyword.get(policies_defaults, :marmot_group_max_h_values_per_filter, 32)
+ ),
+ marmot_group_max_query_window_seconds:
+ int_env.(
+ "PARRHESIA_POLICIES_MARMOT_GROUP_MAX_QUERY_WINDOW_SECONDS",
+ Keyword.get(policies_defaults, :marmot_group_max_query_window_seconds, 2_592_000)
+ ),
+ marmot_media_max_imeta_tags_per_event:
+ int_env.(
+ "PARRHESIA_POLICIES_MARMOT_MEDIA_MAX_IMETA_TAGS_PER_EVENT",
+ Keyword.get(policies_defaults, :marmot_media_max_imeta_tags_per_event, 8)
+ ),
+ marmot_media_max_field_value_bytes:
+ int_env.(
+ "PARRHESIA_POLICIES_MARMOT_MEDIA_MAX_FIELD_VALUE_BYTES",
+ Keyword.get(policies_defaults, :marmot_media_max_field_value_bytes, 1024)
+ ),
+ marmot_media_max_url_bytes:
+ int_env.(
+ "PARRHESIA_POLICIES_MARMOT_MEDIA_MAX_URL_BYTES",
+ Keyword.get(policies_defaults, :marmot_media_max_url_bytes, 2048)
+ ),
+ marmot_media_allowed_mime_prefixes:
+ csv_env.(
+ "PARRHESIA_POLICIES_MARMOT_MEDIA_ALLOWED_MIME_PREFIXES",
+ Keyword.get(policies_defaults, :marmot_media_allowed_mime_prefixes, [])
+ ),
+ marmot_media_reject_mip04_v1:
+ bool_env.(
+ "PARRHESIA_POLICIES_MARMOT_MEDIA_REJECT_MIP04_V1",
+ Keyword.get(policies_defaults, :marmot_media_reject_mip04_v1, true)
+ ),
+ marmot_push_server_pubkeys:
+ csv_env.(
+ "PARRHESIA_POLICIES_MARMOT_PUSH_SERVER_PUBKEYS",
+ Keyword.get(policies_defaults, :marmot_push_server_pubkeys, [])
+ ),
+ marmot_push_max_relay_tags:
+ int_env.(
+ "PARRHESIA_POLICIES_MARMOT_PUSH_MAX_RELAY_TAGS",
+ Keyword.get(policies_defaults, :marmot_push_max_relay_tags, 16)
+ ),
+ marmot_push_max_payload_bytes:
+ int_env.(
+ "PARRHESIA_POLICIES_MARMOT_PUSH_MAX_PAYLOAD_BYTES",
+ Keyword.get(policies_defaults, :marmot_push_max_payload_bytes, 65_536)
+ ),
+ marmot_push_max_trigger_age_seconds:
+ int_env.(
+ "PARRHESIA_POLICIES_MARMOT_PUSH_MAX_TRIGGER_AGE_SECONDS",
+ Keyword.get(policies_defaults, :marmot_push_max_trigger_age_seconds, 120)
+ ),
+ marmot_push_require_expiration:
+ bool_env.(
+ "PARRHESIA_POLICIES_MARMOT_PUSH_REQUIRE_EXPIRATION",
+ Keyword.get(policies_defaults, :marmot_push_require_expiration, true)
+ ),
+ marmot_push_max_expiration_window_seconds:
+ int_env.(
+ "PARRHESIA_POLICIES_MARMOT_PUSH_MAX_EXPIRATION_WINDOW_SECONDS",
+ Keyword.get(policies_defaults, :marmot_push_max_expiration_window_seconds, 120)
+ ),
+ marmot_push_max_server_recipients:
+ int_env.(
+ "PARRHESIA_POLICIES_MARMOT_PUSH_MAX_SERVER_RECIPIENTS",
+ Keyword.get(policies_defaults, :marmot_push_max_server_recipients, 1)
+ ),
+ management_auth_required:
+ bool_env.(
+ "PARRHESIA_POLICIES_MANAGEMENT_AUTH_REQUIRED",
+ Keyword.get(policies_defaults, :management_auth_required, true)
+ )
+ ]
+
+ metrics = [
+ enabled_on_main_endpoint:
+ bool_env.(
+ "PARRHESIA_METRICS_ENABLED_ON_MAIN_ENDPOINT",
+ Keyword.get(metrics_defaults, :enabled_on_main_endpoint, true)
+ ),
+ public:
+ bool_env.(
+ "PARRHESIA_METRICS_PUBLIC",
+ Keyword.get(metrics_defaults, :public, false)
+ ),
+ private_networks_only:
+ bool_env.(
+ "PARRHESIA_METRICS_PRIVATE_NETWORKS_ONLY",
+ Keyword.get(metrics_defaults, :private_networks_only, true)
+ ),
+ allowed_cidrs:
+ csv_env.(
+ "PARRHESIA_METRICS_ALLOWED_CIDRS",
+ Keyword.get(metrics_defaults, :allowed_cidrs, [])
+ ),
+ auth_token:
+ string_env.(
+ "PARRHESIA_METRICS_AUTH_TOKEN",
+ Keyword.get(metrics_defaults, :auth_token)
+ )
+ ]
+
+ features = [
+ verify_event_signatures:
+ bool_env.(
+ "PARRHESIA_FEATURES_VERIFY_EVENT_SIGNATURES",
+ Keyword.get(features_defaults, :verify_event_signatures, true)
+ ),
+ nip_45_count:
+ bool_env.(
+ "PARRHESIA_FEATURES_NIP_45_COUNT",
+ Keyword.get(features_defaults, :nip_45_count, true)
+ ),
+ nip_50_search:
+ bool_env.(
+ "PARRHESIA_FEATURES_NIP_50_SEARCH",
+ Keyword.get(features_defaults, :nip_50_search, true)
+ ),
+ nip_77_negentropy:
+ bool_env.(
+ "PARRHESIA_FEATURES_NIP_77_NEGENTROPY",
+ Keyword.get(features_defaults, :nip_77_negentropy, true)
+ ),
+ marmot_push_notifications:
+ bool_env.(
+ "PARRHESIA_FEATURES_MARMOT_PUSH_NOTIFICATIONS",
+ Keyword.get(features_defaults, :marmot_push_notifications, false)
+ )
+ ]
config :parrhesia, Parrhesia.Repo,
url: database_url,
@@ -35,6 +375,39 @@ if config_env() == :prod do
queue_target: queue_target,
queue_interval: queue_interval
- config :parrhesia, Parrhesia.Web.Endpoint,
- port: String.to_integer(System.get_env("PORT") || "4000")
+ config :parrhesia, Parrhesia.Web.Endpoint, port: int_env.("PORT", 4000)
+
+ config :parrhesia, Parrhesia.Web.MetricsEndpoint,
+ enabled:
+ bool_env.(
+ "PARRHESIA_METRICS_ENDPOINT_ENABLED",
+ Keyword.get(metrics_endpoint_defaults, :enabled, false)
+ ),
+ ip:
+ ipv4_env.(
+ "PARRHESIA_METRICS_ENDPOINT_IP",
+ Keyword.get(metrics_endpoint_defaults, :ip, {127, 0, 0, 1})
+ ),
+ port:
+ int_env.(
+ "PARRHESIA_METRICS_ENDPOINT_PORT",
+ Keyword.get(metrics_endpoint_defaults, :port, 9568)
+ )
+
+ config :parrhesia,
+ relay_url: string_env.("PARRHESIA_RELAY_URL", relay_url_default),
+ moderation_cache_enabled:
+ bool_env.("PARRHESIA_MODERATION_CACHE_ENABLED", moderation_cache_enabled_default),
+ enable_expiration_worker:
+ bool_env.("PARRHESIA_ENABLE_EXPIRATION_WORKER", enable_expiration_worker_default),
+ limits: limits,
+ policies: policies,
+ metrics: metrics,
+ features: features
+
+ case System.get_env("PARRHESIA_EXTRA_CONFIG") do
+ nil -> :ok
+ "" -> :ok
+ path -> import_config path
+ end
end
diff --git a/docs/logo.afdesign b/docs/logo.afdesign
new file mode 100644
index 0000000..24a2303
Binary files /dev/null and b/docs/logo.afdesign differ
diff --git a/docs/logo.svg b/docs/logo.svg
new file mode 100644
index 0000000..f6da3cd
--- /dev/null
+++ b/docs/logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/slop/HARDEN.md b/docs/slop/HARDEN.md
new file mode 100644
index 0000000..f908e29
--- /dev/null
+++ b/docs/slop/HARDEN.md
@@ -0,0 +1,279 @@
+# Hardening Review: Parrhesia Nostr Relay
+
+You are a security engineer specialising in real-time WebSocket servers, Erlang/OTP systems, and protocol-level abuse. You are reviewing **Parrhesia**, a Nostr relay (NIP-01 compliant) written in Elixir, for hardening opportunities — with a primary focus on **denial-of-service resilience** and a secondary focus on the full attack surface.
+
+Produce a prioritised list of **specific, actionable recommendations** with rationale. For each recommendation, state:
+1. The attack or failure mode it mitigates
+2. Suggested implementation (config change, code change, or architectural change)
+3. Severity estimate (critical / high / medium / low)
+
+---
+
+## 1. Architecture Overview
+
+| Component | Technology | Notes |
+|---|---|---|
+| Runtime | Elixir/OTP 27, BEAM VM | Each WS connection is a separate process |
+| HTTP server | Bandit (pure Elixir) | HTTP/1.1 only, no HTTP/2 |
+| WebSocket | `websock_adapter` | Text frames only; binary rejected |
+| Database | PostgreSQL via Ecto | Range-partitioned `events` table by `created_at` |
+| Caching | ETS | Config snapshot + moderation ban/allow lists |
+| Multi-node | Erlang `:pg` groups | Fanout across BEAM cluster nodes |
+| Metrics | Prometheus (Telemetry) | `/metrics` endpoint |
+| TLS termination | **Out of scope** — handled by reverse proxy (nginx/Caddy) |
+
+### Supervision Tree
+
+```
+Parrhesia.Supervisor
+ ├─ Telemetry (Prometheus exporter)
+ ├─ Config (ETS snapshot of runtime config)
+ ├─ Storage.Supervisor (Ecto repo + moderation cache)
+ ├─ Subscriptions.Supervisor (ETS subscription index for fanout)
+ ├─ Auth.Supervisor (NIP-42 challenge GenServer)
+ ├─ Policy.Supervisor (policy enforcement)
+ ├─ Web.Endpoint (Bandit listener)
+ └─ Tasks.Supervisor (ExpirationWorker, 30s GC loop)
+```
+
+### Data Flow
+
+1. Client connects via WebSocket at `/relay`
+2. NIP-42 AUTH challenge issued immediately (16-byte random, base64url)
+3. Inbound text frames are: size-checked → JSON-decoded → rate-limited → protocol-dispatched
+4. EVENT messages: validated → policy-checked → stored in Postgres → ACK → async fanout to matching subscriptions
+5. REQ messages: filters validated → Postgres query → results streamed → EOSE → live subscription registered
+6. Fanout: post-ingest, subscription index (ETS) is traversed; matching connection processes receive events via `send/2`
+
+---
+
+## 2. Current Defences Inventory
+
+### Connection Layer
+
+| Defence | Value | Enforcement Point |
+|---|---|---|
+| Max WebSocket frame size | **1,048,576 bytes (1 MiB)** | Checked in `handle_in` *before* JSON decode, and at Bandit upgrade (`max_frame_size`) |
+| WebSocket upgrade timeout | **60,000 ms** | Passed to `WebSockAdapter.upgrade` |
+| Binary frame rejection | Returns NOTICE, connection stays open | `handle_in` opcode check |
+| Outbound queue limit | **256 events** per connection | Overflow strategy: **`:close`** (WS 1008) |
+| Outbound drain batch | **64 events** | Async drain via `send(self(), :drain_outbound_queue)` |
+| Outbound pressure telemetry | Threshold at **75%** of queue | Emits telemetry event only, no enforcement |
+| IP blocking | Via moderation cache (ETS) | Management API can add blocked IPs |
+
+### Protocol Layer
+
+| Defence | Value | Notes |
+|---|---|---|
+| Max event JSON size | **262,144 bytes (256 KiB)** | Re-serialises decoded event and checks byte size |
+| Max filters per REQ | **16** | Rejected at filter validation |
+| Max filter `limit` | **500** | `min(client_limit, 500)` applied at query time |
+| Max subscriptions per connection | **32** | Existing sub IDs updated without counting toward limit |
+| Subscription ID max length | **64 characters** | Must be non-empty |
+| Event kind range | **0–65,535** | Integer range check |
+| Max future event skew | **900 seconds (15 min)** | Events with `created_at > now + 900` rejected |
+| Unknown filter keys | **Rejected** | Allowed: `ids`, `authors`, `kinds`, `since`, `until`, `limit`, `search`, `#` |
+
+### Event Validation Pipeline
+
+Strict order:
+1. Required fields present (`id`, `pubkey`, `created_at`, `kind`, `tags`, `content`, `sig`)
+2. `id` — 64-char lowercase hex
+3. `pubkey` — 64-char lowercase hex
+4. `created_at` — non-negative integer, max 900s future skew
+5. `kind` — integer in [0, 65535]
+6. `tags` — list of non-empty string arrays (**no length limit on tags array or individual tag values**)
+7. `content` — any binary string
+8. `sig` — 128-char lowercase hex
+9. ID hash recomputation and comparison
+10. Schnorr signature verification via `lib_secp256k1` (gated by `verify_event_signatures` flag, default `true`)
+
+### Rate Limiting
+
+| Defence | Value | Notes |
+|---|---|---|
+| Event ingest rate | **120 events per window** | Per-connection sliding window |
+| Ingest window | **1 second** | Resets on first event after expiry |
+| No per-IP connection rate limiting | — | Must be handled at reverse proxy |
+| No global connection count ceiling | — | BEAM handles thousands but no configured limit |
+
+### Authentication (NIP-42)
+
+- Challenge issued to **all** connections on connect (optional escalation model)
+- AUTH event must: pass full NIP-01 validation, be kind `22242`, contain matching `challenge` tag, contain matching `relay` tag
+- `created_at` freshness: must be `>= now - 600s` (10 min)
+- On success: pubkey added to `authenticated_pubkeys` MapSet; challenge rotated
+- Supports multiple authenticated pubkeys per connection
+
+### Authentication (NIP-98 HTTP)
+
+- Management endpoint (`POST /management`) requires NIP-98 header
+- Auth event must be kind `27235`, `created_at` within **60 seconds** of now
+- Must include `method` and `u` tags matching request exactly
+
+### Access Control
+
+- `auth_required_for_writes`: default **false** (configurable)
+- `auth_required_for_reads`: default **false** (configurable)
+- Protected events (NIP-70, tagged `["-"]`): require auth + pubkey match
+- Giftwrap (kind 1059): unauthenticated REQ → CLOSED; authenticated REQ must include `#p` containing own pubkey
+
+### Database
+
+- All queries use Ecto parameterised bindings — no raw string interpolation
+- LIKE search patterns escaped (`%`, `_`, `\` characters)
+- Deletion enforces `pubkey == deleter_pubkey` in WHERE clause
+- Soft-delete via `deleted_at`; hard-delete only via vanish (NIP-62) or expiration purge
+- DB pool: **32 connections** (prod), queue target 1s, interval 5s
+
+### Moderation
+
+- Banned pubkeys, allowed pubkeys, banned events, blocked IPs stored in ETS cache
+- Management API (NIP-98 authed) for CRUD on moderation lists
+- Cache invalidated atomically on writes
+
+---
+
+## 3. Known Gaps and Areas of Concern
+
+The following are areas where the current implementation may be vulnerable or where defences could be strengthened. **Please evaluate each and provide recommendations.**
+
+### 3.1 Connection Exhaustion
+
+- There is **no global limit on concurrent WebSocket connections**. Each connection is an Elixir process (~2–3 KiB base), but subscriptions, auth state, and outbound queues add per-connection memory.
+- There is **no per-IP connection rate limiting at the application layer**. IP blocking exists but is reactive (management API), not automatic.
+- There is **no idle timeout** after the WebSocket upgrade completes. A connection can remain open indefinitely without sending or receiving messages.
+
+**Questions:**
+- What connection limits should be configured at the Bandit/BEAM level?
+- Should an idle timeout be implemented? If so, what value balances real-time subscription use against resource waste?
+- Should per-IP connection counting be implemented at the application layer, or is this strictly a reverse proxy concern?
+
+### 3.2 Subscription Abuse
+
+- A single connection can hold **32 subscriptions**, each with up to **16 filters**. That's 512 filter predicates per connection being evaluated on every fanout.
+- Filter arrays (`ids`, `authors`, `kinds`, tag values) have **no element count limits**. A filter could contain thousands of author pubkeys.
+- There is no cost accounting for "expensive" subscriptions (e.g., wide open filters matching all events).
+
+**Questions:**
+- Should filter array element counts be bounded? If so, what limits per field?
+- Should there be a per-connection "filter complexity" budget?
+- How expensive is the current ETS subscription index traversal at scale (e.g., 10K concurrent connections × 32 subs each)?
+
+### 3.3 Tag Array Size
+
+- Event validation does **not limit the number of tags** or the length of individual tag values beyond the 256 KiB total event size cap.
+- A maximally-tagged event could contain thousands of short tags, causing amplification in `event_tags` table inserts (one row per tag).
+
+**Questions:**
+- Should a max tag count be enforced? What is a reasonable limit?
+- What is the insert cost of storing e.g. 1,000 tags per event? Could this be used for write amplification?
+- Should individual tag value lengths be bounded?
+
+### 3.4 AUTH Timing
+
+- AUTH event `created_at` freshness only checks the **lower bound** (`>= now - 600`). An AUTH event with `created_at` far in the future passes validation.
+- Regular events have a future skew cap of 900s, but AUTH events do not.
+
+**Questions:**
+- Should AUTH events also enforce a future `created_at` bound?
+- Is a 600-second AUTH window too wide? Could it be reduced?
+
+### 3.5 Outbound Amplification
+
+- A single inbound EVENT can fan out to an unbounded number of matching subscriptions across all connections.
+- The outbound queue (256 events, `:close` strategy) protects individual connections but does not limit total fanout work per event.
+- The fanout traverses the ETS subscription index synchronously in the ingesting connection's process.
+
+**Questions:**
+- Should fanout be bounded per event (e.g., max N recipients before yielding)?
+- Should fanout happen in a separate process pool rather than inline?
+- Is the `:close` overflow strategy optimal, or would `:drop_oldest` be better for well-behaved clients with temporary backpressure?
+
+### 3.6 Query Amplification
+
+- A single REQ with 16 filters, each with `limit: 500`, could trigger 16 separate Postgres queries returning up to 8,000 events total.
+- COUNT requests also execute per-filter queries (now deduplicated via UNION ALL).
+- `search` filters use `ILIKE %pattern%` which cannot use B-tree indexes.
+
+**Questions:**
+- Should there be a per-REQ total result cap (across all filters)?
+- Should `search` queries be rate-limited or require a minimum pattern length?
+- Should COUNT be disabled or rate-limited separately?
+- Are there missing indexes that would help common query patterns?
+
+### 3.7 Multi-Node Trust
+
+- Events received via `:remote_fanout_event` from peer BEAM nodes **skip all validation and policy checks** and go directly to the subscription index.
+- This assumes all cluster peers are trusted.
+
+**Questions:**
+- If cluster membership is dynamic or spans trust boundaries, should remote events be re-validated?
+- Should there be a shared secret or HMAC on inter-node messages?
+
+### 3.8 Metrics Endpoint
+
+- `/metrics` (Prometheus) is **unauthenticated**.
+- Exposes internal telemetry: connection counts, event throughput, queue depths, database timing.
+
+**Questions:**
+- Should `/metrics` require authentication or be restricted to internal networks?
+- Could metrics data be used to profile the relay's capacity and craft targeted attacks?
+
+### 3.9 Negentropy Stub
+
+- NEG-OPEN, NEG-MSG, NEG-CLOSE messages are accepted and acknowledged but the reconciliation logic is a stub (cursor counter only).
+- Are there resource implications of accepting negentropy sessions without real implementation?
+
+### 3.10 Event Re-Serialisation Cost
+
+- To enforce the 256 KiB event size limit, the relay calls `JSON.encode!(event)` on the already-decoded event map. This re-serialisation happens on every inbound EVENT.
+- Could this be replaced with a byte-length check on the raw frame payload (already available)?
+
+---
+
+## 4. Specific Review Requests
+
+Beyond the gaps above, please also evaluate:
+
+1. **Bandit configuration**: Are there Bandit-level options (max connections, header limits, request timeouts, keepalive settings) that should be tuned for a public-facing relay?
+
+2. **BEAM VM flags**: Are there any Erlang VM flags (`+P`, `+Q`, `+S`, memory limits) that should be set for production hardening?
+
+3. **Ecto pool exhaustion**: With 32 DB connections and potentially thousands of concurrent REQ queries, what happens under pool exhaustion? Is the 1s queue target + 5s interval appropriate?
+
+4. **ETS table sizing**: The subscription index and moderation cache use ETS. Are there memory limits or table options (`read_concurrency`, `write_concurrency`, `compressed`) that should be tuned?
+
+5. **Process mailbox overflow**: Connection processes receive events via `send/2` during fanout. If a process is slow to consume, its mailbox grows. The outbound queue mechanism is application-level — but is the BEAM-level mailbox also protected?
+
+6. **Reverse proxy recommendations**: What nginx/Caddy configuration should complement the relay's defences? (Rate limiting, connection limits, WebSocket-specific settings, request body size.)
+
+7. **Monitoring and alerting**: What telemetry signals should trigger alerts? (Connection count spikes, queue overflow rates, DB pool saturation, error rates.)
+
+---
+
+## 5. Out of Scope
+
+The following are **not** in scope for this review:
+- TLS configuration (handled by reverse proxy)
+- DNS and network-level DDoS mitigation
+- Operating system hardening
+- Key management for the relay identity
+- Client-side security
+- Nostr protocol design flaws (we implement the spec as-is)
+
+---
+
+## 6. Response Format
+
+For each recommendation, use this format:
+
+### [Severity] Title
+
+**Attack/failure mode:** What goes wrong without this mitigation.
+
+**Current state:** What exists today (or doesn't).
+
+**Recommendation:** Specific change — config value, code change, or architectural decision.
+
+**Trade-offs:** Any impact on legitimate users or operational complexity.
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..729409a
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,27 @@
+{
+ "nodes": {
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1773389992,
+ "narHash": "sha256-wvfdLLWJ2I9oEpDd9PfMA8osfIZicoQ5MT1jIwNs9Tk=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "c06b4ae3d6599a672a6210b7021d699c351eebda",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "nixpkgs": "nixpkgs"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..ad4b28b
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,68 @@
+{
+ description = "Parrhesia Nostr relay";
+
+ inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+
+ outputs = {nixpkgs, ...}: let
+ systems = [
+ "x86_64-linux"
+ "aarch64-linux"
+ "x86_64-darwin"
+ "aarch64-darwin"
+ ];
+
+ forAllSystems = nixpkgs.lib.genAttrs systems;
+ in {
+ formatter = forAllSystems (system: (import nixpkgs {inherit system;}).alejandra);
+
+ packages = forAllSystems (
+ system: let
+ pkgs = import nixpkgs {inherit system;};
+ lib = pkgs.lib;
+ parrhesia = pkgs.callPackage ./default.nix {};
+ in
+ {
+ default = parrhesia;
+ inherit parrhesia;
+ }
+ // lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
+ dockerImage = pkgs.dockerTools.buildLayeredImage {
+ name = "parrhesia";
+ tag = "latest";
+
+ contents = [
+ parrhesia
+ pkgs.bash
+ pkgs.cacert
+ pkgs.coreutils
+ pkgs.fakeNss
+ ];
+
+ extraCommands = ''
+ mkdir -p tmp
+ chmod 1777 tmp
+ '';
+
+ config = {
+ Entrypoint = ["${parrhesia}/bin/parrhesia"];
+ Cmd = ["foreground"];
+ ExposedPorts = {
+ "4000/tcp" = {};
+ };
+ WorkingDir = "/";
+ User = "65534:65534";
+ Env = [
+ "HOME=/tmp"
+ "LANG=C.UTF-8"
+ "LC_ALL=C.UTF-8"
+ "MIX_ENV=prod"
+ "PORT=4000"
+ "RELEASE_DISTRIBUTION=none"
+ "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
+ ];
+ };
+ };
+ }
+ );
+ };
+}
diff --git a/lib/parrhesia/release.ex b/lib/parrhesia/release.ex
new file mode 100644
index 0000000..6b48d20
--- /dev/null
+++ b/lib/parrhesia/release.ex
@@ -0,0 +1,35 @@
+defmodule Parrhesia.Release do
+ @moduledoc """
+ Helpers for running Ecto tasks from a production release.
+ """
+
+ @app :parrhesia
+
+ def migrate do
+ load_app()
+
+ for repo <- repos() do
+ {:ok, _, _} =
+ Ecto.Migrator.with_repo(repo, fn repo ->
+ Ecto.Migrator.run(repo, :up, all: true)
+ end)
+ end
+ end
+
+ def rollback(repo, version) when is_atom(repo) and is_integer(version) do
+ load_app()
+
+ {:ok, _, _} =
+ Ecto.Migrator.with_repo(repo, fn repo ->
+ Ecto.Migrator.run(repo, :down, to: version)
+ end)
+ end
+
+ defp load_app do
+ Application.load(@app)
+ end
+
+ defp repos do
+ Application.fetch_env!(@app, :ecto_repos)
+ end
+end
diff --git a/scripts/run_e2e_suite.sh b/scripts/run_e2e_suite.sh
index d78e5e2..d159ee8 100755
--- a/scripts/run_e2e_suite.sh
+++ b/scripts/run_e2e_suite.sh
@@ -86,7 +86,24 @@ cleanup() {
trap cleanup EXIT INT TERM
-if ss -ltn "( sport = :${TEST_HTTP_PORT} )" | tail -n +2 | grep -q .; then
+port_in_use() {
+ local port="$1"
+
+ if command -v ss >/dev/null 2>&1; then
+ ss -ltn "( sport = :${port} )" | tail -n +2 | grep -q .
+ return
+ fi
+
+ if command -v lsof >/dev/null 2>&1; then
+ lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1
+ return
+ fi
+
+ echo "Neither ss nor lsof is available for checking whether port ${port} is already in use." >&2
+ exit 1
+}
+
+if port_in_use "$TEST_HTTP_PORT"; then
echo "Port ${TEST_HTTP_PORT} is already in use. Set ${PORT_ENV_VAR} to a free port." >&2
exit 1
fi
diff --git a/test/parrhesia/protocol/event_validator_signature_test.exs b/test/parrhesia/protocol/event_validator_signature_test.exs
index 86e4e6f..8655151 100644
--- a/test/parrhesia/protocol/event_validator_signature_test.exs
+++ b/test/parrhesia/protocol/event_validator_signature_test.exs
@@ -1,5 +1,5 @@
defmodule Parrhesia.Protocol.EventValidatorSignatureTest do
- use ExUnit.Case, async: true
+ use ExUnit.Case, async: false
alias Parrhesia.Protocol.EventValidator
diff --git a/test/parrhesia/storage/adapters/postgres/events_test.exs b/test/parrhesia/storage/adapters/postgres/events_test.exs
index bd0a7d6..334c155 100644
--- a/test/parrhesia/storage/adapters/postgres/events_test.exs
+++ b/test/parrhesia/storage/adapters/postgres/events_test.exs
@@ -1,5 +1,5 @@
defmodule Parrhesia.Storage.Adapters.Postgres.EventsTest do
- use ExUnit.Case, async: true
+ use ExUnit.Case, async: false
alias Parrhesia.Protocol.EventValidator
alias Parrhesia.Storage.Adapters.Postgres.Events