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

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

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