diff --git a/PROGRESS.md b/PROGRESS.md index 4c985c0..8639717 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -4,10 +4,10 @@ Implementation checklist for Parrhesia relay. ## Phase 0 — foundation -- [ ] Confirm architecture doc with final NIP scope (`docs/ARCH.md`) -- [ ] Add core deps (websocket/http server, ecto_sql/postgrex, telemetry, test tooling) -- [ ] Establish application config structure (limits, policies, feature flags) -- [ ] Wire initial supervision tree skeleton +- [x] Confirm architecture doc with final NIP scope (`docs/ARCH.md`) +- [x] Add core deps (websocket/http server, ecto_sql/postgrex, telemetry, test tooling) +- [x] Establish application config structure (limits, policies, feature flags) +- [x] Wire initial supervision tree skeleton ## Phase 1 — protocol core (NIP-01) diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..13861e0 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,25 @@ +import Config + +config :parrhesia, + limits: [ + max_frame_bytes: 1_048_576, + max_event_bytes: 262_144, + max_filters_per_req: 16, + max_subscriptions_per_connection: 32 + ], + policies: [ + auth_required_for_writes: false, + auth_required_for_reads: false, + min_pow_difficulty: 0, + accept_ephemeral_events: true + ], + features: [ + nip_45_count: true, + nip_50_search: false, + nip_77_negentropy: false, + nip_ee_mls: false + ] + +config :parrhesia, Parrhesia.Web.Endpoint, port: 4000 + +import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..fd08519 --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,3 @@ +import Config + +config :logger, :console, format: "[$level] $message\n" diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..becde76 --- /dev/null +++ b/config/prod.exs @@ -0,0 +1 @@ +import Config diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..63787d4 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,3 @@ +import Config + +config :logger, level: :warning diff --git a/lib/parrhesia/application.ex b/lib/parrhesia/application.ex new file mode 100644 index 0000000..8a81944 --- /dev/null +++ b/lib/parrhesia/application.ex @@ -0,0 +1,22 @@ +defmodule Parrhesia.Application do + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + Parrhesia.Telemetry, + Parrhesia.Config, + Parrhesia.Storage.Supervisor, + Parrhesia.Subscriptions.Supervisor, + Parrhesia.Auth.Supervisor, + Parrhesia.Policy.Supervisor, + Parrhesia.Web.Endpoint, + Parrhesia.Tasks.Supervisor + ] + + opts = [strategy: :one_for_one, name: Parrhesia.Supervisor] + Supervisor.start_link(children, opts) + end +end diff --git a/lib/parrhesia/auth/supervisor.ex b/lib/parrhesia/auth/supervisor.ex new file mode 100644 index 0000000..5021ba3 --- /dev/null +++ b/lib/parrhesia/auth/supervisor.ex @@ -0,0 +1,16 @@ +defmodule Parrhesia.Auth.Supervisor do + @moduledoc """ + Supervision entrypoint for AUTH challenge/session tracking. + """ + + use Supervisor + + def start_link(init_arg \\ []) do + Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__) + end + + @impl true + def init(_init_arg) do + Supervisor.init([], strategy: :one_for_one) + end +end diff --git a/lib/parrhesia/config.ex b/lib/parrhesia/config.ex new file mode 100644 index 0000000..b536aa3 --- /dev/null +++ b/lib/parrhesia/config.ex @@ -0,0 +1,65 @@ +defmodule Parrhesia.Config do + @moduledoc """ + Runtime configuration cache backed by ETS. + """ + + use GenServer + + @table __MODULE__ + @root_key :config + + def start_link(init_arg \\ []) do + GenServer.start_link(__MODULE__, init_arg, name: __MODULE__) + end + + @impl true + def init(_init_arg) do + _table = :ets.new(@table, [:named_table, :public, read_concurrency: true]) + + config = + :parrhesia + |> Application.get_all_env() + |> Enum.into(%{}) + + :ets.insert(@table, {@root_key, config}) + + {:ok, %{}} + end + + @spec all() :: map() | keyword() + def all do + case :ets.lookup(@table, @root_key) do + [{@root_key, config}] -> config + [] -> %{} + end + end + + @spec get([atom()], term()) :: term() + def get(path, default \\ nil) when is_list(path) do + case fetch(path) do + {:ok, value} -> value + :error -> default + end + end + + defp fetch(path) do + Enum.reduce_while(path, {:ok, all()}, fn key, {:ok, current} -> + case fetch_key(current, key) do + {:ok, value} -> {:cont, {:ok, value}} + :error -> {:halt, :error} + end + end) + end + + defp fetch_key(current, key) when is_map(current), do: Map.fetch(current, key) + + defp fetch_key(current, key) when is_list(current) do + if Keyword.keyword?(current) do + Keyword.fetch(current, key) + else + :error + end + end + + defp fetch_key(_current, _key), do: :error +end diff --git a/lib/parrhesia/policy/supervisor.ex b/lib/parrhesia/policy/supervisor.ex new file mode 100644 index 0000000..11b8175 --- /dev/null +++ b/lib/parrhesia/policy/supervisor.ex @@ -0,0 +1,16 @@ +defmodule Parrhesia.Policy.Supervisor do + @moduledoc """ + Supervision entrypoint for policy/rate-limit/ACL workers. + """ + + use Supervisor + + def start_link(init_arg \\ []) do + Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__) + end + + @impl true + def init(_init_arg) do + Supervisor.init([], strategy: :one_for_one) + end +end diff --git a/lib/parrhesia/storage/supervisor.ex b/lib/parrhesia/storage/supervisor.ex new file mode 100644 index 0000000..a67f39c --- /dev/null +++ b/lib/parrhesia/storage/supervisor.ex @@ -0,0 +1,16 @@ +defmodule Parrhesia.Storage.Supervisor do + @moduledoc """ + Supervision entrypoint for storage adapter processes. + """ + + use Supervisor + + def start_link(init_arg \\ []) do + Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__) + end + + @impl true + def init(_init_arg) do + Supervisor.init([], strategy: :one_for_one) + end +end diff --git a/lib/parrhesia/subscriptions/supervisor.ex b/lib/parrhesia/subscriptions/supervisor.ex new file mode 100644 index 0000000..b009a04 --- /dev/null +++ b/lib/parrhesia/subscriptions/supervisor.ex @@ -0,0 +1,16 @@ +defmodule Parrhesia.Subscriptions.Supervisor do + @moduledoc """ + Supervision entrypoint for subscription index and fanout workers. + """ + + use Supervisor + + def start_link(init_arg \\ []) do + Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__) + end + + @impl true + def init(_init_arg) do + Supervisor.init([], strategy: :one_for_one) + end +end diff --git a/lib/parrhesia/tasks/supervisor.ex b/lib/parrhesia/tasks/supervisor.ex new file mode 100644 index 0000000..f7cd0ba --- /dev/null +++ b/lib/parrhesia/tasks/supervisor.ex @@ -0,0 +1,16 @@ +defmodule Parrhesia.Tasks.Supervisor do + @moduledoc """ + Supervision entrypoint for background maintenance jobs. + """ + + use Supervisor + + def start_link(init_arg \\ []) do + Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__) + end + + @impl true + def init(_init_arg) do + Supervisor.init([], strategy: :one_for_one) + end +end diff --git a/lib/parrhesia/telemetry.ex b/lib/parrhesia/telemetry.ex new file mode 100644 index 0000000..15c7307 --- /dev/null +++ b/lib/parrhesia/telemetry.ex @@ -0,0 +1,16 @@ +defmodule Parrhesia.Telemetry do + @moduledoc """ + Supervision entrypoint for relay telemetry workers. + """ + + use Supervisor + + def start_link(init_arg \\ []) do + Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__) + end + + @impl true + def init(_init_arg) do + Supervisor.init([], strategy: :one_for_one) + end +end diff --git a/lib/parrhesia/web/endpoint.ex b/lib/parrhesia/web/endpoint.ex new file mode 100644 index 0000000..929f97f --- /dev/null +++ b/lib/parrhesia/web/endpoint.ex @@ -0,0 +1,16 @@ +defmodule Parrhesia.Web.Endpoint do + @moduledoc """ + Supervision entrypoint for WS/HTTP ingress. + """ + + use Supervisor + + def start_link(init_arg \\ []) do + Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__) + end + + @impl true + def init(_init_arg) do + Supervisor.init([], strategy: :one_for_one) + end +end diff --git a/mix.exs b/mix.exs index 221c429..ce1ac59 100644 --- a/mix.exs +++ b/mix.exs @@ -7,17 +7,23 @@ defmodule Parrhesia.MixProject do version: "0.1.0", elixir: "~> 1.19", start_permanent: Mix.env() == :prod, - deps: deps() + deps: deps(), + aliases: aliases() ] end # Run "mix help compile.app" to learn about applications. def application do [ + mod: {Parrhesia.Application, []}, extra_applications: [:logger] ] end + def cli do + [preferred_envs: [precommit: :test]] + end + # Run "mix help deps" to learn about dependencies. defp deps do [ @@ -41,8 +47,15 @@ defmodule Parrhesia.MixProject do {:websockex, "~> 0.4", only: :test}, # Project tooling + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:deps_changelog, "~> 0.3"}, {:igniter, "~> 0.6", only: [:dev, :test]} ] end + + defp aliases do + [ + precommit: ["format --check-formatted", "credo --strict", "test"] + ] + end end diff --git a/mix.lock b/mix.lock index 439e230..3f5d323 100644 --- a/mix.lock +++ b/mix.lock @@ -1,14 +1,17 @@ %{ "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"}, + "credo": {:hex, :credo, "1.7.17", "f92b6aa5b26301eaa5a35e4d48ebf5aa1e7094ac00ae38f87086c562caf8a22f", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1eb5645c835f0b6c9b5410f94b5a185057bcf6d62a9c2b476da971cde8749645"}, "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "deps_changelog": {:hex, :deps_changelog, "0.3.5", "65981997d9bc893b8027a0c03da093a4083328c00b17f562df269c2b61d44073", [:mix], [], "hexpm", "298fcd7794395d8e61dba8d29ce8fcee09f1df4d48adb273a41e8f4a1736491e"}, "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, diff --git a/test/parrhesia/application_test.exs b/test/parrhesia/application_test.exs new file mode 100644 index 0000000..64d4274 --- /dev/null +++ b/test/parrhesia/application_test.exs @@ -0,0 +1,15 @@ +defmodule Parrhesia.ApplicationTest do + use ExUnit.Case, async: false + + test "starts the core supervision tree" do + assert is_pid(Process.whereis(Parrhesia.Supervisor)) + assert is_pid(Process.whereis(Parrhesia.Telemetry)) + assert is_pid(Process.whereis(Parrhesia.Config)) + assert is_pid(Process.whereis(Parrhesia.Storage.Supervisor)) + assert is_pid(Process.whereis(Parrhesia.Subscriptions.Supervisor)) + assert is_pid(Process.whereis(Parrhesia.Auth.Supervisor)) + assert is_pid(Process.whereis(Parrhesia.Policy.Supervisor)) + assert is_pid(Process.whereis(Parrhesia.Web.Endpoint)) + assert is_pid(Process.whereis(Parrhesia.Tasks.Supervisor)) + end +end diff --git a/test/parrhesia/config_test.exs b/test/parrhesia/config_test.exs new file mode 100644 index 0000000..7a57a2e --- /dev/null +++ b/test/parrhesia/config_test.exs @@ -0,0 +1,14 @@ +defmodule Parrhesia.ConfigTest do + use ExUnit.Case, async: true + + test "returns configured relay limits/policies/features" do + assert Parrhesia.Config.get([:limits, :max_frame_bytes]) == 1_048_576 + assert Parrhesia.Config.get([:limits, :max_event_bytes]) == 262_144 + assert Parrhesia.Config.get([:policies, :auth_required_for_writes]) == false + assert Parrhesia.Config.get([:features, :nip_ee_mls]) == false + end + + test "returns default for unknown keys" do + assert Parrhesia.Config.get([:limits, :unknown_limit], :missing) == :missing + end +end