From f732d9cf24a25c721816bb06e4c96c6f1885c803 Mon Sep 17 00:00:00 2001 From: Steffen Beyer Date: Wed, 18 Mar 2026 15:28:15 +0100 Subject: [PATCH] Implement full NIP-43 relay access flow --- README.md | 13 + config/config.exs | 5 + lib/parrhesia/api/events.ex | 26 +- lib/parrhesia/groups/flow.ex | 156 +++++-- lib/parrhesia/nip43.ex | 389 ++++++++++++++++++ lib/parrhesia/policy/event_policy.ex | 28 +- lib/parrhesia/protocol/event_validator.ex | 172 +++++++- lib/parrhesia/web/relay_info.ex | 14 +- test/parrhesia/groups/flow_test.exs | 41 +- test/parrhesia/nip43_test.exs | 63 +++ .../protocol/event_validator_nip43_test.exs | 123 ++++++ test/parrhesia/web/connection_nip43_test.exs | 270 ++++++++++++ 12 files changed, 1226 insertions(+), 74 deletions(-) create mode 100644 lib/parrhesia/nip43.ex create mode 100644 test/parrhesia/nip43_test.exs create mode 100644 test/parrhesia/protocol/event_validator_nip43_test.exs create mode 100644 test/parrhesia/web/connection_nip43_test.exs diff --git a/README.md b/README.md index 6639201..1fb0a74 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ Current `supported_nips` list: `1, 9, 11, 13, 17, 40, 42, 43, 44, 45, 50, 59, 62, 66, 70, 77, 86, 98` +`43` is advertised when the built-in NIP-43 relay access flow is enabled. Parrhesia generates relay-signed `28935` invite responses on `REQ`, validates join and leave requests locally, and publishes the resulting signed `8000`, `8001`, and `13534` relay membership events into its own local event store. + `66` is advertised when the built-in NIP-66 publisher is enabled and has at least one relay target. The default config enables it for the `public` relay URL. Parrhesia probes those target relays, collects the resulting NIP-11 / websocket liveness data, and then publishes the signed `10166` and `30166` events locally on this relay. ## Requirements @@ -183,6 +185,7 @@ CSV env vars use comma-separated values. Boolean env vars accept `1/0`, `true/fa | `:identity.private_key` | `PARRHESIA_IDENTITY_PRIVATE_KEY` | `nil` | Optional inline relay private key | | `:moderation_cache_enabled` | `PARRHESIA_MODERATION_CACHE_ENABLED` | `true` | Toggle moderation cache | | `:enable_expiration_worker` | `PARRHESIA_ENABLE_EXPIRATION_WORKER` | `true` | Toggle background expiration worker | +| `:nip43` | config-file driven | see table below | Built-in NIP-43 relay access invite / membership flow | | `:nip66` | config-file driven | see table below | Built-in NIP-66 discovery / monitor publisher | | `:sync.path` | `PARRHESIA_SYNC_PATH` | `nil` | Optional path to sync peer config | | `:sync.start_workers?` | `PARRHESIA_SYNC_START_WORKERS` | `true` | Start outbound sync workers on boot | @@ -266,6 +269,16 @@ Every listener supports this config-file schema: NIP-66 targets are probe sources, not publish destinations. Parrhesia connects to each target relay, collects the configured liveness / discovery data, and stores the resulting signed `10166` / `30166` events in its own local event store so clients can query them here. +#### `:nip43` + +| Atom key | ENV | Default | Notes | +| --- | --- | --- | --- | +| `:enabled` | `-` | `true` | Enables the built-in NIP-43 relay access flow and advertises `43` in NIP-11 | +| `:invite_ttl_seconds` | `-` | `900` | Expiration window for generated invite claim strings returned by `REQ` filters targeting kind `28935` | +| `:request_max_age_seconds` | `-` | `300` | Maximum allowed age for inbound join (`28934`) and leave (`28936`) requests | + +Parrhesia treats NIP-43 invite requests as synthetic relay output, not stored client input. A `REQ` for kind `28935` causes the relay to generate a fresh relay-signed invite event on the fly. Clients then submit that claim back in a protected kind `28934` join request. When a join or leave request is accepted, Parrhesia updates its local relay membership state and publishes the corresponding relay-signed `8000` / `8001` delta plus the latest `13534` membership snapshot locally. + #### `:limits` | Atom key | ENV | Default | diff --git a/config/config.exs b/config/config.exs index 1937c7c..5ad85d4 100644 --- a/config/config.exs +++ b/config/config.exs @@ -5,6 +5,11 @@ config :postgrex, :json_library, JSON config :parrhesia, moderation_cache_enabled: true, relay_url: "ws://localhost:4413/relay", + nip43: [ + enabled: true, + invite_ttl_seconds: 900, + request_max_age_seconds: 300 + ], nip66: [ enabled: true, publish_interval_seconds: 900, diff --git a/lib/parrhesia/api/events.ex b/lib/parrhesia/api/events.ex index b438ecf..5f2b45e 100644 --- a/lib/parrhesia/api/events.ex +++ b/lib/parrhesia/api/events.ex @@ -7,7 +7,7 @@ defmodule Parrhesia.API.Events do alias Parrhesia.API.RequestContext alias Parrhesia.Fanout.Dispatcher alias Parrhesia.Fanout.MultiNode - alias Parrhesia.Groups.Flow + alias Parrhesia.NIP43 alias Parrhesia.Policy.EventPolicy alias Parrhesia.Protocol alias Parrhesia.Protocol.Filter @@ -40,7 +40,7 @@ defmodule Parrhesia.API.Events do :ok <- validate_event_payload_size(event, max_event_bytes(opts)), :ok <- Protocol.validate_event(event), :ok <- EventPolicy.authorize_write(event, context.authenticated_pubkeys, context), - :ok <- maybe_process_group_event(event), + {:ok, publish_state} <- NIP43.prepare_publish(event, nip43_opts(opts, context)), {:ok, _stored, message} <- persist_event(event) do Telemetry.emit( [:parrhesia, :ingest, :stop], @@ -48,6 +48,12 @@ defmodule Parrhesia.API.Events do telemetry_metadata_for_event(event) ) + message = + case NIP43.finalize_publish(event, publish_state, nip43_opts(opts, context)) do + {:ok, override} when is_binary(override) -> override + :ok -> message + end + Dispatcher.dispatch(event) maybe_publish_multi_node(event) @@ -85,6 +91,8 @@ defmodule Parrhesia.API.Events do :ok <- maybe_validate_filters(filters, opts), :ok <- maybe_authorize_read(filters, context, opts), {:ok, events} <- Storage.events().query(%{}, filters, storage_query_opts(context, opts)) do + events = NIP43.dynamic_events(filters, nip43_opts(opts, context)) ++ events + Telemetry.emit( [:parrhesia, :query, :stop], %{duration: System.monotonic_time() - started_at}, @@ -108,6 +116,7 @@ defmodule Parrhesia.API.Events do :ok <- maybe_authorize_read(filters, context, opts), {:ok, count} <- Storage.events().count(%{}, filters, requester_pubkeys: requester_pubkeys(context)), + count <- count + NIP43.dynamic_count(filters, nip43_opts(opts, context)), {:ok, result} <- maybe_build_count_result(filters, count, Keyword.get(opts, :options)) do Telemetry.emit( [:parrhesia, :query, :stop], @@ -184,14 +193,6 @@ defmodule Parrhesia.API.Events do |> Base.encode64() end - defp maybe_process_group_event(event) do - if Flow.group_related_kind?(Map.get(event, "kind")) do - Flow.handle_event(event) - else - :ok - end - end - defp persist_event(event) do kind = Map.get(event, "kind") @@ -282,6 +283,11 @@ defmodule Parrhesia.API.Events do end end + defp nip43_opts(opts, %RequestContext{} = context) do + [context: context, relay_url: Application.get_env(:parrhesia, :relay_url)] + |> Kernel.++(Keyword.take(opts, [:path, :private_key, :configured_private_key])) + end + defp error_message_for_publish_failure(:duplicate_event), do: "duplicate: event already stored" diff --git a/lib/parrhesia/groups/flow.ex b/lib/parrhesia/groups/flow.ex index a6866a9..7845b4f 100644 --- a/lib/parrhesia/groups/flow.ex +++ b/lib/parrhesia/groups/flow.ex @@ -1,52 +1,62 @@ defmodule Parrhesia.Groups.Flow do @moduledoc """ - Minimal group and membership flow handling for NIP-29/NIP-43 related kinds. + Relay access membership projection backed by the shared group storage adapter. """ alias Parrhesia.Storage - @membership_request_kind 8_000 - @membership_approval_kind 8_001 - @relay_metadata_kind 28_934 - @relay_admins_kind 28_935 - @relay_rules_kind 28_936 - @membership_event_kind 13_534 + @relay_access_group_id "__relay_access__" + @add_user_kind 8_000 + @remove_user_kind 8_001 + @join_request_kind 28_934 + @invite_request_kind 28_935 + @leave_request_kind 28_936 + @membership_list_kind 13_534 @spec handle_event(map()) :: :ok | {:error, term()} def handle_event(event) when is_map(event) do case Map.get(event, "kind") do - @membership_request_kind -> upsert_membership(event, "requested") - @membership_approval_kind -> upsert_membership(event, "member") - @membership_event_kind -> upsert_membership(event, "member") - @relay_metadata_kind -> :ok - @relay_admins_kind -> :ok - @relay_rules_kind -> :ok + @join_request_kind -> put_member(event, membership_pubkey_from_event(event)) + @leave_request_kind -> delete_member(event, membership_pubkey_from_event(event)) + @add_user_kind -> put_member(event, tagged_pubkey(event, "p")) + @remove_user_kind -> delete_member(event, tagged_pubkey(event, "p")) + @membership_list_kind -> replace_membership_snapshot(event) + @invite_request_kind -> :ok _other -> :ok end end - @spec group_related_kind?(non_neg_integer()) :: boolean() - def group_related_kind?(kind) + @spec relay_access_kind?(non_neg_integer()) :: boolean() + def relay_access_kind?(kind) when kind in [ - @membership_request_kind, - @membership_approval_kind, - @relay_metadata_kind, - @relay_admins_kind, - @relay_rules_kind, - @membership_event_kind + @add_user_kind, + @remove_user_kind, + @join_request_kind, + @invite_request_kind, + @leave_request_kind, + @membership_list_kind ], do: true - def group_related_kind?(_kind), do: false + def relay_access_kind?(_kind), do: false - defp upsert_membership(event, role) do - with {:ok, group_id} <- group_id_from_event(event), - {:ok, pubkey} <- pubkey_from_event(event) do + @spec get_membership(binary()) :: {:ok, map() | nil} | {:error, term()} + def get_membership(pubkey) when is_binary(pubkey) do + Storage.groups().get_membership(%{}, @relay_access_group_id, pubkey) + end + + @spec list_memberships() :: {:ok, [map()]} | {:error, term()} + def list_memberships do + Storage.groups().list_memberships(%{}, @relay_access_group_id) + end + + defp put_member(event, {:ok, pubkey}) do + with {:ok, metadata} <- membership_metadata(event) do Storage.groups().put_membership(%{}, %{ - group_id: group_id, + group_id: @relay_access_group_id, pubkey: pubkey, - role: role, - metadata: %{"source_kind" => Map.get(event, "kind")} + role: "member", + metadata: metadata }) |> case do {:ok, _membership} -> :ok @@ -55,21 +65,85 @@ defmodule Parrhesia.Groups.Flow do end end - defp group_id_from_event(event) do - group_id = - event - |> Map.get("tags", []) - |> Enum.find_value(fn - ["h", value | _rest] when is_binary(value) and value != "" -> value - _tag -> nil - end) + defp put_member(_event, {:error, reason}), do: {:error, reason} - case group_id do - nil -> {:error, :missing_group_id} - value -> {:ok, value} + defp delete_member(_event, {:ok, pubkey}) do + Storage.groups().delete_membership(%{}, @relay_access_group_id, pubkey) + end + + defp delete_member(_event, {:error, reason}), do: {:error, reason} + + defp replace_membership_snapshot(event) do + with {:ok, tagged_members} <- tagged_pubkeys(event, "member"), + {:ok, existing_memberships} <- list_memberships() do + incoming_pubkeys = MapSet.new(tagged_members) + existing_pubkeys = MapSet.new(Enum.map(existing_memberships, & &1.pubkey)) + + remove_members = + existing_pubkeys + |> MapSet.difference(incoming_pubkeys) + |> MapSet.to_list() + + add_members = + incoming_pubkeys + |> MapSet.to_list() + + :ok = remove_memberships(remove_members) + add_memberships(event, add_members) + else + {:error, reason} -> {:error, reason} end end - defp pubkey_from_event(%{"pubkey" => pubkey}) when is_binary(pubkey), do: {:ok, pubkey} - defp pubkey_from_event(_event), do: {:error, :missing_pubkey} + defp membership_pubkey_from_event(%{"pubkey" => pubkey}) when is_binary(pubkey), + do: {:ok, pubkey} + + defp membership_pubkey_from_event(_event), do: {:error, :missing_pubkey} + + defp tagged_pubkey(event, tag_name) do + event + |> tagged_pubkeys(tag_name) + |> case do + {:ok, [pubkey]} -> {:ok, pubkey} + {:ok, []} -> {:error, :missing_pubkey} + {:ok, _pubkeys} -> {:error, :invalid_pubkey} + end + end + + defp tagged_pubkeys(event, tag_name) do + pubkeys = + event + |> Map.get("tags", []) + |> Enum.flat_map(fn + [^tag_name, pubkey | _rest] when is_binary(pubkey) and pubkey != "" -> [pubkey] + _tag -> [] + end) + + {:ok, Enum.uniq(pubkeys)} + end + + defp membership_metadata(event) do + {:ok, + %{ + "source_kind" => Map.get(event, "kind"), + "source_event_id" => Map.get(event, "id") + }} + end + + defp remove_memberships(pubkeys) when is_list(pubkeys) do + Enum.each(pubkeys, fn pubkey -> + :ok = Storage.groups().delete_membership(%{}, @relay_access_group_id, pubkey) + end) + + :ok + end + + defp add_memberships(event, pubkeys) when is_list(pubkeys) do + Enum.reduce_while(pubkeys, :ok, fn pubkey, :ok -> + case put_member(event, {:ok, pubkey}) do + :ok -> {:cont, :ok} + {:error, _reason} = error -> {:halt, error} + end + end) + end end diff --git a/lib/parrhesia/nip43.ex b/lib/parrhesia/nip43.ex new file mode 100644 index 0000000..2b65f00 --- /dev/null +++ b/lib/parrhesia/nip43.ex @@ -0,0 +1,389 @@ +defmodule Parrhesia.NIP43 do + @moduledoc false + + alias Parrhesia.API.Events + alias Parrhesia.API.Identity + alias Parrhesia.API.RequestContext + alias Parrhesia.Groups.Flow + alias Parrhesia.Protocol + alias Parrhesia.Protocol.Filter + + @join_request_kind 28_934 + @invite_request_kind 28_935 + @leave_request_kind 28_936 + @add_user_kind 8_000 + @remove_user_kind 8_001 + @membership_list_kind 13_534 + @claim_token_kind 31_943 + @default_invite_ttl_seconds 900 + + @type publish_state :: + :ok + | %{action: :join, duplicate?: boolean(), message: String.t()} + | %{action: :leave, duplicate?: boolean(), message: String.t()} + + @spec enabled?(keyword()) :: boolean() + def enabled?(opts \\ []) do + config(opts) + |> Keyword.get(:enabled, true) + |> Kernel.==(true) + end + + @spec prepare_publish(map(), keyword()) :: {:ok, publish_state()} | {:error, term()} + def prepare_publish(event, opts \\ []) when is_map(event) and is_list(opts) do + if enabled?(opts) do + prepare_enabled_publish(event, opts) + else + prepare_disabled_publish(event) + end + end + + @spec finalize_publish(map(), publish_state(), keyword()) :: :ok | {:ok, String.t()} + def finalize_publish(event, publish_state, opts \\ []) + + def finalize_publish(event, :ok, _opts) when is_map(event) do + case Map.get(event, "kind") do + kind when kind in [@add_user_kind, @remove_user_kind, @membership_list_kind] -> + Flow.handle_event(event) + + _other -> + :ok + end + end + + def finalize_publish(event, %{action: :join, duplicate?: true, message: message}, _opts) + when is_map(event) do + {:ok, message} + end + + def finalize_publish(event, %{action: :join, duplicate?: false, message: message}, opts) + when is_map(event) do + opts = Keyword.put_new(opts, :now, Map.get(event, "created_at")) + :ok = Flow.handle_event(event) + publish_membership_events(Map.get(event, "pubkey"), :add, opts) + {:ok, message} + end + + def finalize_publish(event, %{action: :leave, duplicate?: true, message: message}, _opts) + when is_map(event) do + {:ok, message} + end + + def finalize_publish(event, %{action: :leave, duplicate?: false, message: message}, opts) + when is_map(event) do + opts = Keyword.put_new(opts, :now, Map.get(event, "created_at")) + :ok = Flow.handle_event(event) + publish_membership_events(Map.get(event, "pubkey"), :remove, opts) + {:ok, message} + end + + @spec dynamic_events([map()], keyword()) :: [map()] + def dynamic_events(filters, opts \\ []) when is_list(filters) and is_list(opts) do + if enabled?(opts) and requests_invite?(filters) do + filters + |> build_invite_event(opts) + |> maybe_wrap_event() + else + [] + end + end + + @spec dynamic_count([map()], keyword()) :: non_neg_integer() + def dynamic_count(filters, opts \\ []) do + filters + |> dynamic_events(opts) + |> length() + end + + defp prepare_enabled_publish(%{"kind" => @join_request_kind, "pubkey" => pubkey} = event, opts) + when is_binary(pubkey) do + with {:ok, _claim} <- validate_claim_from_event(event), + {:ok, membership} <- Flow.get_membership(pubkey) do + if membership_active?(membership) do + {:ok, + %{ + action: :join, + duplicate?: true, + message: "duplicate: you are already a member of this relay." + }} + else + {:ok, + %{ + action: :join, + duplicate?: false, + message: "info: welcome to #{relay_url(opts)}!" + }} + end + end + end + + defp prepare_enabled_publish(%{"kind" => @leave_request_kind, "pubkey" => pubkey}, _opts) + when is_binary(pubkey) do + with {:ok, membership} <- Flow.get_membership(pubkey) do + if membership_active?(membership) do + {:ok, %{action: :leave, duplicate?: false, message: "info: membership revoked."}} + else + {:ok, + %{ + action: :leave, + duplicate?: true, + message: "duplicate: you are not a member of this relay." + }} + end + end + end + + defp prepare_enabled_publish(%{"kind" => @invite_request_kind}, _opts) do + {:error, "restricted: kind 28935 invite claims are generated via REQ"} + end + + defp prepare_enabled_publish(%{"kind" => kind, "pubkey" => pubkey}, _opts) + when kind in [@add_user_kind, @remove_user_kind, @membership_list_kind] and + is_binary(pubkey) do + case relay_pubkey() do + {:ok, ^pubkey} -> {:ok, :ok} + {:ok, _other} -> {:error, "restricted: relay access metadata must be relay-signed"} + {:error, _reason} -> {:error, "error: relay identity unavailable"} + end + end + + defp prepare_enabled_publish(_event, _opts), do: {:ok, :ok} + + defp prepare_disabled_publish(%{"kind" => kind}) + when kind in [ + @join_request_kind, + @invite_request_kind, + @leave_request_kind, + @add_user_kind, + @remove_user_kind, + @membership_list_kind + ] do + {:error, "blocked: NIP-43 relay access requests are disabled"} + end + + defp prepare_disabled_publish(_event), do: {:ok, :ok} + + defp build_invite_event(filters, opts) do + now = Keyword.get(opts, :now, System.system_time(:second)) + identity_opts = identity_opts(opts) + + with {:ok, claim} <- issue_claim(now, opts), + {:ok, signed_event} <- + %{ + "created_at" => now, + "kind" => @invite_request_kind, + "tags" => [["-"], ["claim", claim]], + "content" => "" + } + |> Identity.sign_event(identity_opts), + true <- Filter.matches_any?(signed_event, filters) do + {:ok, signed_event} + else + _other -> :error + end + end + + defp maybe_wrap_event({:ok, event}), do: [event] + defp maybe_wrap_event(_other), do: [] + + defp requests_invite?(filters) do + Enum.any?(filters, fn filter -> + case Map.get(filter, "kinds") do + kinds when is_list(kinds) -> @invite_request_kind in kinds + _other -> false + end + end) + end + + defp issue_claim(now, opts) do + ttl_seconds = + config(opts) + |> Keyword.get(:invite_ttl_seconds, @default_invite_ttl_seconds) + |> normalize_positive_integer(@default_invite_ttl_seconds) + + identity_opts = identity_opts(opts) + + token_event = %{ + "created_at" => now, + "kind" => @claim_token_kind, + "tags" => [["exp", Integer.to_string(now + ttl_seconds)]], + "content" => Base.encode16(:crypto.strong_rand_bytes(16), case: :lower) + } + + with {:ok, signed_token} <- Identity.sign_event(token_event, identity_opts) do + signed_token + |> JSON.encode!() + |> Base.url_encode64(padding: false) + |> then(&{:ok, &1}) + end + end + + defp validate_claim_from_event(event) do + claim = + event + |> Map.get("tags", []) + |> Enum.find_value(fn + ["claim", value | _rest] when is_binary(value) and value != "" -> value + _tag -> nil + end) + + case claim do + nil -> {:error, "restricted: that is an invalid invite code."} + value -> validate_claim(value) + end + end + + defp validate_claim(claim) when is_binary(claim) do + with {:ok, payload} <- Base.url_decode64(claim, padding: false), + {:ok, decoded} <- JSON.decode(payload), + :ok <- Protocol.validate_event(decoded), + :ok <- validate_claim_token(decoded) do + {:ok, decoded} + else + {:error, :expired_claim} -> + {:error, "restricted: that invite code is expired."} + + _other -> + {:error, "restricted: that is an invalid invite code."} + end + end + + defp validate_claim(_claim), do: {:error, "restricted: that is an invalid invite code."} + + defp validate_claim_token(%{ + "kind" => @claim_token_kind, + "pubkey" => pubkey, + "tags" => tags + }) do + with {:ok, relay_pubkey} <- relay_pubkey(), + true <- pubkey == relay_pubkey, + {:ok, expires_at} <- fetch_expiration(tags), + true <- expires_at >= System.system_time(:second) do + :ok + else + false -> {:error, :invalid_claim} + {:error, _reason} -> {:error, :invalid_claim} + end + end + + defp validate_claim_token(_event), do: {:error, :invalid_claim} + + defp fetch_expiration(tags) when is_list(tags) do + case Enum.find(tags, &match?(["exp", _value | _rest], &1)) do + ["exp", value | _rest] -> + parse_expiration(value) + + _other -> + {:error, :invalid_claim} + end + end + + defp parse_expiration(value) when is_binary(value) do + case Integer.parse(value) do + {expires_at, ""} when expires_at > 0 -> validate_expiration(expires_at) + _other -> {:error, :invalid_claim} + end + end + + defp parse_expiration(_value), do: {:error, :invalid_claim} + + defp validate_expiration(expires_at) when is_integer(expires_at) do + if expires_at >= System.system_time(:second) do + {:ok, expires_at} + else + {:error, :expired_claim} + end + end + + defp validate_expiration(_expires_at), do: {:error, :expired_claim} + + defp publish_membership_events(member_pubkey, action, opts) when is_binary(member_pubkey) do + now = Keyword.get(opts, :now, System.system_time(:second)) + identity_opts = identity_opts(opts) + context = Keyword.get(opts, :context, %RequestContext{}) + + action + |> build_membership_delta_event(member_pubkey, now) + |> sign_and_publish(context, identity_opts) + + current_membership_snapshot(now) + |> sign_and_publish(context, identity_opts) + + :ok + end + + defp build_membership_delta_event(:add, member_pubkey, now) do + %{ + "created_at" => now, + "kind" => @add_user_kind, + "tags" => [["-"], ["p", member_pubkey]], + "content" => "" + } + end + + defp build_membership_delta_event(:remove, member_pubkey, now) do + %{ + "created_at" => now, + "kind" => @remove_user_kind, + "tags" => [["-"], ["p", member_pubkey]], + "content" => "" + } + end + + defp current_membership_snapshot(now) do + tags = + case Flow.list_memberships() do + {:ok, memberships} -> + [["-"] | Enum.map(memberships, &["member", &1.pubkey])] + + {:error, _reason} -> + [["-"]] + end + + %{ + "created_at" => now, + "kind" => @membership_list_kind, + "tags" => tags, + "content" => "" + } + end + + defp sign_and_publish(unsigned_event, context, identity_opts) do + with {:ok, signed_event} <- Identity.sign_event(unsigned_event, identity_opts), + {:ok, %{accepted: true}} <- Events.publish(signed_event, context: context) do + :ok + else + _other -> :ok + end + end + + defp membership_active?(nil), do: false + defp membership_active?(%{role: "member"}), do: true + defp membership_active?(_membership), do: false + + defp relay_pubkey do + case Identity.get() do + {:ok, %{pubkey: pubkey}} when is_binary(pubkey) -> {:ok, pubkey} + {:error, reason} -> {:error, reason} + end + end + + defp relay_url(opts) do + Keyword.get(opts, :relay_url, Application.get_env(:parrhesia, :relay_url)) + end + + defp identity_opts(opts) do + opts + |> Keyword.take([:path, :private_key, :configured_private_key]) + end + + defp config(opts) do + case Keyword.get(opts, :config) do + config when is_list(config) -> config + _other -> Application.get_env(:parrhesia, :nip43, []) + end + end + + defp normalize_positive_integer(value, _default) when is_integer(value) and value > 0, do: value + defp normalize_positive_integer(_value, default), do: default +end diff --git a/lib/parrhesia/policy/event_policy.ex b/lib/parrhesia/policy/event_policy.ex index 6a1ef8d..e857b6a 100644 --- a/lib/parrhesia/policy/event_policy.ex +++ b/lib/parrhesia/policy/event_policy.ex @@ -686,19 +686,29 @@ defmodule Parrhesia.Policy.EventPolicy do _tag -> false end) - if protected? do - pubkey = Map.get(event, "pubkey") + cond do + not protected? -> + :ok - cond do - MapSet.size(authenticated_pubkeys) == 0 -> {:error, :protected_event_requires_auth} - MapSet.member?(authenticated_pubkeys, pubkey) -> :ok - true -> {:error, :protected_event_pubkey_mismatch} - end - else - :ok + nip43_relay_access_kind?(Map.get(event, "kind")) -> + :ok + + true -> + pubkey = Map.get(event, "pubkey") + + cond do + MapSet.size(authenticated_pubkeys) == 0 -> {:error, :protected_event_requires_auth} + MapSet.member?(authenticated_pubkeys, pubkey) -> :ok + true -> {:error, :protected_event_pubkey_mismatch} + end end end + defp nip43_relay_access_kind?(kind) when kind in [8_000, 8_001, 13_534, 28_934, 28_935, 28_936], + do: true + + defp nip43_relay_access_kind?(_kind), do: false + defp config_bool([scope, key], default) do case Application.get_env(:parrhesia, scope, []) |> Keyword.get(key, default) do true -> true diff --git a/lib/parrhesia/protocol/event_validator.ex b/lib/parrhesia/protocol/event_validator.ex index e0ee211..94c92df 100644 --- a/lib/parrhesia/protocol/event_validator.ex +++ b/lib/parrhesia/protocol/event_validator.ex @@ -7,6 +7,7 @@ defmodule Parrhesia.Protocol.EventValidator do @max_kind 65_535 @default_max_event_future_skew_seconds 900 @default_max_tags_per_event 256 + @default_nip43_request_max_age_seconds 300 @supported_mls_ciphersuites MapSet.new(~w[0x0001 0x0002 0x0003 0x0004 0x0005 0x0006 0x0007]) @required_mls_extensions MapSet.new(["0xf2ee", "0x000a"]) @supported_keypackage_ref_sizes [32, 48, 64] @@ -53,6 +54,15 @@ defmodule Parrhesia.Protocol.EventValidator do | :invalid_nip66_frequency_tag | :invalid_nip66_timeout_tag | :invalid_nip66_check_tag + | :missing_nip43_protected_tag + | :missing_nip43_claim_tag + | :invalid_nip43_claim_tag + | :missing_nip43_member_tag + | :invalid_nip43_member_tag + | :missing_nip43_pubkey_tag + | :invalid_nip43_pubkey_tag + | :stale_nip43_join_request + | :stale_nip43_leave_request @spec validate(map()) :: :ok | {:error, error_reason()} def validate(event) when is_map(event) do @@ -149,7 +159,23 @@ defmodule Parrhesia.Protocol.EventValidator do "invalid: kind 10166 must include a single [\"frequency\", ] tag", invalid_nip66_timeout_tag: "invalid: kind 10166 timeout tags must be [\"timeout\", , ]", - invalid_nip66_check_tag: "invalid: kind 10166 c tags must contain lowercase check names" + invalid_nip66_check_tag: "invalid: kind 10166 c tags must contain lowercase check names", + missing_nip43_protected_tag: + "invalid: NIP-43 events must include a NIP-70 protected [\"-\"] tag", + missing_nip43_claim_tag: + "invalid: kinds 28934 and 28935 must include a single [\"claim\", ] tag", + invalid_nip43_claim_tag: + "invalid: kinds 28934 and 28935 must include a single [\"claim\", ] tag", + missing_nip43_member_tag: + "invalid: kind 13534 must include at least one [\"member\", ] tag", + invalid_nip43_member_tag: + "invalid: kind 13534 member tags must contain lowercase hex pubkeys", + missing_nip43_pubkey_tag: + "invalid: kinds 8000 and 8001 must include a single [\"p\", ] tag", + invalid_nip43_pubkey_tag: + "invalid: kinds 8000 and 8001 must include a single [\"p\", ] tag", + stale_nip43_join_request: "invalid: kind 28934 created_at must be recent", + stale_nip43_leave_request: "invalid: kind 28936 created_at must be recent" } @spec error_message(error_reason()) :: String.t() @@ -277,6 +303,21 @@ defmodule Parrhesia.Protocol.EventValidator do defp validate_kind_specific(%{"kind" => 10_166} = event), do: validate_nip66_monitor_announcement(event) + defp validate_kind_specific(%{"kind" => 13_534} = event), + do: validate_nip43_membership_list(event) + + defp validate_kind_specific(%{"kind" => kind} = event) when kind in [8_000, 8_001], + do: validate_nip43_membership_delta(event) + + defp validate_kind_specific(%{"kind" => 28_934} = event), + do: validate_nip43_join_request(event) + + defp validate_kind_specific(%{"kind" => 28_935} = event), + do: validate_nip43_invite_response(event) + + defp validate_kind_specific(%{"kind" => 28_936} = event), + do: validate_nip43_leave_request(event) + defp validate_kind_specific(_event), do: :ok defp validate_marmot_keypackage_event(event) do @@ -454,6 +495,80 @@ defmodule Parrhesia.Protocol.EventValidator do end end + defp validate_nip43_membership_list(event) do + tags = Map.get(event, "tags", []) + + case validate_protected_tag(tags) do + :ok -> validate_optional_repeated_pubkey_tag(tags, "member", :invalid_nip43_member_tag) + {:error, _reason} = error -> error + end + end + + defp validate_nip43_membership_delta(event) do + tags = Map.get(event, "tags", []) + + case validate_protected_tag(tags) do + :ok -> + validate_single_pubkey_tag( + tags, + "p", + :missing_nip43_pubkey_tag, + :invalid_nip43_pubkey_tag + ) + + {:error, _reason} = error -> + error + end + end + + defp validate_nip43_join_request(event) do + tags = Map.get(event, "tags", []) + + case validate_protected_tag(tags) do + :ok -> + with :ok <- + validate_single_string_tag_with_predicate( + tags, + "claim", + :missing_nip43_claim_tag, + :invalid_nip43_claim_tag, + &non_empty_string?/1 + ) do + validate_recent_created_at(event, :stale_nip43_join_request) + end + + {:error, _reason} = error -> + error + end + end + + defp validate_nip43_invite_response(event) do + tags = Map.get(event, "tags", []) + + case validate_protected_tag(tags) do + :ok -> + validate_single_string_tag_with_predicate( + tags, + "claim", + :missing_nip43_claim_tag, + :invalid_nip43_claim_tag, + &non_empty_string?/1 + ) + + {:error, _reason} = error -> + error + end + end + + defp validate_nip43_leave_request(event) do + tags = Map.get(event, "tags", []) + + case validate_protected_tag(tags) do + :ok -> validate_recent_created_at(event, :stale_nip43_leave_request) + {:error, _reason} = error -> error + end + end + defp validate_non_empty_base64_content(event), do: validate_non_empty_base64_content(event, :invalid_marmot_keypackage_content) @@ -626,6 +741,55 @@ defmodule Parrhesia.Protocol.EventValidator do end) end + defp validate_protected_tag(tags) do + if Enum.any?(tags, &match?(["-"], &1)) do + :ok + else + {:error, :missing_nip43_protected_tag} + end + end + + defp validate_single_pubkey_tag(tags, tag_name, missing_error, invalid_error) do + case fetch_single_tag(tags, tag_name, missing_error) do + {:ok, [^tag_name, value]} -> + if lowercase_hex?(value, 32) do + :ok + else + {:error, invalid_error} + end + + {:ok, _invalid_tag_shape} -> + {:error, invalid_error} + + {:error, _reason} = error -> + error + end + end + + defp validate_optional_repeated_pubkey_tag(tags, tag_name, invalid_error) do + matching_tags = Enum.filter(tags, &match_tag_name?(&1, tag_name)) + + if Enum.all?(matching_tags, fn + [^tag_name, pubkey | _rest] -> lowercase_hex?(pubkey, 32) + _other -> false + end) do + :ok + else + {:error, invalid_error} + end + end + + defp validate_recent_created_at(%{"created_at" => created_at}, error_reason) + when is_integer(created_at) do + if created_at >= System.system_time(:second) - nip43_request_max_age_seconds() do + :ok + else + {:error, error_reason} + end + end + + defp validate_recent_created_at(_event, error_reason), do: {:error, error_reason} + defp fetch_single_tag(tags, tag_name, missing_error) do case Enum.filter(tags, &match_tag_name?(&1, tag_name)) do [tag] -> {:ok, tag} @@ -754,4 +918,10 @@ defmodule Parrhesia.Protocol.EventValidator do _other -> @default_max_tags_per_event end end + + defp nip43_request_max_age_seconds do + :parrhesia + |> Application.get_env(:nip43, []) + |> Keyword.get(:request_max_age_seconds, @default_nip43_request_max_age_seconds) + end end diff --git a/lib/parrhesia/web/relay_info.ex b/lib/parrhesia/web/relay_info.ex index d34bc66..683e279 100644 --- a/lib/parrhesia/web/relay_info.ex +++ b/lib/parrhesia/web/relay_info.ex @@ -4,6 +4,7 @@ defmodule Parrhesia.Web.RelayInfo do """ alias Parrhesia.API.Identity + alias Parrhesia.NIP43 alias Parrhesia.Web.Listener @spec document(Listener.t()) :: map() @@ -21,13 +22,20 @@ defmodule Parrhesia.Web.RelayInfo do end defp supported_nips do - base = [1, 9, 11, 13, 17, 40, 42, 43, 44, 45, 50, 59, 62, 70] + base = [1, 9, 11, 13, 17, 40, 42, 44, 45, 50, 59, 62, 70] + + with_nip43 = + if NIP43.enabled?() do + base ++ [43] + else + base + end with_nip66 = if Parrhesia.NIP66.enabled?() do - base ++ [66] + with_nip43 ++ [66] else - base + with_nip43 end with_negentropy = diff --git a/test/parrhesia/groups/flow_test.exs b/test/parrhesia/groups/flow_test.exs index 151e160..8920dfb 100644 --- a/test/parrhesia/groups/flow_test.exs +++ b/test/parrhesia/groups/flow_test.exs @@ -4,24 +4,45 @@ defmodule Parrhesia.Groups.FlowTest do alias Parrhesia.Groups.Flow alias Parrhesia.Storage - test "handles membership request kinds by upserting group memberships" do + test "handles join requests by upserting relay memberships" do event = %{ - "kind" => 8_000, + "kind" => 28_934, "pubkey" => String.duplicate("a", 64), - "tags" => [["h", "group-1"]] + "tags" => [["-"], ["claim", "invite-code"]], + "id" => "join-1" } assert :ok = Flow.handle_event(event) - assert {:ok, membership} = - Storage.groups().get_membership(%{}, "group-1", String.duplicate("a", 64)) + assert {:ok, membership} = Flow.get_membership(String.duplicate("a", 64)) - assert membership.role == "requested" + assert membership.role == "member" + assert membership.metadata["source_kind"] == 28_934 end - test "marks configured membership and relay kinds as group related" do - assert Flow.group_related_kind?(8_000) - assert Flow.group_related_kind?(13_534) - refute Flow.group_related_kind?(1) + test "membership snapshot replaces the stored relay memberships" do + assert {:ok, _membership} = + Storage.groups().put_membership(%{}, %{ + group_id: "__relay_access__", + pubkey: String.duplicate("a", 64), + role: "member" + }) + + snapshot = %{ + "kind" => 13_534, + "tags" => [["-"], ["member", String.duplicate("b", 64)]], + "id" => "snapshot-1" + } + + assert :ok = Flow.handle_event(snapshot) + + assert {:ok, memberships} = Flow.list_memberships() + assert Enum.map(memberships, & &1.pubkey) == [String.duplicate("b", 64)] + end + + test "marks configured relay access kinds as handled" do + assert Flow.relay_access_kind?(28_934) + assert Flow.relay_access_kind?(13_534) + refute Flow.relay_access_kind?(1) end end diff --git a/test/parrhesia/nip43_test.exs b/test/parrhesia/nip43_test.exs new file mode 100644 index 0000000..4c962f8 --- /dev/null +++ b/test/parrhesia/nip43_test.exs @@ -0,0 +1,63 @@ +defmodule Parrhesia.NIP43Test do + use Parrhesia.IntegrationCase, async: false, sandbox: true + + alias Parrhesia.API.Events + alias Parrhesia.API.Identity + alias Parrhesia.API.RequestContext + alias Parrhesia.Groups.Flow + alias Parrhesia.Web.Listener + alias Parrhesia.Web.RelayInfo + + test "relay-authored membership snapshots replace the stored access list" do + first_snapshot = + signed_relay_event(13_534, [ + ["-"], + ["member", String.duplicate("a", 64)], + ["member", String.duplicate("b", 64)] + ]) + + second_snapshot = + signed_relay_event(13_534, [ + ["-"], + ["member", String.duplicate("b", 64)] + ]) + + assert {:ok, %{accepted: true}} = Events.publish(first_snapshot, context: %RequestContext{}) + assert {:ok, %{accepted: true}} = Events.publish(second_snapshot, context: %RequestContext{}) + + assert {:ok, memberships} = Flow.list_memberships() + assert Enum.map(memberships, & &1.pubkey) == [String.duplicate("b", 64)] + end + + test "count includes generated invite responses for kind 28935 filters" do + assert {:ok, 1} = Events.count([%{"kinds" => [28_935]}], context: %RequestContext{}) + end + + test "relay info only advertises 43 when NIP-43 is enabled" do + previous_config = Application.get_env(:parrhesia, :nip43, []) + + on_exit(fn -> + Application.put_env(:parrhesia, :nip43, previous_config) + end) + + listener = Listener.from_opts(id: :public) + + Application.put_env(:parrhesia, :nip43, enabled: false) + refute 43 in RelayInfo.document(listener)["supported_nips"] + + Application.put_env(:parrhesia, :nip43, enabled: true, invite_ttl_seconds: 900) + assert 43 in RelayInfo.document(listener)["supported_nips"] + end + + defp signed_relay_event(kind, tags) do + {:ok, event} = + Identity.sign_event(%{ + "created_at" => System.system_time(:second), + "kind" => kind, + "tags" => tags, + "content" => "" + }) + + event + end +end diff --git a/test/parrhesia/protocol/event_validator_nip43_test.exs b/test/parrhesia/protocol/event_validator_nip43_test.exs new file mode 100644 index 0000000..7c0a378 --- /dev/null +++ b/test/parrhesia/protocol/event_validator_nip43_test.exs @@ -0,0 +1,123 @@ +defmodule Parrhesia.Protocol.EventValidatorNIP43Test do + use ExUnit.Case, async: true + + alias Parrhesia.Protocol.EventValidator + + test "accepts valid NIP-43 relay access events" do + join_request = + build_event(%{ + "kind" => 28_934, + "tags" => [["-"], ["claim", "invite-code"]], + "content" => "" + }) + + invite_response = + build_event(%{ + "kind" => 28_935, + "tags" => [["-"], ["claim", "invite-code"]], + "content" => "" + }) + + leave_request = + build_event(%{ + "kind" => 28_936, + "tags" => [["-"]], + "content" => "" + }) + + add_user = + build_event(%{ + "kind" => 8_000, + "tags" => [["-"], ["p", String.duplicate("a", 64)]], + "content" => "" + }) + + remove_user = + build_event(%{ + "kind" => 8_001, + "tags" => [["-"], ["p", String.duplicate("b", 64)]], + "content" => "" + }) + + membership_list = + build_event(%{ + "kind" => 13_534, + "tags" => [ + ["-"], + ["member", String.duplicate("c", 64)], + ["member", String.duplicate("d", 64)] + ], + "content" => "" + }) + + assert :ok = EventValidator.validate(join_request) + assert :ok = EventValidator.validate(invite_response) + assert :ok = EventValidator.validate(leave_request) + assert :ok = EventValidator.validate(add_user) + assert :ok = EventValidator.validate(remove_user) + assert :ok = EventValidator.validate(membership_list) + end + + test "rejects malformed NIP-43 relay access events" do + stale_join = + build_event(%{ + "kind" => 28_934, + "created_at" => System.system_time(:second) - 301, + "tags" => [["-"], ["claim", "invite-code"]], + "content" => "" + }) + + missing_claim = + build_event(%{ + "kind" => 28_935, + "tags" => [["-"]], + "content" => "" + }) + + invalid_leave = + build_event(%{ + "kind" => 28_936, + "tags" => [], + "content" => "" + }) + + invalid_membership_delta = + build_event(%{ + "kind" => 8_000, + "tags" => [["-"], ["p", "not-a-pubkey"]], + "content" => "" + }) + + invalid_membership_list = + build_event(%{ + "kind" => 13_534, + "tags" => [["-"], ["member", "not-a-pubkey"]], + "content" => "" + }) + + assert {:error, :stale_nip43_join_request} = EventValidator.validate(stale_join) + assert {:error, :missing_nip43_claim_tag} = EventValidator.validate(missing_claim) + assert {:error, :missing_nip43_protected_tag} = EventValidator.validate(invalid_leave) + + assert {:error, :invalid_nip43_pubkey_tag} = + EventValidator.validate(invalid_membership_delta) + + assert {:error, :invalid_nip43_member_tag} = + EventValidator.validate(invalid_membership_list) + end + + defp build_event(overrides) do + event = + %{ + "pubkey" => String.duplicate("1", 64), + "created_at" => System.system_time(:second), + "kind" => 1, + "tags" => [], + "content" => "", + "sig" => String.duplicate("2", 128) + } + |> Map.merge(overrides) + + Map.put(event, "id", EventValidator.compute_id(event)) + end +end diff --git a/test/parrhesia/web/connection_nip43_test.exs b/test/parrhesia/web/connection_nip43_test.exs new file mode 100644 index 0000000..7cb1712 --- /dev/null +++ b/test/parrhesia/web/connection_nip43_test.exs @@ -0,0 +1,270 @@ +defmodule Parrhesia.Web.ConnectionNIP43Test do + use Parrhesia.IntegrationCase, async: false, sandbox: true + + alias Parrhesia.Groups.Flow + alias Parrhesia.Protocol + alias Parrhesia.Protocol.EventValidator + alias Parrhesia.Storage + alias Parrhesia.Web.Connection + + test "REQ for kind 28935 returns a relay-signed invite response" do + state = connection_state() + + assert {:push, frames, next_state} = + Connection.handle_in( + {JSON.encode!(["REQ", "sub-invite", %{"kinds" => [28_935]}]), [opcode: :text]}, + state + ) + + assert next_state.subscriptions["sub-invite"].eose_sent? + + decoded = Enum.map(frames, fn {:text, frame} -> JSON.decode!(frame) end) + assert ["EOSE", "sub-invite"] = List.last(decoded) + + assert ["EVENT", "sub-invite", invite_event] = + Enum.find(decoded, fn frame -> List.first(frame) == "EVENT" end) + + assert invite_event["kind"] == 28_935 + assert :ok = Protocol.validate_event(invite_event) + assert is_binary(claim_from_event(invite_event)) + end + + test "join request accepts valid claims, stores membership, and publishes membership events" do + invite_event = request_invite_event() + join_pubkey = String.duplicate("9", 64) + + join_request = + valid_event(%{ + "pubkey" => join_pubkey, + "kind" => 28_934, + "tags" => [["-"], ["claim", claim_from_event(invite_event)]], + "content" => "" + }) + + assert {:push, {:text, response}, _state} = + Connection.handle_in( + {JSON.encode!(["EVENT", join_request]), [opcode: :text]}, + connection_state() + ) + + assert JSON.decode!(response) == [ + "OK", + join_request["id"], + true, + "info: welcome to ws://localhost:4413/relay!" + ] + + assert {:ok, membership} = Flow.get_membership(join_pubkey) + assert membership.role == "member" + + assert {:ok, add_events} = + Storage.events().query(%{}, [%{"kinds" => [8_000], "#p" => [join_pubkey]}], []) + + assert length(add_events) == 1 + + assert {:ok, membership_list_events} = + Storage.events().query(%{}, [%{"kinds" => [13_534]}], []) + + assert Enum.any?(membership_list_events, fn event -> + ["member", join_pubkey] in event["tags"] + end) + end + + test "join request rejects invalid claims" do + join_request = + valid_event(%{ + "kind" => 28_934, + "tags" => [["-"], ["claim", "invalid-claim"]], + "content" => "" + }) + + assert {:push, {:text, response}, _state} = + Connection.handle_in( + {JSON.encode!(["EVENT", join_request]), [opcode: :text]}, + connection_state() + ) + + assert JSON.decode!(response) == [ + "OK", + join_request["id"], + false, + "restricted: that is an invalid invite code." + ] + end + + test "duplicate join and leave requests return duplicate messages" do + invite_event = request_invite_event() + member_pubkey = String.duplicate("8", 64) + + first_join = + valid_event(%{ + "pubkey" => member_pubkey, + "kind" => 28_934, + "tags" => [["-"], ["claim", claim_from_event(invite_event)]], + "content" => "" + }) + + second_join = + valid_event(%{ + "pubkey" => member_pubkey, + "created_at" => System.system_time(:second) + 1, + "kind" => 28_934, + "tags" => [["-"], ["claim", claim_from_event(invite_event)]], + "content" => "" + }) + + assert {:push, {:text, _response}, _state} = + Connection.handle_in( + {JSON.encode!(["EVENT", first_join]), [opcode: :text]}, + connection_state() + ) + + assert {:push, {:text, duplicate_join_response}, _state} = + Connection.handle_in( + {JSON.encode!(["EVENT", second_join]), [opcode: :text]}, + connection_state() + ) + + assert JSON.decode!(duplicate_join_response) == [ + "OK", + second_join["id"], + true, + "duplicate: you are already a member of this relay." + ] + + leave_request = + valid_event(%{ + "pubkey" => member_pubkey, + "kind" => 28_936, + "tags" => [["-"]], + "content" => "" + }) + + duplicate_leave = + valid_event(%{ + "pubkey" => member_pubkey, + "created_at" => System.system_time(:second) + 2, + "kind" => 28_936, + "tags" => [["-"]], + "content" => "" + }) + + assert {:push, {:text, leave_response}, _state} = + Connection.handle_in( + {JSON.encode!(["EVENT", leave_request]), [opcode: :text]}, + connection_state() + ) + + assert JSON.decode!(leave_response) == [ + "OK", + leave_request["id"], + true, + "info: membership revoked." + ] + + assert {:push, {:text, duplicate_leave_response}, _state} = + Connection.handle_in( + {JSON.encode!(["EVENT", duplicate_leave]), [opcode: :text]}, + connection_state() + ) + + assert JSON.decode!(duplicate_leave_response) == [ + "OK", + duplicate_leave["id"], + true, + "duplicate: you are not a member of this relay." + ] + end + + test "leave request publishes a remove event and prunes the membership list" do + invite_event = request_invite_event() + member_pubkey = String.duplicate("7", 64) + + join_request = + valid_event(%{ + "pubkey" => member_pubkey, + "kind" => 28_934, + "tags" => [["-"], ["claim", claim_from_event(invite_event)]], + "content" => "" + }) + + leave_request = + valid_event(%{ + "pubkey" => member_pubkey, + "created_at" => System.system_time(:second) + 1, + "kind" => 28_936, + "tags" => [["-"]], + "content" => "" + }) + + assert {:push, {:text, _response}, _state} = + Connection.handle_in( + {JSON.encode!(["EVENT", join_request]), [opcode: :text]}, + connection_state() + ) + + assert {:push, {:text, _response}, _state} = + Connection.handle_in( + {JSON.encode!(["EVENT", leave_request]), [opcode: :text]}, + connection_state() + ) + + assert {:ok, nil} = Flow.get_membership(member_pubkey) + + assert {:ok, remove_events} = + Storage.events().query(%{}, [%{"kinds" => [8_001], "#p" => [member_pubkey]}], []) + + assert length(remove_events) == 1 + + assert {:ok, membership_list_events} = + Storage.events().query(%{}, [%{"kinds" => [13_534]}], []) + + assert Enum.any?(membership_list_events, fn event -> + ["member", member_pubkey] not in event["tags"] + end) + end + + defp request_invite_event do + state = connection_state() + + assert {:push, frames, _next_state} = + Connection.handle_in( + {JSON.encode!(["REQ", "sub-invite", %{"kinds" => [28_935]}]), [opcode: :text]}, + state + ) + + frames + |> Enum.map(fn {:text, frame} -> JSON.decode!(frame) end) + |> Enum.find_value(fn + ["EVENT", "sub-invite", invite_event] -> invite_event + _frame -> nil + end) + end + + defp claim_from_event(event) do + event + |> Map.get("tags", []) + |> Enum.find_value(fn + ["claim", claim | _rest] -> claim + _tag -> nil + end) + end + + defp connection_state(opts \\ []) do + {:ok, state} = Connection.init(Keyword.put_new(opts, :subscription_index, nil)) + state + end + + defp valid_event(overrides) do + %{ + "pubkey" => String.duplicate("1", 64), + "created_at" => System.system_time(:second), + "kind" => 1, + "tags" => [], + "content" => "", + "sig" => String.duplicate("3", 128) + } + |> Map.merge(overrides) + |> then(fn event -> Map.put(event, "id", EventValidator.compute_id(event)) end) + end +end