Implement full NIP-43 relay access flow

This commit is contained in:
2026-03-18 15:28:15 +01:00
parent f2856d000e
commit f732d9cf24
12 changed files with 1226 additions and 74 deletions

View File

@@ -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` `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. `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 ## 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 | | `:identity.private_key` | `PARRHESIA_IDENTITY_PRIVATE_KEY` | `nil` | Optional inline relay private key |
| `:moderation_cache_enabled` | `PARRHESIA_MODERATION_CACHE_ENABLED` | `true` | Toggle moderation cache | | `:moderation_cache_enabled` | `PARRHESIA_MODERATION_CACHE_ENABLED` | `true` | Toggle moderation cache |
| `:enable_expiration_worker` | `PARRHESIA_ENABLE_EXPIRATION_WORKER` | `true` | Toggle background expiration worker | | `: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 | | `: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.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 | | `: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. 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` #### `:limits`
| Atom key | ENV | Default | | Atom key | ENV | Default |

View File

@@ -5,6 +5,11 @@ config :postgrex, :json_library, JSON
config :parrhesia, config :parrhesia,
moderation_cache_enabled: true, moderation_cache_enabled: true,
relay_url: "ws://localhost:4413/relay", relay_url: "ws://localhost:4413/relay",
nip43: [
enabled: true,
invite_ttl_seconds: 900,
request_max_age_seconds: 300
],
nip66: [ nip66: [
enabled: true, enabled: true,
publish_interval_seconds: 900, publish_interval_seconds: 900,

View File

@@ -7,7 +7,7 @@ defmodule Parrhesia.API.Events do
alias Parrhesia.API.RequestContext alias Parrhesia.API.RequestContext
alias Parrhesia.Fanout.Dispatcher alias Parrhesia.Fanout.Dispatcher
alias Parrhesia.Fanout.MultiNode alias Parrhesia.Fanout.MultiNode
alias Parrhesia.Groups.Flow alias Parrhesia.NIP43
alias Parrhesia.Policy.EventPolicy alias Parrhesia.Policy.EventPolicy
alias Parrhesia.Protocol alias Parrhesia.Protocol
alias Parrhesia.Protocol.Filter alias Parrhesia.Protocol.Filter
@@ -40,7 +40,7 @@ defmodule Parrhesia.API.Events do
:ok <- validate_event_payload_size(event, max_event_bytes(opts)), :ok <- validate_event_payload_size(event, max_event_bytes(opts)),
:ok <- Protocol.validate_event(event), :ok <- Protocol.validate_event(event),
:ok <- EventPolicy.authorize_write(event, context.authenticated_pubkeys, context), :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 {:ok, _stored, message} <- persist_event(event) do
Telemetry.emit( Telemetry.emit(
[:parrhesia, :ingest, :stop], [:parrhesia, :ingest, :stop],
@@ -48,6 +48,12 @@ defmodule Parrhesia.API.Events do
telemetry_metadata_for_event(event) 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) Dispatcher.dispatch(event)
maybe_publish_multi_node(event) maybe_publish_multi_node(event)
@@ -85,6 +91,8 @@ defmodule Parrhesia.API.Events do
:ok <- maybe_validate_filters(filters, opts), :ok <- maybe_validate_filters(filters, opts),
:ok <- maybe_authorize_read(filters, context, opts), :ok <- maybe_authorize_read(filters, context, opts),
{:ok, events} <- Storage.events().query(%{}, filters, storage_query_opts(context, opts)) do {:ok, events} <- Storage.events().query(%{}, filters, storage_query_opts(context, opts)) do
events = NIP43.dynamic_events(filters, nip43_opts(opts, context)) ++ events
Telemetry.emit( Telemetry.emit(
[:parrhesia, :query, :stop], [:parrhesia, :query, :stop],
%{duration: System.monotonic_time() - started_at}, %{duration: System.monotonic_time() - started_at},
@@ -108,6 +116,7 @@ defmodule Parrhesia.API.Events do
:ok <- maybe_authorize_read(filters, context, opts), :ok <- maybe_authorize_read(filters, context, opts),
{:ok, count} <- {:ok, count} <-
Storage.events().count(%{}, filters, requester_pubkeys: requester_pubkeys(context)), 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 {:ok, result} <- maybe_build_count_result(filters, count, Keyword.get(opts, :options)) do
Telemetry.emit( Telemetry.emit(
[:parrhesia, :query, :stop], [:parrhesia, :query, :stop],
@@ -184,14 +193,6 @@ defmodule Parrhesia.API.Events do
|> Base.encode64() |> Base.encode64()
end 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 defp persist_event(event) do
kind = Map.get(event, "kind") kind = Map.get(event, "kind")
@@ -282,6 +283,11 @@ defmodule Parrhesia.API.Events do
end end
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), defp error_message_for_publish_failure(:duplicate_event),
do: "duplicate: event already stored" do: "duplicate: event already stored"

View File

@@ -1,52 +1,62 @@
defmodule Parrhesia.Groups.Flow do defmodule Parrhesia.Groups.Flow do
@moduledoc """ @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 alias Parrhesia.Storage
@membership_request_kind 8_000 @relay_access_group_id "__relay_access__"
@membership_approval_kind 8_001 @add_user_kind 8_000
@relay_metadata_kind 28_934 @remove_user_kind 8_001
@relay_admins_kind 28_935 @join_request_kind 28_934
@relay_rules_kind 28_936 @invite_request_kind 28_935
@membership_event_kind 13_534 @leave_request_kind 28_936
@membership_list_kind 13_534
@spec handle_event(map()) :: :ok | {:error, term()} @spec handle_event(map()) :: :ok | {:error, term()}
def handle_event(event) when is_map(event) do def handle_event(event) when is_map(event) do
case Map.get(event, "kind") do case Map.get(event, "kind") do
@membership_request_kind -> upsert_membership(event, "requested") @join_request_kind -> put_member(event, membership_pubkey_from_event(event))
@membership_approval_kind -> upsert_membership(event, "member") @leave_request_kind -> delete_member(event, membership_pubkey_from_event(event))
@membership_event_kind -> upsert_membership(event, "member") @add_user_kind -> put_member(event, tagged_pubkey(event, "p"))
@relay_metadata_kind -> :ok @remove_user_kind -> delete_member(event, tagged_pubkey(event, "p"))
@relay_admins_kind -> :ok @membership_list_kind -> replace_membership_snapshot(event)
@relay_rules_kind -> :ok @invite_request_kind -> :ok
_other -> :ok _other -> :ok
end end
end end
@spec group_related_kind?(non_neg_integer()) :: boolean() @spec relay_access_kind?(non_neg_integer()) :: boolean()
def group_related_kind?(kind) def relay_access_kind?(kind)
when kind in [ when kind in [
@membership_request_kind, @add_user_kind,
@membership_approval_kind, @remove_user_kind,
@relay_metadata_kind, @join_request_kind,
@relay_admins_kind, @invite_request_kind,
@relay_rules_kind, @leave_request_kind,
@membership_event_kind @membership_list_kind
], ],
do: true do: true
def group_related_kind?(_kind), do: false def relay_access_kind?(_kind), do: false
defp upsert_membership(event, role) do @spec get_membership(binary()) :: {:ok, map() | nil} | {:error, term()}
with {:ok, group_id} <- group_id_from_event(event), def get_membership(pubkey) when is_binary(pubkey) do
{:ok, pubkey} <- pubkey_from_event(event) 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(%{}, %{ Storage.groups().put_membership(%{}, %{
group_id: group_id, group_id: @relay_access_group_id,
pubkey: pubkey, pubkey: pubkey,
role: role, role: "member",
metadata: %{"source_kind" => Map.get(event, "kind")} metadata: metadata
}) })
|> case do |> case do
{:ok, _membership} -> :ok {:ok, _membership} -> :ok
@@ -55,21 +65,85 @@ defmodule Parrhesia.Groups.Flow do
end end
end end
defp group_id_from_event(event) do defp put_member(_event, {:error, reason}), do: {:error, reason}
group_id =
event
|> Map.get("tags", [])
|> Enum.find_value(fn
["h", value | _rest] when is_binary(value) and value != "" -> value
_tag -> nil
end)
case group_id do defp delete_member(_event, {:ok, pubkey}) do
nil -> {:error, :missing_group_id} Storage.groups().delete_membership(%{}, @relay_access_group_id, pubkey)
value -> {:ok, value} 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
end end
defp pubkey_from_event(%{"pubkey" => pubkey}) when is_binary(pubkey), do: {:ok, pubkey} defp membership_pubkey_from_event(%{"pubkey" => pubkey}) when is_binary(pubkey),
defp pubkey_from_event(_event), do: {:error, :missing_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 end

389
lib/parrhesia/nip43.ex Normal file
View File

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

View File

@@ -686,19 +686,29 @@ defmodule Parrhesia.Policy.EventPolicy do
_tag -> false _tag -> false
end) end)
if protected? do cond do
pubkey = Map.get(event, "pubkey") not protected? ->
:ok
cond do nip43_relay_access_kind?(Map.get(event, "kind")) ->
MapSet.size(authenticated_pubkeys) == 0 -> {:error, :protected_event_requires_auth} :ok
MapSet.member?(authenticated_pubkeys, pubkey) -> :ok
true -> {:error, :protected_event_pubkey_mismatch} true ->
end pubkey = Map.get(event, "pubkey")
else
: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
end 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 defp config_bool([scope, key], default) do
case Application.get_env(:parrhesia, scope, []) |> Keyword.get(key, default) do case Application.get_env(:parrhesia, scope, []) |> Keyword.get(key, default) do
true -> true true -> true

View File

@@ -7,6 +7,7 @@ defmodule Parrhesia.Protocol.EventValidator do
@max_kind 65_535 @max_kind 65_535
@default_max_event_future_skew_seconds 900 @default_max_event_future_skew_seconds 900
@default_max_tags_per_event 256 @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]) @supported_mls_ciphersuites MapSet.new(~w[0x0001 0x0002 0x0003 0x0004 0x0005 0x0006 0x0007])
@required_mls_extensions MapSet.new(["0xf2ee", "0x000a"]) @required_mls_extensions MapSet.new(["0xf2ee", "0x000a"])
@supported_keypackage_ref_sizes [32, 48, 64] @supported_keypackage_ref_sizes [32, 48, 64]
@@ -53,6 +54,15 @@ defmodule Parrhesia.Protocol.EventValidator do
| :invalid_nip66_frequency_tag | :invalid_nip66_frequency_tag
| :invalid_nip66_timeout_tag | :invalid_nip66_timeout_tag
| :invalid_nip66_check_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()} @spec validate(map()) :: :ok | {:error, error_reason()}
def validate(event) when is_map(event) do 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\", <seconds>] tag", "invalid: kind 10166 must include a single [\"frequency\", <seconds>] tag",
invalid_nip66_timeout_tag: invalid_nip66_timeout_tag:
"invalid: kind 10166 timeout tags must be [\"timeout\", <check>, <ms>]", "invalid: kind 10166 timeout tags must be [\"timeout\", <check>, <ms>]",
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\", <invite code>] tag",
invalid_nip43_claim_tag:
"invalid: kinds 28934 and 28935 must include a single [\"claim\", <invite code>] tag",
missing_nip43_member_tag:
"invalid: kind 13534 must include at least one [\"member\", <hex pubkey>] 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\", <hex pubkey>] tag",
invalid_nip43_pubkey_tag:
"invalid: kinds 8000 and 8001 must include a single [\"p\", <hex pubkey>] 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() @spec error_message(error_reason()) :: String.t()
@@ -277,6 +303,21 @@ defmodule Parrhesia.Protocol.EventValidator do
defp validate_kind_specific(%{"kind" => 10_166} = event), defp validate_kind_specific(%{"kind" => 10_166} = event),
do: validate_nip66_monitor_announcement(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_kind_specific(_event), do: :ok
defp validate_marmot_keypackage_event(event) do defp validate_marmot_keypackage_event(event) do
@@ -454,6 +495,80 @@ defmodule Parrhesia.Protocol.EventValidator do
end end
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), defp validate_non_empty_base64_content(event),
do: validate_non_empty_base64_content(event, :invalid_marmot_keypackage_content) do: validate_non_empty_base64_content(event, :invalid_marmot_keypackage_content)
@@ -626,6 +741,55 @@ defmodule Parrhesia.Protocol.EventValidator do
end) end)
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 defp fetch_single_tag(tags, tag_name, missing_error) do
case Enum.filter(tags, &match_tag_name?(&1, tag_name)) do case Enum.filter(tags, &match_tag_name?(&1, tag_name)) do
[tag] -> {:ok, tag} [tag] -> {:ok, tag}
@@ -754,4 +918,10 @@ defmodule Parrhesia.Protocol.EventValidator do
_other -> @default_max_tags_per_event _other -> @default_max_tags_per_event
end end
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 end

View File

@@ -4,6 +4,7 @@ defmodule Parrhesia.Web.RelayInfo do
""" """
alias Parrhesia.API.Identity alias Parrhesia.API.Identity
alias Parrhesia.NIP43
alias Parrhesia.Web.Listener alias Parrhesia.Web.Listener
@spec document(Listener.t()) :: map() @spec document(Listener.t()) :: map()
@@ -21,13 +22,20 @@ defmodule Parrhesia.Web.RelayInfo do
end end
defp supported_nips do 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 = with_nip66 =
if Parrhesia.NIP66.enabled?() do if Parrhesia.NIP66.enabled?() do
base ++ [66] with_nip43 ++ [66]
else else
base with_nip43
end end
with_negentropy = with_negentropy =

View File

@@ -4,24 +4,45 @@ defmodule Parrhesia.Groups.FlowTest do
alias Parrhesia.Groups.Flow alias Parrhesia.Groups.Flow
alias Parrhesia.Storage alias Parrhesia.Storage
test "handles membership request kinds by upserting group memberships" do test "handles join requests by upserting relay memberships" do
event = %{ event = %{
"kind" => 8_000, "kind" => 28_934,
"pubkey" => String.duplicate("a", 64), "pubkey" => String.duplicate("a", 64),
"tags" => [["h", "group-1"]] "tags" => [["-"], ["claim", "invite-code"]],
"id" => "join-1"
} }
assert :ok = Flow.handle_event(event) assert :ok = Flow.handle_event(event)
assert {:ok, membership} = assert {:ok, membership} = Flow.get_membership(String.duplicate("a", 64))
Storage.groups().get_membership(%{}, "group-1", String.duplicate("a", 64))
assert membership.role == "requested" assert membership.role == "member"
assert membership.metadata["source_kind"] == 28_934
end end
test "marks configured membership and relay kinds as group related" do test "membership snapshot replaces the stored relay memberships" do
assert Flow.group_related_kind?(8_000) assert {:ok, _membership} =
assert Flow.group_related_kind?(13_534) Storage.groups().put_membership(%{}, %{
refute Flow.group_related_kind?(1) 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
end end

View File

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

View File

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

View File

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